From f5e129fd3575914c48cc3964f35dff16b6723efd Mon Sep 17 00:00:00 2001 From: Alexander Date: Tue, 27 Oct 2026 10:33:12 +0100 Subject: [PATCH 01/40] fix(profiling): Cancel a pending stop when the profiler is started again (#6291) * fix(profiling): Cancel a pending stop when the profiler is started again A stop request only sets shouldStop, and the profiler stops when the current chunk ends. A start call before that was ignored because the profiler was still running, and shouldStop stayed set. So the profiler stopped at the end of the chunk, even in TRACE mode while sampled spans were active. The legacy profiler also never reset shouldStop, so after the first stop every later profile ended after one chunk. Reset shouldStop on every sampled start call in AndroidContinuousProfiler and PerfettoContinuousProfiler. A manual start while the profiler runs is now an expected call, so the Perfetto profiler logs it at DEBUG level like the legacy profiler. * changelog * docs(changelog): Move the profiler restart entry to Unreleased 8.61.1 was released after the branch was created, so the rebase landed the entry inside the released section. Move it back under a new Unreleased heading. --- CHANGELOG.md | 7 +++++++ .../core/AndroidContinuousProfiler.java | 2 ++ .../core/PerfettoContinuousProfiler.java | 7 +++---- .../core/AndroidContinuousProfilerTest.kt | 12 +++++++++++ .../core/ContinuousProfilerTestCases.kt | 20 +++++++++++++++++++ .../core/PerfettoContinuousProfilerTest.kt | 14 ++++++++----- 6 files changed, 53 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cda5f98dcba..85bbfc57c8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixes + +- Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291)) + - Before, the start call was ignored and the profiler still stopped at the end of the chunk, even in `TRACE` mode while sampled spans were active. On API 34 and lower, every later profile also stopped after one chunk. + ## 8.62.0 ### Improvements diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java index 20fea39a28d..4f115ca9d7f 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidContinuousProfiler.java @@ -131,6 +131,8 @@ public void startProfiler( logger.log(SentryLevel.DEBUG, "Profiler was not started due to sampling decision."); return; } + // A stop only takes effect when the current chunk ends, so a start before then cancels it. + shouldStop = false; switch (profileLifecycle) { case TRACE: // rootSpanCounter should never be negative, unless the user changed profile lifecycle diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java index 58065c708ec..15ae88f22c8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/PerfettoContinuousProfiler.java @@ -141,6 +141,8 @@ public void startProfiler( logger.log(SentryLevel.DEBUG, "Profiler was not started due to sampling decision."); return; } + // A stop only takes effect when the current chunk ends, so a start before then cancels it. + shouldStop = false; switch (profileLifecycle) { case TRACE: activeTraceCount = Math.max(0, activeTraceCount); // safety check. @@ -148,16 +150,13 @@ public void startProfiler( break; case MANUAL: if (isRunning()) { - logger.log( - SentryLevel.WARNING, - "Unexpected call to startProfiler(MANUAL) while profiler already running. Skipping."); + logger.log(SentryLevel.DEBUG, "Profiler is already running."); return; } break; } if (!isRunning()) { logger.log(SentryLevel.DEBUG, "Started Profiler."); - shouldStop = false; startInternal(); } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt index caaa30152a4..a13f3fe1246 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidContinuousProfilerTest.kt @@ -278,6 +278,18 @@ class AndroidContinuousProfilerTest { } .testDoesNotStartWhenOffline(mocks) + @Test + fun `manual start cancels a pending stop`() = + fixture.getSut().testManualStartCancelsPendingStop(mocks) + + @Test + fun `trace start cancels a pending stop`() = + fixture.getSut().testTraceStartCancelsPendingStop(mocks) + + @Test + fun `manual profiler can be started again after a full start-stop cycle`() = + fixture.getSut().testCanBeStartedAgainAfterStopCycle(mocks) + // -- Legacy-specific tests (AndroidContinuousProfiler only) -- @Test diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt index 5e4e0504ddf..267529fd33d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ContinuousProfilerTestCases.kt @@ -192,3 +192,23 @@ fun IContinuousProfiler.testCanBeStartedAgainAfterStopCycle(mocks: ProfilerMocks mocks.executor.runAll() assertTrue(isRunning, "shouldStop must be reset on start") } + +fun IContinuousProfiler.testManualStartCancelsPendingStop(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + val profilerIdBeforeStop = profilerId + stopProfiler(ProfileLifecycle.MANUAL) + startProfiler(ProfileLifecycle.MANUAL, mocks.tracesSampler) + mocks.executor.runAll() + assertTrue(isRunning, "a start before the chunk ends must cancel the pending stop") + assertEquals(profilerIdBeforeStop, profilerId) +} + +fun IContinuousProfiler.testTraceStartCancelsPendingStop(mocks: ProfilerMocks) { + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + val profilerIdBeforeStop = profilerId + stopProfiler(ProfileLifecycle.TRACE) + startProfiler(ProfileLifecycle.TRACE, mocks.tracesSampler) + mocks.executor.runAll() + assertTrue(isRunning, "a start before the chunk ends must cancel the pending stop") + assertEquals(profilerIdBeforeStop, profilerId) +} diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt index d6f29b97b7b..3bafc29be3d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/PerfettoContinuousProfilerTest.kt @@ -178,6 +178,14 @@ class PerfettoContinuousProfilerTest { fun `manual profiler can be started again after a full start-stop cycle`() = fixture.getSut().testCanBeStartedAgainAfterStopCycle(mocks) + @Test + fun `manual start cancels a pending stop`() = + fixture.getSut().testManualStartCancelsPendingStop(mocks) + + @Test + fun `trace start cancels a pending stop`() = + fixture.getSut().testTraceStartCancelsPendingStop(mocks) + // -- Perfetto-specific tests -- @Test @@ -503,10 +511,6 @@ class PerfettoContinuousProfilerTest { assertTrue(profiler.isRunning) profiler.startProfiler(ProfileLifecycle.MANUAL, fixture.mockTracesSampler) assertTrue(profiler.isRunning) - verify(fixture.mockLogger) - .log( - eq(SentryLevel.WARNING), - eq("Unexpected call to startProfiler(MANUAL) while profiler already running. Skipping."), - ) + verify(fixture.mockLogger).log(eq(SentryLevel.DEBUG), eq("Profiler is already running.")) } } From 07a8d1be67774cc840ec37d009077ffb16dede72 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:43:13 +0100 Subject: [PATCH 02/40] chore: Use team-mobile-android-sdk in CODEOWNERS (#6386) Co-Authored-By: Chris Aigner Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6e1f71a7677..7db31b56297 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @adinauer @romtsn @markushi @runningcode @0xadam-brown +* @getsentry/team-mobile-android-sdk @adinauer From f7af383a5de8f1b781e9259d4cd2b421204e2265 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:10:56 +0100 Subject: [PATCH 03/40] docs: Use dark-mode-aware Sentry logo in READMEs (#6397) The current wordmark is dark on a transparent background, so it is nearly illegible when GitHub is in dark mode. Wrap the logo in a `` element that serves `sentry-logo-white.png` for `prefers-color-scheme: dark` and the purple `sentry-wordmark-dark-280x84.png` otherwise. The plain `` fallback keeps renderers without `` support working. Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Daniel Szoke --- CONTRIBUTING.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79800a6ff2b..a1d18c6856c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,10 @@

- + + + + Sentry +

From 74f16849a341c92bb7ace8cfe378414499b4dbf3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:09:09 +0100 Subject: [PATCH 04/40] chore(deps): bump androidx.recyclerview:recyclerview from 1.2.1 to 1.4.0 (#6403) Bumps androidx.recyclerview:recyclerview from 1.2.1 to 1.4.0. --- updated-dependencies: - dependency-name: androidx.recyclerview:recyclerview dependency-version: 1.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7cbe8e626c8..7be3a6a4620 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -127,7 +127,7 @@ androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteRc" } androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteRc" } -androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.2.1" } +androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version = "1.4.0" } androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } async-profiler-jfr-converter = { module = "tools.profiler:jfr-converter", version.ref = "asyncProfiler" } From 16a1cddc893bdbd06f16f731a64ece735eff402b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:24:00 +0000 Subject: [PATCH 05/40] chore(deps): bump com.abovevacant:epitaph from 0.1.1 to 0.2.0 (#6402) * chore(deps): bump com.abovevacant:epitaph from 0.1.1 to 0.2.0 Bumps [com.abovevacant:epitaph](https://github.com/abovevacant/epitaph) from 0.1.1 to 0.2.0. - [Changelog](https://github.com/abovevacant/epitaph/blob/main/CHANGELOG.md) - [Commits](https://github.com/abovevacant/epitaph/compare/v0.1.1...v0.2.0) --- updated-dependencies: - dependency-name: com.abovevacant:epitaph dependency-version: 0.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * changelog Co-Authored-By: Nelson Osacky --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Nelson Osacky --- CHANGELOG.md | 5 +++++ gradle/libs.versions.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bbfc57c8f..139a2359402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ - Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291)) - Before, the start call was ignored and the profiler still stopped at the end of the chunk, even in `TRACE` mode while sampled spans were active. On API 34 and lower, every later profile also stopped after one chunk. +### Dependencies + +- Bump epitaph (Android tombstone parsing) from 0.1.1 to 0.2.0 ([#6402](https://github.com/getsentry/sentry-java/pull/6402)) + - Reject malformed protobuf tags, oversized lengths, invalid varints, and invalid reader bounds instead of silently accepting partial/corrupt tombstone data. + ## 8.62.0 ### Improvements diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7be3a6a4620..3cca378018d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -183,7 +183,7 @@ otel-javaagent-extension-api = { module = "io.opentelemetry.javaagent:openteleme otel-semconv = { module = "io.opentelemetry.semconv:opentelemetry-semconv", version.ref = "otelSemanticConventions" } otel-semconv-incubating = { module = "io.opentelemetry.semconv:opentelemetry-semconv-incubating", version.ref = "otelSemanticConventionsAlpha" } p6spy = { module = "p6spy:p6spy", version = "3.9.1" } -epitaph = { module = "com.abovevacant:epitaph", version = "0.1.1" } +epitaph = { module = "com.abovevacant:epitaph", version = "0.2.0" } jcache = { module = "javax.cache:cache-api", version = "1.1.1" } quartz = { module = "org.quartz-scheduler:quartz", version = "2.3.0" } reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } From d6d89a67457f4b29cae21128659e72c1c826f936 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 28 Oct 2026 08:27:33 +0000 Subject: [PATCH 06/40] Move from bcv plugin to KGP api validation (#6362) * build: Move from the binary-compatibility-validator plugin to KGP ABI validation * build: Validate the ABI of sentry-opentelemetry-agentless Co-Authored-By: Markus Hintersteiner * build: Drop the apiDump and apiCheck aliases in favour of the KGP task names * build: Restore the root build file changes from main lost in the merge * build: Chain the ABI filters onto the abiValidation lookup --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- .claude/skills/create-java-pr/SKILL.md | 2 +- .cursor/BUGBOT.md | 2 +- .cursor/rules/api.mdc | 4 +- .cursor/rules/options.mdc | 2 +- AGENTS.md | 6 +-- CONTRIBUTING.md | 4 +- Makefile | 2 +- build-logic/build.gradle.kts | 4 ++ .../sentry/gradle/ApiValidationExtension.kt | 12 +++++ .../gradle/SentryApiValidationPlugin.kt | 47 +++++++++++++++++++ build.gradle.kts | 46 ------------------ gradle/libs.versions.toml | 1 - sentry-android-core/build.gradle.kts | 3 ++ sentry-android-distribution/build.gradle.kts | 1 + sentry-android-fragment/build.gradle.kts | 1 + sentry-android-navigation/build.gradle.kts | 1 + sentry-android-navigation3/build.gradle.kts | 1 + sentry-android-ndk/build.gradle.kts | 1 + sentry-android-replay/build.gradle.kts | 1 + sentry-android-sqlite/build.gradle.kts | 1 + sentry-android-timber/build.gradle.kts | 1 + sentry-apache-http-client-5/build.gradle.kts | 1 + sentry-apollo-3/build.gradle.kts | 1 + sentry-apollo-4/build.gradle.kts | 1 + sentry-apollo-5/build.gradle.kts | 1 + sentry-apollo/build.gradle.kts | 1 + sentry-async-profiler/build.gradle.kts | 1 + sentry-compose/build.gradle.kts | 1 + sentry-graphql-22/build.gradle.kts | 1 + sentry-graphql-core/build.gradle.kts | 1 + sentry-graphql/build.gradle.kts | 1 + sentry-jcache/build.gradle.kts | 1 + sentry-jdbc/build.gradle.kts | 1 + sentry-jul/build.gradle.kts | 1 + sentry-kafka/build.gradle.kts | 1 + sentry-kotlin-extensions/build.gradle.kts | 1 + sentry-ktor-client/build.gradle.kts | 1 + sentry-launchdarkly-android/build.gradle.kts | 1 + sentry-launchdarkly-server/build.gradle.kts | 1 + sentry-log4j2/build.gradle.kts | 1 + sentry-logback/build.gradle.kts | 1 + sentry-okhttp/build.gradle.kts | 1 + sentry-openfeature/build.gradle.kts | 1 + sentry-openfeign/build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + .../build.gradle.kts | 1 + sentry-quartz/build.gradle.kts | 1 + sentry-reactor/build.gradle.kts | 1 + .../api/sentry-samples-console-otlp.api | 5 -- .../api/sentry-samples-ktor.api | 6 --- sentry-servlet-jakarta/build.gradle.kts | 1 + sentry-servlet/build.gradle.kts | 1 + sentry-spotlight/build.gradle.kts | 1 + sentry-spring-7/build.gradle.kts | 1 + sentry-spring-boot-4-starter/build.gradle.kts | 1 + sentry-spring-boot-4/build.gradle.kts | 1 + sentry-spring-boot-jakarta/build.gradle.kts | 1 + .../build.gradle.kts | 1 + sentry-spring-boot-starter/build.gradle.kts | 1 + sentry-spring-boot/build.gradle.kts | 1 + sentry-spring-jakarta/build.gradle.kts | 1 + sentry-spring/build.gradle.kts | 1 + sentry/build.gradle.kts | 1 + 67 files changed, 129 insertions(+), 69 deletions(-) create mode 100644 build-logic/src/main/kotlin/io/sentry/gradle/ApiValidationExtension.kt create mode 100644 build-logic/src/main/kotlin/io/sentry/gradle/SentryApiValidationPlugin.kt delete mode 100644 sentry-samples/sentry-samples-console-otlp/api/sentry-samples-console-otlp.api delete mode 100644 sentry-samples/sentry-samples-ktor-client/api/sentry-samples-ktor.api diff --git a/.claude/skills/create-java-pr/SKILL.md b/.claude/skills/create-java-pr/SKILL.md index 17b5839d88a..70bc4453a58 100644 --- a/.claude/skills/create-java-pr/SKILL.md +++ b/.claude/skills/create-java-pr/SKILL.md @@ -74,7 +74,7 @@ Derive the branch name from the changes being made. Use `feat/`, `fix/`, `ref/`, ## Step 2: Format Code and Regenerate API Files ```bash -./gradlew spotlessApply apiDump +./gradlew spotlessApply updateKotlinAbi ``` This is **required** before every PR in this repo. It formats all Java/Kotlin code via Spotless and regenerates the `.api` binary compatibility files. diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 8882291ee46..5abe0d601f0 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -120,6 +120,6 @@ rule file in [`.cursor/rules/`](rules) for the area the diff touches (`api`, `op ## What NOT to flag - Formatting and import order — Spotless owns it. -- Contents of generated `.api` files, beyond confirming `apiDump` was run. +- Contents of generated `.api` files, beyond confirming `updateKotlinAbi` was run. - Conventional commit / PR title format, and missing changelog entries — CI and Danger check both. - Speculative refactors or improvements unrelated to the diff. diff --git a/.cursor/rules/api.mdc b/.cursor/rules/api.mdc index 9435bc2590f..4a9e3629c77 100644 --- a/.cursor/rules/api.mdc +++ b/.cursor/rules/api.mdc @@ -6,9 +6,9 @@ description: Public API surface, binary compatibility, and common classes to mod ## API Compatibility -Public API is tracked via `.api` files generated by the [Binary Compatibility Validator](https://github.com/Kotlin/binary-compatibility-validator) Gradle plugin. Each module has its own file at `/api/.api`. +Public API is tracked via `.api` files generated by the [ABI validation](https://kotlinlang.org/docs/gradle-binary-compatibility-validation.html) built into the Kotlin Gradle plugin. Each module has its own file at `/api/.api`. -- **Never edit `.api` files manually.** Run `./gradlew apiDump` to regenerate them. +- **Never edit `.api` files manually.** Run `./gradlew updateKotlinAbi` to regenerate them. - `./gradlew check` validates current code against `.api` files and fails on unintended changes. - `@ApiStatus.Internal` marks classes/methods as internal — they still appear in `.api` files but are not part of the public contract. - `@ApiStatus.Experimental` marks API that may change in future versions. diff --git a/.cursor/rules/options.mdc b/.cursor/rules/options.mdc index 2d239da7813..8c79bcbe677 100644 --- a/.cursor/rules/options.mdc +++ b/.cursor/rules/options.mdc @@ -112,4 +112,4 @@ When a feature has its own capture path (e.g. `captureLog`), the relevant classe 4. `ManifestMetadataReader.java` — Android manifest support (if Android-relevant) 5. `SentryAutoConfigurationTest.kt` — Spring Boot property binding tests (all three Spring Boot modules) 6. Tests for all of the above (`SentryOptionsTest`, `ExternalOptionsTest`, `ManifestMetadataReaderTest`) -7. Run `./gradlew apiDump` — the nested class and its methods appear in `sentry.api` +7. Run `./gradlew updateKotlinAbi` — the nested class and its methods appear in `sentry.api` diff --git a/AGENTS.md b/AGENTS.md index 1ffec6c02b3..a736a15e389 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ rule file in `.cursor/rules/`: | Rule | Read it when working on | |---|---| -| `api` | Public API surface, binary compatibility, `.api` files, `apiDump`, `IScope`/`IScopes`/`Sentry` static API, protocol classes | +| `api` | Public API surface, binary compatibility, `.api` files, `updateKotlinAbi`, `IScope`/`IScopes`/`Sentry` static API, protocol classes | | `options` | `SentryOptions`, namespaced options, `ExternalOptions`, `sentry.properties`, `ManifestMetadataReader`, Spring Boot properties | | `scopes` | Scope management, forking, lifecycle, `ScopeType`, thread-local storage, scope bleeding, Hub → Scopes migration | | `deduplication` | Duplicate event detection, `DuplicateEventDetectionEventProcessor`, `enableDeduplication` | @@ -42,7 +42,7 @@ The project uses **Gradle** with Kotlin DSL. Key build files: ```bash # Format code and regenerate .api files (REQUIRED before committing) -./gradlew spotlessApply apiDump +./gradlew spotlessApply updateKotlinAbi # Run all tests and linter ./gradlew check @@ -74,7 +74,7 @@ resolves the per-module test task, including integration test suites, for you. 3. **Use todo tracking**: Work through todo items, marking them as complete as you go 4. **High-level communication**: Give high-level explanations of changes made, not step-by-step descriptions 5. **Simplicity first**: Make every task and code change as simple as possible. Avoid massive or complex changes. Impact as little code as possible. -6. **Format and regenerate**: Once done, format code and regenerate .api files: `./gradlew spotlessApply apiDump` +6. **Format and regenerate**: Once done, format code and regenerate .api files: `./gradlew spotlessApply updateKotlinAbi` 7. **Propose commit**: As final step, git stage relevant files and propose (but not execute) a single git commit command. This applies to implementation work; when the task is to open a PR, the `create-java-pr` skill takes over from here and does commit, push, and open it. ## Repository Skills diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1d18c6856c..77714917518 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,7 +81,7 @@ or # Binary compatibility validation -To prevent breaking ABI changes and exposing things we should not, we make use of https://github.com/Kotlin/binary-compatibility-validator. If your change intended to introduce a new public method/property or modify the existing one you can overwrite the API declarations to make CI happy as follows (overwrites them from scratch): +To prevent breaking ABI changes and exposing things we should not, we make use of https://kotlinlang.org/docs/gradle-binary-compatibility-validation.html. If your change intended to introduce a new public method/property or modify the existing one you can overwrite the API declarations to make CI happy as follows (overwrites them from scratch): ```shell make api @@ -90,7 +90,7 @@ make api or ```shell -./gradlew apiDump +./gradlew updateKotlinAbi ``` However, if your change did not intend to modify the public API, consider changing the method/property visibility or removing the change altogether. diff --git a/Makefile b/Makefile index 7a57ed54e09..d1f80c5f99b 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ checkFormat: # Binary compatibility validator api: - ./gradlew apiDump + ./gradlew updateKotlinAbi # Assemble release and Android test apk of the uitest-android module assembleUiTestRelease: diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 60a340c9b85..385d1b08386 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -16,6 +16,10 @@ gradlePlugin { id = "io.sentry.jdk-target" implementationClass = "io.sentry.gradle.JdkTargetConvention" } + register("sentryApiValidation") { + id = "io.sentry.api-validation" + implementationClass = "io.sentry.gradle.SentryApiValidationPlugin" + } register("sentryAnimalSniffer") { id = "io.sentry.animalsniffer" implementationClass = "io.sentry.gradle.SentryAnimalSnifferPlugin" diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/ApiValidationExtension.kt b/build-logic/src/main/kotlin/io/sentry/gradle/ApiValidationExtension.kt new file mode 100644 index 00000000000..7f46dc2acd7 --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/ApiValidationExtension.kt @@ -0,0 +1,12 @@ +package io.sentry.gradle + +import org.gradle.api.provider.SetProperty + +/** Configuration for the `io.sentry.api-validation` convention plugin. */ +abstract class ApiValidationExtension { + /** + * Packages that are public to the JVM but not part of the SDK's API, and so are left out of the + * dump. Subpackages are left out too. + */ + abstract val ignoredPackages: SetProperty +} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SentryApiValidationPlugin.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SentryApiValidationPlugin.kt new file mode 100644 index 00000000000..f4c1dca5948 --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SentryApiValidationPlugin.kt @@ -0,0 +1,47 @@ +package io.sentry.gradle + +import org.gradle.api.GradleException +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.ExtensionAware +import org.gradle.kotlin.dsl.create +import org.jetbrains.kotlin.gradle.dsl.abi.AbiValidationExtension +import org.jetbrains.kotlin.gradle.dsl.abi.AbiValidationMultiplatformExtension +import org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation + +private val KOTLIN_PLUGIN_IDS = + listOf( + "org.jetbrains.kotlin.jvm", + "org.jetbrains.kotlin.android", + "org.jetbrains.kotlin.multiplatform", + ) + +/** + * Checks the public ABI of a module against the dump checked into its `api` directory, using the + * Kotlin Gradle plugin's own ABI validation. Adds `checkKotlinAbi`, which `check` depends on, and + * `updateKotlinAbi`, which rewrites the dump. + */ +@OptIn(ExperimentalAbiValidation::class) +class SentryApiValidationPlugin : Plugin { + override fun apply(project: Project) { + val extension = project.extensions.create("sentryApiValidation") + + // The abiValidation extension only exists once a Kotlin plugin has been applied, and a module + // may list this plugin before that one. + KOTLIN_PLUGIN_IDS.forEach { + project.pluginManager.withPlugin(it) { enable(project, extension) } + } + } + + private fun enable(project: Project, extension: ApiValidationExtension) { + val kotlin = project.extensions.getByName("kotlin") as ExtensionAware + when (val abi = kotlin.extensions.getByName("abiValidation")) { + is AbiValidationMultiplatformExtension -> abi.also { it.enabled.set(true) } + is AbiValidationExtension -> abi.also { it.enabled.set(true) } + else -> throw GradleException("Unexpected abiValidation extension: ${abi.javaClass.name}") + }.filters { + // Lazy, because the module configures the extension after this plugin has been applied. + exclude { byNames.addAll(extension.ignoredPackages.map { it.map { pkg -> "$pkg.**" } }) } + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 3ed35d2da82..a2f7bb27df8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,6 @@ plugins { alias(libs.plugins.spotless) apply false alias(libs.plugins.detekt) `maven-publish` - alias(libs.plugins.binary.compatibility.validator) alias(libs.plugins.vanniktech.maven.publish) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.multiplatform) apply false @@ -43,51 +42,6 @@ buildscript { } } -apiValidation { - ignoredPackages.addAll( - setOf( - "io.sentry.android.core.internal" - ) - ) - ignoredProjects.addAll( - listOf( - "sentry-samples-android", - "sentry-samples-console", - "sentry-samples-console-opentelemetry-noagent", - "sentry-samples-jul", - "sentry-samples-log4j2", - "sentry-samples-logback", - "sentry-samples-openfeign", - "sentry-samples-servlet", - "sentry-samples-spring", - "sentry-samples-spring-jakarta", - "sentry-samples-spring-7", - "sentry-samples-spring-boot", - "sentry-samples-spring-boot-opentelemetry", - "sentry-samples-spring-boot-opentelemetry-noagent", - "sentry-samples-spring-boot-jakarta", - "sentry-samples-spring-boot-jakarta-log4j2", - "sentry-samples-spring-boot-jakarta-opentelemetry", - "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", - "sentry-samples-spring-boot-webflux", - "sentry-samples-spring-boot-webflux-jakarta", - "sentry-samples-spring-boot-4", - "sentry-samples-spring-boot-4-log4j2", - "sentry-samples-spring-boot-4-opentelemetry", - "sentry-samples-spring-boot-4-opentelemetry-noagent", - "sentry-samples-spring-boot-4-otlp", - "sentry-samples-spring-boot-4-webflux", - "sentry-samples-ktor-client", - "sentry-uitest-android", - "sentry-uitest-android-critical", - "test-app-size", - "sentry-samples-netflix-dgs", - "sentry-samples-console-otlp", - "sentry-test-support" - ) - ) -} - allprojects { group = Config.Sentry.group version = providers.gradleProperty(Config.Sentry.versionNameProp).get() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3cca378018d..09ac7a2113e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,7 +68,6 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } buildconfig = { id = "com.github.gmazzo.buildconfig", version = "5.6.5" } dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } dokka-javadoc = { id = "org.jetbrains.dokka-javadoc", version = "2.0.0" } -binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.13.0" } errorprone = { id = "net.ltgt.errorprone", version = "5.1.1" } gradle-versions = { id = "com.github.ben-manes.versions", version = "0.42.0" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 4facf6f4c23..f78dd0c036b 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.api-validation") } android { @@ -135,3 +136,5 @@ dependencies { testRuntimeOnly(libs.androidx.fragment.ktx) testRuntimeOnly(libs.timber) } + +sentryApiValidation { ignoredPackages.add("io.sentry.android.core.internal") } diff --git a/sentry-android-distribution/build.gradle.kts b/sentry-android-distribution/build.gradle.kts index 074d56c08e6..69c9e3ccada 100644 --- a/sentry-android-distribution/build.gradle.kts +++ b/sentry-android-distribution/build.gradle.kts @@ -5,6 +5,7 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.detekt) + id("io.sentry.api-validation") } android { diff --git a/sentry-android-fragment/build.gradle.kts b/sentry-android-fragment/build.gradle.kts index 3c7a0404970..8a69c231328 100644 --- a/sentry-android-fragment/build.gradle.kts +++ b/sentry-android-fragment/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) + id("io.sentry.api-validation") } android { diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts index ddc6d6f4161..c91426649d1 100644 --- a/sentry-android-navigation/build.gradle.kts +++ b/sentry-android-navigation/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) + id("io.sentry.api-validation") } android { diff --git a/sentry-android-navigation3/build.gradle.kts b/sentry-android-navigation3/build.gradle.kts index 9612fded9d6..95462ad61f1 100644 --- a/sentry-android-navigation3/build.gradle.kts +++ b/sentry-android-navigation3/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) + id("io.sentry.api-validation") } android { diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index dff85906ca1..c61678be0cb 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) + id("io.sentry.api-validation") } android { diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 1a92f9a0b36..6fff59a0c41 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) + id("io.sentry.api-validation") } android { diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index 38ea832082f..e01b3dbd4a0 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) + id("io.sentry.api-validation") } android { diff --git a/sentry-android-timber/build.gradle.kts b/sentry-android-timber/build.gradle.kts index 92f14b37c8c..b2f09450d9d 100644 --- a/sentry-android-timber/build.gradle.kts +++ b/sentry-android-timber/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) + id("io.sentry.api-validation") } android { diff --git a/sentry-apache-http-client-5/build.gradle.kts b/sentry-apache-http-client-5/build.gradle.kts index 06c5f839f95..7cef8834444 100644 --- a/sentry-apache-http-client-5/build.gradle.kts +++ b/sentry-apache-http-client-5/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.api-validation") } kotlin { diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index ae8721b22b2..2750eec5fde 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 63c8b5431c6..04cb6b12f10 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-apollo-5/build.gradle.kts b/sentry-apollo-5/build.gradle.kts index 2d78dff8fa1..40de399fd30 100644 --- a/sentry-apollo-5/build.gradle.kts +++ b/sentry-apollo-5/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index 72a183bb7b3..e489f64269f 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index fbb4a8fc353..f02de255bd4 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 19fce9f95b5..befdf01e678 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.dokka) alias(libs.plugins.dokka.javadoc) `maven-publish` // necessary for publishMavenLocal task to publish correct artifacts + id("io.sentry.api-validation") } kotlin { diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index 9da6bcc1e36..b0ebeb2a37a 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts index df0496f070e..af4c988bce4 100644 --- a/sentry-graphql-core/build.gradle.kts +++ b/sentry-graphql-core/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 25613e1fdd6..da4a35021e2 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-jcache/build.gradle.kts b/sentry-jcache/build.gradle.kts index b773c42c340..52b71618cf5 100644 --- a/sentry-jcache/build.gradle.kts +++ b/sentry-jcache/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-jdbc/build.gradle.kts b/sentry-jdbc/build.gradle.kts index 9d0aa1596fe..8fa8130edaa 100644 --- a/sentry-jdbc/build.gradle.kts +++ b/sentry-jdbc/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index 451cbf1991b..934be2d203f 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index eb293ada057..36f66d60c9b 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index ded17d445e7..d8941b1088a 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) id("io.sentry.animalsniffer.android") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index 28edd1689e0..a28c11eab8d 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-launchdarkly-android/build.gradle.kts b/sentry-launchdarkly-android/build.gradle.kts index df081b21213..cf99b017db0 100644 --- a/sentry-launchdarkly-android/build.gradle.kts +++ b/sentry-launchdarkly-android/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) + id("io.sentry.api-validation") } android { diff --git a/sentry-launchdarkly-server/build.gradle.kts b/sentry-launchdarkly-server/build.gradle.kts index f2ed3d15742..6368731ad78 100644 --- a/sentry-launchdarkly-server/build.gradle.kts +++ b/sentry-launchdarkly-server/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 68cdaa9b726..39d75e53fa0 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-logback/build.gradle.kts b/sentry-logback/build.gradle.kts index 48f84725a78..b5854279987 100644 --- a/sentry-logback/build.gradle.kts +++ b/sentry-logback/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-okhttp/build.gradle.kts b/sentry-okhttp/build.gradle.kts index 0b3e2388415..bdc04b8d2b9 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-openfeature/build.gradle.kts b/sentry-openfeature/build.gradle.kts index 6750e22e61c..9d07106806b 100644 --- a/sentry-openfeature/build.gradle.kts +++ b/sentry-openfeature/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index 209e5c34e54..b1a09d5eae4 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts index b4b1a8b840d..4580dceeed4 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agent/build.gradle.kts @@ -8,6 +8,7 @@ plugins { id("io.sentry.javadoc") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.shadow) + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts index 3390f2695c8..79ddbc77d7e 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts index 32d029746c1..1a12e5c4336 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts index 28a1b030e15..af03e26b222 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index 1c1e7f984ba..265c99ad639 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index fb95d722d6f..1a928ca8701 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts index b5387daf207..d8ede48f169 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-reactor/build.gradle.kts b/sentry-reactor/build.gradle.kts index b98eafd028f..403f1c4e6f2 100644 --- a/sentry-reactor/build.gradle.kts +++ b/sentry-reactor/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) + id("io.sentry.api-validation") } // Published code targets Java 17. Test code is not published and follows the toolchain. diff --git a/sentry-samples/sentry-samples-console-otlp/api/sentry-samples-console-otlp.api b/sentry-samples/sentry-samples-console-otlp/api/sentry-samples-console-otlp.api deleted file mode 100644 index 867869223ea..00000000000 --- a/sentry-samples/sentry-samples-console-otlp/api/sentry-samples-console-otlp.api +++ /dev/null @@ -1,5 +0,0 @@ -public class io/sentry/samples/console/Main { - public fun ()V - public static fun main ([Ljava/lang/String;)V -} - diff --git a/sentry-samples/sentry-samples-ktor-client/api/sentry-samples-ktor.api b/sentry-samples/sentry-samples-ktor-client/api/sentry-samples-ktor.api deleted file mode 100644 index cf3a710f94f..00000000000 --- a/sentry-samples/sentry-samples-ktor-client/api/sentry-samples-ktor.api +++ /dev/null @@ -1,6 +0,0 @@ -public final class io/sentry/samples/ktor/MainKt { - public static final fun main ()V - public static synthetic fun main ([Ljava/lang/String;)V - public static final fun makeRequests (Lio/ktor/client/HttpClient;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - diff --git a/sentry-servlet-jakarta/build.gradle.kts b/sentry-servlet-jakarta/build.gradle.kts index b9d6ffc2ec4..8bbc4228552 100644 --- a/sentry-servlet-jakarta/build.gradle.kts +++ b/sentry-servlet-jakarta/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-servlet/build.gradle.kts b/sentry-servlet/build.gradle.kts index 08ac7d45284..cd3a344f759 100644 --- a/sentry-servlet/build.gradle.kts +++ b/sentry-servlet/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-spotlight/build.gradle.kts b/sentry-spotlight/build.gradle.kts index 072cb360e61..f283b1e37b8 100644 --- a/sentry-spotlight/build.gradle.kts +++ b/sentry-spotlight/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) id("io.sentry.animalsniffer.android") alias(libs.plugins.buildconfig) + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts index b9d418abe4d..eb920eea82f 100644 --- a/sentry-spring-7/build.gradle.kts +++ b/sentry-spring-7/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) alias(libs.plugins.springboot4) apply false + id("io.sentry.api-validation") } // Published code targets Java 17. Test code is not published and follows the toolchain. diff --git a/sentry-spring-boot-4-starter/build.gradle.kts b/sentry-spring-boot-4-starter/build.gradle.kts index 7ad300d4790..fca672dac9d 100644 --- a/sentry-spring-boot-4-starter/build.gradle.kts +++ b/sentry-spring-boot-4-starter/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.springboot4) apply false + id("io.sentry.api-validation") } // Published code targets Java 17. Test code is not published and follows the toolchain. diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 7f4428551b2..90d40e162de 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) alias(libs.plugins.springboot4) apply false + id("io.sentry.api-validation") } // Published code targets Java 17. Test code is not published and follows the toolchain. diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 9451cf95117..be264b890f2 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -11,6 +11,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) alias(libs.plugins.springboot3) apply false + id("io.sentry.api-validation") } // Published code targets Java 17. Test code is not published and follows the toolchain. diff --git a/sentry-spring-boot-starter-jakarta/build.gradle.kts b/sentry-spring-boot-starter-jakarta/build.gradle.kts index 63594df7a2e..92cc97b240c 100644 --- a/sentry-spring-boot-starter-jakarta/build.gradle.kts +++ b/sentry-spring-boot-starter-jakarta/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.springboot3) apply false + id("io.sentry.api-validation") } // Published code targets Java 17. Test code is not published and follows the toolchain. diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index 641b335becb..54ef6444d34 100644 --- a/sentry-spring-boot-starter/build.gradle.kts +++ b/sentry-spring-boot-starter/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 151d5a829dd..b20f500e64f 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -11,6 +11,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index 701d5a04208..4b5e3b00107 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) alias(libs.plugins.springboot3) apply false + id("io.sentry.api-validation") } // Published code targets Java 17. Test code is not published and follows the toolchain. diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index f11087c1b07..9836686857d 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 9cbcf5bf2c0..70f0068a5c3 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer.android") + id("io.sentry.api-validation") } tasks.named("compileKotlin") { From 2b4127d1ab12fe30728301be6aced59cdc0296cb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:30:56 +0100 Subject: [PATCH 07/40] chore: Remove unused Python and devenv tooling (#6387) Co-Authored-By: Nelson Osacky Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- .envrc | 3 --- .python-version | 1 - Makefile | 1 - devenv/config.ini | 16 ---------------- devenv/sync.py | 23 ----------------------- pyproject.toml | 3 --- uv.lock | 8 -------- 7 files changed, 55 deletions(-) delete mode 100644 .envrc delete mode 100644 .python-version delete mode 100644 devenv/config.ini delete mode 100644 devenv/sync.py delete mode 100644 pyproject.toml delete mode 100644 uv.lock diff --git a/.envrc b/.envrc deleted file mode 100644 index f58a7cee600..00000000000 --- a/.envrc +++ /dev/null @@ -1,3 +0,0 @@ -export VIRTUAL_ENV="${PWD}/.venv" -devenv sync -PATH_add "${PWD}/.venv/bin" diff --git a/.python-version b/.python-version deleted file mode 100644 index 2c20ac9bea3..00000000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13.3 diff --git a/Makefile b/Makefile index d1f80c5f99b..c5bb6ac2313 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,6 @@ publish: clean dryRelease clean: ./gradlew clean --no-configuration-cache rm -rf distributions - rm -rf .venv # build and run tests compile: diff --git a/devenv/config.ini b/devenv/config.ini deleted file mode 100644 index 904a4143fbb..00000000000 --- a/devenv/config.ini +++ /dev/null @@ -1,16 +0,0 @@ -[devenv] -minimum_version = 1.22.1 - -[uv] -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.13.5 - diff --git a/devenv/sync.py b/devenv/sync.py deleted file mode 100644 index 45e663cd99a..00000000000 --- a/devenv/sync.py +++ /dev/null @@ -1,23 +0,0 @@ -from devenv import constants -from devenv.lib import config, proc, uv -import os - -def main(context: dict[str, str]) -> int: - reporoot = context["reporoot"] - cfg = config.get_repo(reporoot) - - uv.install( - cfg["uv"]["version"], - cfg["uv"][constants.SYSTEM_MACHINE], - cfg["uv"][f"{constants.SYSTEM_MACHINE}_sha256"], - reporoot, - ) - - # reporoot/.venv is the default venv location - print(f"syncing .venv ...") - if not os.path.exists(".venv"): - proc.run(("uv", "venv", "--seed")) - proc.run(("uv", "sync", "--frozen", "--quiet")) - - return 0 - diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 55509e3912b..00000000000 --- a/pyproject.toml +++ /dev/null @@ -1,3 +0,0 @@ -[project] -name = "javasdk" -version = "0.0.0" diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 84b75276602..00000000000 --- a/uv.lock +++ /dev/null @@ -1,8 +0,0 @@ -version = 1 -revision = 2 -requires-python = ">=3.13" - -[[package]] -name = "javasdk" -version = "0.0.0" -source = { virtual = "." } From 8eee5d193c95f5c3e548dc79b1e69fdb72a33bc7 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 28 Oct 2026 10:43:43 +0100 Subject: [PATCH 08/40] build: Replace deprecated AGP BaseExtension in JdkTargetConvention (#6406) BaseExtension is deprecated in AGP 9 and removed in AGP 10, and it is not the registered android extension once android.newDsl=true. The DSL LibraryExtension exposes the same compileOptions and aarMetadata blocks and works with both DSL modes. Co-authored-by: Claude Opus 5.5 --- .../src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ca2999f878a..73202bbb3f3 100644 --- a/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt +++ b/build-logic/src/main/kotlin/io/sentry/gradle/JdkTargetConvention.kt @@ -1,8 +1,8 @@ package io.sentry.gradle import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.dsl.LibraryExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension -import com.android.build.gradle.BaseExtension import org.gradle.api.JavaVersion import org.gradle.api.Plugin import org.gradle.api.Project @@ -87,7 +87,7 @@ class JdkTargetConvention : Plugin { } project.plugins.withId("com.android.library") { - val android = project.extensions.getByType(BaseExtension::class.java) + val android = project.extensions.getByType(LibraryExtension::class.java) android.compileOptions { sourceCompatibility = PUBLISHED_JAVA_VERSION targetCompatibility = PUBLISHED_JAVA_VERSION From 4bf0a823ee135f41b048e6816582f965152c6fd2 Mon Sep 17 00:00:00 2001 From: Stefan Jandl Date: Wed, 28 Oct 2026 11:55:29 +0100 Subject: [PATCH 09/40] feat: add `Get/Set Environment` on `Scope` (#5772) * added get/set environment to scope * updated changelog * updated changelog * sync scope clear * clear * apply to replay * scope helper * comment * made scope apply its environment on other events too * Update CHANGELOG.md Co-authored-by: Roman Zavarnitsyn * feat: apply the scope environment to Baggage/DSC and address review feedback * fix: read the DSC environment from the combined scope view instead of the isolation scope * feat: apply the scope environment to logs, metrics and profiles * test: stub the ScopeType overload of configureScope in scopes mocks * feat(android): sync the scope environment to the NDK scope * ci: fix Build Job ubuntu-latest [pr-shepherd] * test(apollo-5): stub the ScopeType overload of configureScope in scopes mock * chore: move changelog entry to Unreleased * fix(ndk): catch only RejectedExecutionException when syncing the scope environment --------- Co-authored-by: Roman Zavarnitsyn Co-authored-by: Markus Hintersteiner Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- CHANGELOG.md | 5 + .../ApplicationExitInfoEventProcessor.java | 11 +++ .../ApplicationExitInfoEventProcessorTest.kt | 13 +++ .../SentryGestureListenerClickTest.kt | 4 + .../SentryGestureListenerScrollTest.kt | 4 + .../SentryGestureListenerTracingTest.kt | 5 + sentry-android-ndk/api/sentry-android-ndk.api | 1 + .../sentry/android/ndk/NdkScopeObserver.java | 10 ++ .../android/ndk/NdkScopeObserverTest.kt | 11 +++ .../sentry/android/replay/ReplaySmokeTest.kt | 4 + .../capture/BufferCaptureStrategyTest.kt | 4 + .../capture/SessionCaptureStrategyTest.kt | 4 + .../apollo3/SentryApollo3InterceptorTest.kt | 4 + .../SentryApollo4HttpInterceptorTest.kt | 4 + .../SentryApollo5HttpInterceptorTest.kt | 4 + .../apollo/SentryApolloInterceptorTest.kt | 4 + .../sentry/graphql/ExceptionReporterTest.kt | 5 + .../sentry/kafka/SentryKafkaProducerTest.kt | 5 + .../ktorClient/SentryKtorClientPluginTest.kt | 4 + .../okhttp/SentryOkHttpInterceptorTest.kt | 14 ++- .../sentry/openfeign/SentryFeignClientTest.kt | 4 + .../sentry/opentelemetry/OtelSpanWrapper.java | 7 ++ .../sentry/spring7/SentrySpringFilterTest.kt | 4 + ...trySpanClientHttpRequestInterceptorTest.kt | 5 + .../SentrySpanRestClientCustomizerTest.kt | 4 + .../SentrySpanRestTemplateCustomizerTest.kt | 4 + .../SentrySpanWebClientCustomizerTest.kt | 4 + .../SentrySpanRestClientCustomizerTest.kt | 4 + .../SentrySpanRestTemplateCustomizerTest.kt | 4 + .../SentrySpanWebClientCustomizerTest.kt | 4 + .../SentrySpanRestTemplateCustomizerTest.kt | 4 + .../boot/SentrySpanWebClientCustomizerTest.kt | 4 + .../spring/jakarta/SentrySpringFilterTest.kt | 4 + ...trySpanClientHttpRequestInterceptorTest.kt | 5 + .../sentry/spring/SentrySpringFilterTest.kt | 4 + ...trySpanClientHttpRequestInterceptorTest.kt | 5 + sentry/api/sentry.api | 15 ++- sentry/src/main/java/io/sentry/Baggage.java | 6 +- .../java/io/sentry/CombinedScopeView.java | 24 ++++- sentry/src/main/java/io/sentry/IScope.java | 15 +++ .../main/java/io/sentry/IScopeObserver.java | 2 + sentry/src/main/java/io/sentry/NoOpScope.java | 8 ++ .../src/main/java/io/sentry/ProfileChunk.java | 5 + sentry/src/main/java/io/sentry/Scope.java | 41 +++++++- .../java/io/sentry/ScopeObserverAdapter.java | 3 + sentry/src/main/java/io/sentry/Scopes.java | 2 +- sentry/src/main/java/io/sentry/Sentry.java | 6 ++ .../src/main/java/io/sentry/SentryClient.java | 34 ++++++- .../src/main/java/io/sentry/SentryTracer.java | 6 ++ .../sentry/cache/PersistingScopeObserver.java | 7 ++ .../main/java/io/sentry/logger/LoggerApi.java | 4 +- .../java/io/sentry/metrics/MetricsApi.java | 4 +- .../java/io/sentry/util/TracingUtils.java | 4 + sentry/src/test/java/io/sentry/BaggageTest.kt | 76 +++++++++++++++ .../java/io/sentry/CombinedScopeViewTest.kt | 11 +++ sentry/src/test/java/io/sentry/ScopeTest.kt | 58 +++++++++++ sentry/src/test/java/io/sentry/ScopesTest.kt | 33 +++++++ .../test/java/io/sentry/SentryClientTest.kt | 97 ++++++++++++++++--- sentry/src/test/java/io/sentry/SentryTest.kt | 27 ++++++ .../test/java/io/sentry/SentryTracerTest.kt | 20 ++++ .../sentry/TraceContextSerializationTest.kt | 1 + .../cache/PersistingScopeObserverTest.kt | 11 +++ .../java/io/sentry/util/TracingUtilsTest.kt | 23 +++++ 63 files changed, 706 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 139a2359402..ee444a8d3da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Features + +- Added `Environment` to the `Scope`, allowing you to overwrite the `Environment` set on the options ([#5772](https://github.com/getsentry/sentry-java/pull/5772)) + - The scope `Environment` is also applied to the trace envelope header (DSC), logs, metrics and profiles, so all telemetry of a trace reports the same environment + ### Fixes - Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291)) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index 483259fa8dd..a82beb74121 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -198,6 +198,7 @@ private void backfillScope( setRequest(event); setUser(event); setScopeTags(event); + setScopeEnvironment(event); setBreadcrumbs(event); setExtras(event); setContexts(event); @@ -492,6 +493,16 @@ private void setEnvironment( } } + private void setScopeEnvironment(final @NotNull SentryBaseEvent event) { + if (event.getEnvironment() == null) { + final @Nullable String scopeEnvironment = + readFromDisk(options, PersistingScopeObserver.ENVIRONMENT_FILENAME, String.class); + if (scopeEnvironment != null) { + event.setEnvironment(scopeEnvironment); + } + } + } + private void setDebugMeta( final @NotNull SentryBaseEvent event, final @NotNull OptionsSource optionsSource) { DebugMeta debugMeta = event.getDebugMeta(); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index 2e90247aca5..aa08875a34e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -461,6 +461,18 @@ class ApplicationExitInfoEventProcessorTest { assertEquals("release", processed.environment) } + @Test + fun `if scope environment is persisted, it takes precedence over the options environment`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + + val processor = fixture.getSut(tmpDir, populateOptionsCache = true) + fixture.persistScope(PersistingScopeObserver.ENVIRONMENT_FILENAME, "staging") + + val processed = processor.process(SentryEvent(), hint) + + assertEquals("staging", processed!!.environment) + } + @Test fun `if release is not persisted and app was not updated, uses release from options`() { val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) @@ -542,6 +554,7 @@ class ApplicationExitInfoEventProcessorTest { fixture.options.release = "io.sentry.samples@1.2.0+232" fixture.options.environment = "production" fixture.options.dist = "custom-dist" + fixture.persistScope(PersistingScopeObserver.ENVIRONMENT_FILENAME, "staging") setLastUpdateTime(1_000) val processed = processor.process(SentryEvent(), hint)!! diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt index 81950647fa9..6f2b6ade8ec 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerClickTest.kt @@ -15,6 +15,7 @@ import io.sentry.IScopes import io.sentry.PropagationContext import io.sentry.Scope.IWithPropagationContext import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryLevel.INFO import io.sentry.android.core.SentryAndroidOptions import io.sentry.util.LazyEvaluator @@ -85,6 +86,9 @@ class SentryGestureListenerClickTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) doAnswer { (it.arguments[0] as IWithPropagationContext).accept(propagationContext) propagationContext diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt index 633bb2fdb86..0607c093a1c 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerScrollTest.kt @@ -17,6 +17,7 @@ import io.sentry.IScopes import io.sentry.PropagationContext import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryLevel import io.sentry.SentryLevel.INFO import io.sentry.android.core.SentryAndroidOptions @@ -82,6 +83,9 @@ class SentryGestureListenerScrollTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) doAnswer { (it.arguments[0] as Scope.IWithPropagationContext).accept(propagationContext) propagationContext diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt index 9d7606bfe44..e73bf891c8e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/internal/gestures/SentryGestureListenerTracingTest.kt @@ -13,6 +13,7 @@ import io.sentry.IScope import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryTracer import io.sentry.SpanContext import io.sentry.SpanId @@ -31,6 +32,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import org.mockito.ArgumentCaptor import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.clearInvocations import org.mockito.kotlin.doAnswer @@ -94,6 +96,9 @@ class SentryGestureListenerTracingTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) return SentryGestureListener(activity, scopes, options) } diff --git a/sentry-android-ndk/api/sentry-android-ndk.api b/sentry-android-ndk/api/sentry-android-ndk.api index a7c5571d0bb..0c2ae5c90f8 100644 --- a/sentry-android-ndk/api/sentry-android-ndk.api +++ b/sentry-android-ndk/api/sentry-android-ndk.api @@ -20,6 +20,7 @@ public final class io/sentry/android/ndk/NdkScopeObserver : io/sentry/ScopeObser public fun clearAttachments ()V public fun removeExtra (Ljava/lang/String;)V public fun removeTag (Ljava/lang/String;)V + public fun setEnvironment (Ljava/lang/String;)V public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V public fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java index a1474bb69c8..c35a294c828 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/NdkScopeObserver.java @@ -14,6 +14,7 @@ import io.sentry.util.Objects; import java.util.Locale; import java.util.Map; +import java.util.concurrent.RejectedExecutionException; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -53,6 +54,15 @@ public void setUser(final @Nullable User user) { } } + @Override + public void setEnvironment(@Nullable String environment) { + try { + options.getExecutorService().submit(() -> nativeScope.setEnvironment(environment)); + } catch (RejectedExecutionException e) { + options.getLogger().log(SentryLevel.ERROR, e, "Scope sync setEnvironment has an error."); + } + } + @Override public void addBreadcrumb(final @NotNull Breadcrumb crumb) { try { diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt index a8b5318bfab..19fad628158 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/NdkScopeObserverTest.kt @@ -184,4 +184,15 @@ class NdkScopeObserverTest { verify(fixture.nativeScope).clearAttachments() } + + @Test + fun `set environments forwards call to native scope`() { + val sut = fixture.getSut() + + sut.setEnvironment(null) + verify(fixture.nativeScope).setEnvironment(eq(null)) + + sut.setEnvironment("production") + verify(fixture.nativeScope).setEnvironment(eq("production")) + } } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 0a8076f20f6..d35ef4e8e0f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -16,6 +16,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType import io.sentry.android.replay.util.ReplayShadowMediaCodec @@ -72,6 +73,9 @@ class ReplaySmokeTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(it) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(it) + .configureScope(anyOrNull(), any()) on { rateLimiter }.doReturn(rateLimiter) } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt index 6fdf3ab210d..f3830bc1ed5 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/BufferCaptureStrategyTest.kt @@ -6,6 +6,7 @@ import io.sentry.DataCategory import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryEnvelope import io.sentry.SentryEnvelopeHeader import io.sentry.SentryOptions @@ -67,6 +68,9 @@ class BufferCaptureStrategyTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(it) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(it) + .configureScope(anyOrNull(), any()) } var persistedSegment = LinkedHashMap() val replayCache = diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index b0dbe3f82bb..281eb1da3be 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -7,6 +7,7 @@ import io.sentry.DateUtils import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType @@ -86,6 +87,9 @@ class SessionCaptureStrategyTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(it) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(it) + .configureScope(anyOrNull(), any()) } var persistedSegment = LinkedHashMap() val replayCache = diff --git a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt index 49d7d790a53..1ca41b566de 100644 --- a/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt +++ b/sentry-apollo-3/src/test/java/io/sentry/apollo3/SentryApollo3InterceptorTest.kt @@ -16,6 +16,7 @@ import io.sentry.IScopes import io.sentry.ITransaction import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS @@ -71,6 +72,9 @@ class SentryApollo3InterceptorTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(it) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(it) + .configureScope(anyOrNull(), any()) } private var httpInterceptor = SentryApollo3HttpInterceptor(scopes, captureFailedRequests = false) diff --git a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt index 210cab90327..0418e55fb3d 100644 --- a/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt +++ b/sentry-apollo-4/src/test/java/io/sentry/apollo4/SentryApollo4HttpInterceptorTest.kt @@ -19,6 +19,7 @@ import io.sentry.IScopes import io.sentry.ITransaction import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS @@ -85,6 +86,9 @@ abstract class SentryApollo4HttpInterceptorTest( doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(it) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(it) + .configureScope(anyOrNull(), any()) } private var httpInterceptor = SentryApollo4HttpInterceptor(scopes, captureFailedRequests = false) diff --git a/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt b/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt index 399667a7647..acd27f0ee1b 100644 --- a/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt +++ b/sentry-apollo-5/src/test/java/io/sentry/apollo5/SentryApollo5HttpInterceptorTest.kt @@ -20,6 +20,7 @@ import io.sentry.IScopes import io.sentry.ITransaction import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.SentryOptions.DEFAULT_PROPAGATION_TARGETS import io.sentry.SentryTraceHeader @@ -88,6 +89,9 @@ abstract class SentryApollo5HttpInterceptorTest( doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(it) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(it) + .configureScope(anyOrNull(), any()) } private var httpInterceptor = SentryApollo5HttpInterceptor(scopes, captureFailedRequests = false) diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt index 498b4663cac..9c35f7249c2 100644 --- a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt +++ b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt @@ -12,6 +12,7 @@ import io.sentry.IScopes import io.sentry.ITransaction import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryTraceHeader @@ -62,6 +63,9 @@ class SentryApolloInterceptorTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(it) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(it) + .configureScope(anyOrNull(), any()) } private var interceptor = SentryApolloInterceptor(scopes) diff --git a/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt index 3d367663a15..c054911396d 100644 --- a/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt +++ b/sentry-graphql-core/src/test/kotlin/io/sentry/graphql/ExceptionReporterTest.kt @@ -18,6 +18,7 @@ import io.sentry.IScopes import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.exception.ExceptionMechanismException import io.sentry.protocol.Request @@ -28,6 +29,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertSame import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -91,6 +93,9 @@ class ExceptionReporterTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) return exceptionReporter } diff --git a/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaProducerTest.kt b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaProducerTest.kt index a4ba5254c36..5901880371d 100644 --- a/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaProducerTest.kt +++ b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaProducerTest.kt @@ -7,6 +7,7 @@ import io.sentry.ISpan import io.sentry.NoOpSpan import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.SentryTraceHeader @@ -33,6 +34,7 @@ import org.apache.kafka.common.TopicPartition import org.apache.kafka.common.header.Header import org.apache.kafka.common.header.Headers import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq @@ -64,6 +66,9 @@ class SentryKafkaProducerTest { doAnswer { (it.arguments[0] as ScopeCallback).run(Scope(options)) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(Scope(options)) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) delegate = mock() whenever(delegate.send(any(), any())).thenReturn(CompletableFuture.completedFuture(null)) } diff --git a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt index 87d607f2553..55a87e9ad9d 100644 --- a/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt +++ b/sentry-ktor-client/src/test/java/io/sentry/ktorClient/SentryKtorClientPluginTest.kt @@ -21,6 +21,7 @@ import io.sentry.IScopes import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.Sentry import io.sentry.SentryEvent import io.sentry.SentryLevel @@ -99,6 +100,9 @@ class SentryKtorClientPluginTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) diff --git a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt index 7d099f8a971..d6c4c56c378 100644 --- a/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt +++ b/sentry-okhttp/src/test/java/io/sentry/okhttp/SentryOkHttpInterceptorTest.kt @@ -14,6 +14,7 @@ import io.sentry.IScopes import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.Sentry import io.sentry.SentryLevel import io.sentry.SentryOptions @@ -104,9 +105,7 @@ class SentryOkHttpInterceptorTest { } scope = Scope(options) whenever(scopes.options).thenReturn(options) - doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } - .whenever(scopes) - .configureScope(any()) + stubConfigureScope() sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) @@ -152,6 +151,15 @@ class SentryOkHttpInterceptorTest { } .build() } + + private fun stubConfigureScope() { + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) + } } private val fixture = Fixture() diff --git a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt index 5771802c5b8..3f47b4f6e17 100644 --- a/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt +++ b/sentry-openfeign/src/test/kotlin/io/sentry/openfeign/SentryFeignClientTest.kt @@ -14,6 +14,7 @@ import io.sentry.ILogger import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.SentryTraceHeader @@ -59,6 +60,9 @@ class SentryFeignClientTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java index 80da51f9db7..aac15a9e59f 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java @@ -15,6 +15,7 @@ import io.sentry.NoOpScopesLifecycleToken; import io.sentry.NoOpSpan; import io.sentry.ScopeBindingMode; +import io.sentry.ScopeType; import io.sentry.SentryDate; import io.sentry.SentryLevel; import io.sentry.SentryTraceHeader; @@ -231,14 +232,20 @@ private void updateBaggageValues(final @NotNull Baggage baggage) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { if (baggage != null && baggage.isMutable()) { final AtomicReference replayIdAtomicReference = new AtomicReference<>(); + final AtomicReference environmentAtomicReference = new AtomicReference<>(); + // the combined view resolves current -> isolation -> global, matching what event capture + // sees, so the DSC cannot disagree with the environment on the events of the same trace scopes.configureScope( + ScopeType.COMBINED, scope -> { replayIdAtomicReference.set(scope.getReplayId()); + environmentAtomicReference.set(scope.getEnvironment()); }); baggage.setValuesFromTransaction( getSpanContext().getTraceId(), replayIdAtomicReference.get(), scopes.getOptions(), + environmentAtomicReference.get(), this.getSamplingDecision(), getTransactionName(), getTransactionNameSource()); diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt index b1ea92766b1..37c97e99d89 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt @@ -8,6 +8,7 @@ import io.sentry.ISentryLifecycleToken import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.SentryOptions.RequestSize.ALWAYS import io.sentry.SentryOptions.RequestSize.MEDIUM @@ -63,6 +64,9 @@ class SentrySpringFilterTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) this.request = request ?: MockHttpServletRequest().apply { diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptorTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptorTest.kt index 85d138604dd..267477df6e8 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptorTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptorTest.kt @@ -3,6 +3,7 @@ package io.sentry.spring7.tracing import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.SentryTraceHeader @@ -13,6 +14,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -44,6 +46,9 @@ class SentrySpanClientHttpRequestInterceptorTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) whenever(requestExecution.execute(any(), any())).thenReturn(mock()) } diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestClientCustomizerTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestClientCustomizerTest.kt index 7b50e923b1a..9b35c0e2abc 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestClientCustomizerTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestClientCustomizerTest.kt @@ -5,6 +5,7 @@ import io.sentry.Breadcrumb import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.SentryTraceHeader import io.sentry.SentryTracer @@ -54,6 +55,9 @@ class SentrySpanRestClientCustomizerTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) transaction = SentryTracer(TransactionContext("aTransaction", "op", TracesSamplingDecision(true)), scopes) } diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestTemplateCustomizerTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestTemplateCustomizerTest.kt index 988824465bb..381a10975f6 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestTemplateCustomizerTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestTemplateCustomizerTest.kt @@ -5,6 +5,7 @@ import io.sentry.Breadcrumb import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.SentryTraceHeader import io.sentry.SentryTracer @@ -58,6 +59,9 @@ class SentrySpanRestTemplateCustomizerTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) transaction = SentryTracer(TransactionContext("aTransaction", "op", TracesSamplingDecision(true)), scopes) } diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanWebClientCustomizerTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanWebClientCustomizerTest.kt index b593dd261dc..b30de4f0763 100644 --- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanWebClientCustomizerTest.kt +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanWebClientCustomizerTest.kt @@ -6,6 +6,7 @@ import io.sentry.IScope import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.Sentry.OptionsConfiguration import io.sentry.SentryOptions import io.sentry.SentryTraceHeader @@ -69,6 +70,9 @@ class SentrySpanWebClientCustomizerTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) transaction = SentryTracer(TransactionContext("aTransaction", "op", TracesSamplingDecision(true)), scopes) val webClientBuilder = WebClient.builder() diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentrySpanRestClientCustomizerTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentrySpanRestClientCustomizerTest.kt index 7a2e6a0ff4f..ac38f48047a 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentrySpanRestClientCustomizerTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentrySpanRestClientCustomizerTest.kt @@ -5,6 +5,7 @@ import io.sentry.Breadcrumb import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.SentryTraceHeader import io.sentry.SentryTracer @@ -54,6 +55,9 @@ class SentrySpanRestClientCustomizerTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) transaction = SentryTracer(TransactionContext("aTransaction", "op", TracesSamplingDecision(true)), scopes) } diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentrySpanRestTemplateCustomizerTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentrySpanRestTemplateCustomizerTest.kt index c6c7707aab9..e30c2e99d72 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentrySpanRestTemplateCustomizerTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentrySpanRestTemplateCustomizerTest.kt @@ -5,6 +5,7 @@ import io.sentry.Breadcrumb import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.SentryTraceHeader import io.sentry.SentryTracer @@ -58,6 +59,9 @@ class SentrySpanRestTemplateCustomizerTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) transaction = SentryTracer(TransactionContext("aTransaction", "op", TracesSamplingDecision(true)), scopes) } diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentrySpanWebClientCustomizerTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentrySpanWebClientCustomizerTest.kt index 83321f30e78..d34e6322abb 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentrySpanWebClientCustomizerTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentrySpanWebClientCustomizerTest.kt @@ -6,6 +6,7 @@ import io.sentry.IScope import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.Sentry.OptionsConfiguration import io.sentry.SentryOptions import io.sentry.SentryTraceHeader @@ -69,6 +70,9 @@ class SentrySpanWebClientCustomizerTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) transaction = SentryTracer(TransactionContext("aTransaction", "op", TracesSamplingDecision(true)), scopes) val webClientBuilder = WebClient.builder() diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentrySpanRestTemplateCustomizerTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentrySpanRestTemplateCustomizerTest.kt index 29699f61022..7fcba218af4 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentrySpanRestTemplateCustomizerTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentrySpanRestTemplateCustomizerTest.kt @@ -5,6 +5,7 @@ import io.sentry.Breadcrumb import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.SentryTraceHeader import io.sentry.SentryTracer @@ -58,6 +59,9 @@ class SentrySpanRestTemplateCustomizerTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) transaction = SentryTracer(TransactionContext("aTransaction", "op", TracesSamplingDecision(true)), scopes) } diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentrySpanWebClientCustomizerTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentrySpanWebClientCustomizerTest.kt index 80c6398fbe7..92ec1db72a5 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentrySpanWebClientCustomizerTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentrySpanWebClientCustomizerTest.kt @@ -6,6 +6,7 @@ import io.sentry.IScope import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.Sentry.OptionsConfiguration import io.sentry.SentryOptions import io.sentry.SentryTraceHeader @@ -69,6 +70,9 @@ class SentrySpanWebClientCustomizerTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) transaction = SentryTracer(TransactionContext("aTransaction", "op", TracesSamplingDecision(true)), scopes) val webClientBuilder = WebClient.builder() diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt index f3c94cd4500..1c30ca58445 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentrySpringFilterTest.kt @@ -8,6 +8,7 @@ import io.sentry.ISentryLifecycleToken import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.SentryOptions.RequestSize.ALWAYS import io.sentry.SentryOptions.RequestSize.MEDIUM @@ -63,6 +64,9 @@ class SentrySpringFilterTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) this.request = request ?: MockHttpServletRequest().apply { diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptorTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptorTest.kt index e3f9ad2dfa9..504b6d7bd12 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptorTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentrySpanClientHttpRequestInterceptorTest.kt @@ -3,6 +3,7 @@ package io.sentry.spring.jakarta.tracing import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.SentryTraceHeader @@ -13,6 +14,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -44,6 +46,9 @@ class SentrySpanClientHttpRequestInterceptorTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) whenever(requestExecution.execute(any(), any())).thenReturn(mock()) } diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt index ea92a49ceb2..8a265eea8a7 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringFilterTest.kt @@ -8,6 +8,7 @@ import io.sentry.ISentryLifecycleToken import io.sentry.KeyValueCollectionBehavior import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.SentryOptions.RequestSize.ALWAYS import io.sentry.SentryOptions.RequestSize.MEDIUM @@ -63,6 +64,9 @@ class SentrySpringFilterTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) this.request = request ?: MockHttpServletRequest().apply { diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentrySpanClientHttpRequestInterceptorTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentrySpanClientHttpRequestInterceptorTest.kt index a850480099a..6531cd7fd04 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentrySpanClientHttpRequestInterceptorTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentrySpanClientHttpRequestInterceptorTest.kt @@ -3,6 +3,7 @@ package io.sentry.spring.tracing import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.SentryTraceHeader @@ -13,6 +14,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -44,6 +46,9 @@ class SentrySpanClientHttpRequestInterceptorTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) whenever(requestExecution.execute(any(), any())).thenReturn(mock()) } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index abf8fc28526..2890298efb5 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -77,7 +77,7 @@ public final class io/sentry/Baggage { public fun setUserId (Ljava/lang/String;)V public fun setValuesFromSamplingDecision (Lio/sentry/TracesSamplingDecision;)V public fun setValuesFromScope (Lio/sentry/IScope;Lio/sentry/SentryOptions;)V - public fun setValuesFromTransaction (Lio/sentry/protocol/SentryId;Lio/sentry/protocol/SentryId;Lio/sentry/SentryOptions;Lio/sentry/TracesSamplingDecision;Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;)V + public fun setValuesFromTransaction (Lio/sentry/protocol/SentryId;Lio/sentry/protocol/SentryId;Lio/sentry/SentryOptions;Ljava/lang/String;Lio/sentry/TracesSamplingDecision;Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;)V public fun toHeaderString (Ljava/lang/String;)Ljava/lang/String; public fun toTraceContext ()Lio/sentry/TraceContext; } @@ -288,6 +288,7 @@ public final class io/sentry/CombinedScopeView : io/sentry/IScope { public fun getBreadcrumbs ()Ljava/util/Queue; public fun getClient ()Lio/sentry/ISentryClient; public fun getContexts ()Lio/sentry/protocol/Contexts; + public fun getEnvironment ()Ljava/lang/String; public fun getEventProcessors ()Ljava/util/List; public fun getEventProcessorsWithOrder ()Ljava/util/List; public fun getExtras ()Ljava/util/Map; @@ -323,6 +324,7 @@ public final class io/sentry/CombinedScopeView : io/sentry/IScope { public fun setContexts (Ljava/lang/String;Ljava/lang/String;)V public fun setContexts (Ljava/lang/String;Ljava/util/Collection;)V public fun setContexts (Ljava/lang/String;[Ljava/lang/Object;)V + public fun setEnvironment (Ljava/lang/String;)V public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public fun setFingerprint (Ljava/util/List;)V public fun setLastEventId (Lio/sentry/protocol/SentryId;)V @@ -1003,6 +1005,7 @@ public abstract interface class io/sentry/IScope { public abstract fun getBreadcrumbs ()Ljava/util/Queue; public abstract fun getClient ()Lio/sentry/ISentryClient; public abstract fun getContexts ()Lio/sentry/protocol/Contexts; + public abstract fun getEnvironment ()Ljava/lang/String; public abstract fun getEventProcessors ()Ljava/util/List; public abstract fun getEventProcessorsWithOrder ()Ljava/util/List; public abstract fun getExtras ()Ljava/util/Map; @@ -1038,6 +1041,7 @@ public abstract interface class io/sentry/IScope { public abstract fun setContexts (Ljava/lang/String;Ljava/lang/String;)V public abstract fun setContexts (Ljava/lang/String;Ljava/util/Collection;)V public abstract fun setContexts (Ljava/lang/String;[Ljava/lang/Object;)V + public abstract fun setEnvironment (Ljava/lang/String;)V public abstract fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public abstract fun setFingerprint (Ljava/util/List;)V public abstract fun setLastEventId (Lio/sentry/protocol/SentryId;)V @@ -1065,6 +1069,7 @@ public abstract interface class io/sentry/IScopeObserver { public abstract fun removeTag (Ljava/lang/String;)V public abstract fun setBreadcrumbs (Ljava/util/Collection;)V public abstract fun setContexts (Lio/sentry/protocol/Contexts;)V + public abstract fun setEnvironment (Ljava/lang/String;)V public abstract fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public abstract fun setExtras (Ljava/util/Map;)V public abstract fun setFingerprint (Ljava/util/Collection;)V @@ -1855,6 +1860,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun getBreadcrumbs ()Ljava/util/Queue; public fun getClient ()Lio/sentry/ISentryClient; public fun getContexts ()Lio/sentry/protocol/Contexts; + public fun getEnvironment ()Ljava/lang/String; public fun getEventProcessors ()Ljava/util/List; public fun getEventProcessorsWithOrder ()Ljava/util/List; public fun getExtras ()Ljava/util/Map; @@ -1891,6 +1897,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun setContexts (Ljava/lang/String;Ljava/lang/String;)V public fun setContexts (Ljava/lang/String;Ljava/util/Collection;)V public fun setContexts (Ljava/lang/String;[Ljava/lang/Object;)V + public fun setEnvironment (Ljava/lang/String;)V public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public fun setFingerprint (Ljava/util/List;)V public fun setLastEventId (Lio/sentry/protocol/SentryId;)V @@ -2242,6 +2249,7 @@ public final class io/sentry/ProfileChunk : io/sentry/JsonSerializable, io/sentr public fun hashCode ()I public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setDebugMeta (Lio/sentry/protocol/DebugMeta;)V + public fun setEnvironment (Ljava/lang/String;)V public fun setSampledProfile (Ljava/lang/String;)V public fun setSentryProfile (Lio/sentry/protocol/profiling/SentryProfile;)V public fun setUnknown (Ljava/util/Map;)V @@ -2551,6 +2559,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun getBreadcrumbs ()Ljava/util/Queue; public fun getClient ()Lio/sentry/ISentryClient; public fun getContexts ()Lio/sentry/protocol/Contexts; + public fun getEnvironment ()Ljava/lang/String; public fun getEventProcessors ()Ljava/util/List; public fun getEventProcessorsWithOrder ()Ljava/util/List; public fun getExtras ()Ljava/util/Map; @@ -2586,6 +2595,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun setContexts (Ljava/lang/String;Ljava/lang/String;)V public fun setContexts (Ljava/lang/String;Ljava/util/Collection;)V public fun setContexts (Ljava/lang/String;[Ljava/lang/Object;)V + public fun setEnvironment (Ljava/lang/String;)V public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public fun setFingerprint (Ljava/util/List;)V public fun setLastEventId (Lio/sentry/protocol/SentryId;)V @@ -2638,6 +2648,7 @@ public abstract class io/sentry/ScopeObserverAdapter : io/sentry/IScopeObserver public fun removeTag (Ljava/lang/String;)V public fun setBreadcrumbs (Ljava/util/Collection;)V public fun setContexts (Lio/sentry/protocol/Contexts;)V + public fun setEnvironment (Ljava/lang/String;)V public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V public fun setExtras (Ljava/util/Map;)V public fun setFingerprint (Ljava/util/Collection;)V @@ -5040,6 +5051,7 @@ public final class io/sentry/cache/PersistingOptionsObserver : io/sentry/IOption public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObserverAdapter { public static final field BREADCRUMBS_FILENAME Ljava/lang/String; public static final field CONTEXTS_FILENAME Ljava/lang/String; + public static final field ENVIRONMENT_FILENAME Ljava/lang/String; public static final field EXTRAS_FILENAME Ljava/lang/String; public static final field FINGERPRINT_FILENAME Ljava/lang/String; public static final field LEVEL_FILENAME Ljava/lang/String; @@ -5056,6 +5068,7 @@ public final class io/sentry/cache/PersistingScopeObserver : io/sentry/ScopeObse public fun resetCache ()V public fun setBreadcrumbs (Ljava/util/Collection;)V public fun setContexts (Lio/sentry/protocol/Contexts;)V + public fun setEnvironment (Ljava/lang/String;)V public fun setExtras (Ljava/util/Map;)V public fun setFingerprint (Ljava/util/Collection;)V public fun setLevel (Lio/sentry/SentryLevel;)V diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index a4ef2c9e859..e4777bd063e 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -522,13 +522,14 @@ public void setValuesFromTransaction( final @NotNull SentryId traceId, final @Nullable SentryId replayId, final @NotNull SentryOptions sentryOptions, + final @Nullable String scopeEnvironment, final @Nullable TracesSamplingDecision samplingDecision, final @Nullable String transactionName, final @Nullable TransactionNameSource transactionNameSource) { setTraceId(traceId.toString()); setPublicKey(sentryOptions.retrieveParsedDsn().getPublicKey()); setRelease(sentryOptions.getRelease()); - setEnvironment(sentryOptions.getEnvironment()); + setEnvironment(scopeEnvironment != null ? scopeEnvironment : sentryOptions.getEnvironment()); setTransaction(isHighQualityTransactionName(transactionNameSource) ? transactionName : null); if (replayId != null && !SentryId.EMPTY_ID.equals(replayId)) { setReplayId(replayId.toString()); @@ -565,7 +566,8 @@ public void setValuesFromScope( setTraceId(propagationContext.getTraceId().toString()); setPublicKey(options.retrieveParsedDsn().getPublicKey()); setRelease(options.getRelease()); - setEnvironment(options.getEnvironment()); + final @Nullable String scopeEnvironment = scope.getEnvironment(); + setEnvironment(scopeEnvironment != null ? scopeEnvironment : options.getEnvironment()); if (!SentryId.EMPTY_ID.equals(replayId)) { setReplayId(replayId.toString()); } diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java index ea2d752d44b..cb2ff8c6401 100644 --- a/sentry/src/main/java/io/sentry/CombinedScopeView.java +++ b/sentry/src/main/java/io/sentry/CombinedScopeView.java @@ -56,6 +56,24 @@ public void setLevel(@Nullable SentryLevel level) { getDefaultWriteScope().setLevel(level); } + @Override + public @Nullable String getEnvironment() { + final @Nullable String current = scope.getEnvironment(); + if (current != null) { + return current; + } + final @Nullable String isolation = isolationScope.getEnvironment(); + if (isolation != null) { + return isolation; + } + return globalScope.getEnvironment(); + } + + @Override + public void setEnvironment(@Nullable String environment) { + getDefaultWriteScope().setEnvironment(environment); + } + @Override public @Nullable String getTransactionName() { final @Nullable String current = scope.getTransactionName(); @@ -540,7 +558,11 @@ public void addEventProcessor(@NotNull EventProcessor eventProcessor) { @Override public @Nullable Scope.SessionPair startSession() { - return getDefaultWriteScope().startSession(); + final IScope defaultScope = getDefaultWriteScope(); + if (defaultScope instanceof Scope) { + return ((Scope) defaultScope).startSession(getEnvironment()); + } + return defaultScope.startSession(); } @Override diff --git a/sentry/src/main/java/io/sentry/IScope.java b/sentry/src/main/java/io/sentry/IScope.java index 5b6c38bbcfb..ee512c7441f 100644 --- a/sentry/src/main/java/io/sentry/IScope.java +++ b/sentry/src/main/java/io/sentry/IScope.java @@ -26,6 +26,21 @@ public interface IScope { */ void setLevel(final @Nullable SentryLevel level); + /** + * Returns the Scope's environment. + * + * @return the environment or {@code null} if not set on the scope + */ + @Nullable + String getEnvironment(); + + /** + * Sets the Scope's environment. Takes precedence over {@link SentryOptions#getEnvironment()}. + * + * @param environment the environment + */ + void setEnvironment(final @Nullable String environment); + /** * Returns the Scope's transaction name. * diff --git a/sentry/src/main/java/io/sentry/IScopeObserver.java b/sentry/src/main/java/io/sentry/IScopeObserver.java index e1b9a785043..b6210c88701 100644 --- a/sentry/src/main/java/io/sentry/IScopeObserver.java +++ b/sentry/src/main/java/io/sentry/IScopeObserver.java @@ -38,6 +38,8 @@ public interface IScopeObserver { void setLevel(@Nullable SentryLevel level); + void setEnvironment(@Nullable String environment); + void setContexts(@NotNull Contexts contexts); void setTransaction(@Nullable String transaction); diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index e5cbd5f6846..277b14d2ca8 100644 --- a/sentry/src/main/java/io/sentry/NoOpScope.java +++ b/sentry/src/main/java/io/sentry/NoOpScope.java @@ -42,6 +42,14 @@ public static NoOpScope getInstance() { @Override public void setLevel(@Nullable SentryLevel level) {} + @Override + public @Nullable String getEnvironment() { + return null; + } + + @Override + public void setEnvironment(@Nullable String environment) {} + @Override public @Nullable String getTransactionName() { return null; diff --git a/sentry/src/main/java/io/sentry/ProfileChunk.java b/sentry/src/main/java/io/sentry/ProfileChunk.java index 13b40784144..68cb2201663 100644 --- a/sentry/src/main/java/io/sentry/ProfileChunk.java +++ b/sentry/src/main/java/io/sentry/ProfileChunk.java @@ -102,6 +102,11 @@ public void setDebugMeta(final @Nullable DebugMeta debugMeta) { return environment; } + @ApiStatus.Internal + public void setEnvironment(final @Nullable String environment) { + this.environment = environment; + } + public @NotNull String getPlatform() { return platform; } diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index ec98cc5fcaf..f4ba9dbbb9c 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -54,6 +54,9 @@ public final class Scope implements IScope { /** Scope's screen */ private @Nullable String screen; + /** Scope's environment */ + private @Nullable String environment; + /** Scope's request */ private @Nullable Request request; @@ -138,6 +141,7 @@ private Scope(final @NotNull Scope scope) { final User userRef = scope.user; this.user = userRef != null ? new User(userRef) : null; this.screen = scope.screen; + this.environment = scope.environment; this.replayId = scope.replayId; final Request requestRef = scope.request; @@ -225,6 +229,30 @@ public void setLevel(final @Nullable SentryLevel level) { } } + /** + * Returns the Scope's environment. + * + * @return the environment or {@code null} if not set on the scope + */ + @Override + public @Nullable String getEnvironment() { + return environment; + } + + /** + * Sets the Scope's environment. Takes precedence over {@link SentryOptions#getEnvironment()}. + * + * @param environment the environment + */ + @Override + public void setEnvironment(final @Nullable String environment) { + this.environment = environment; + + for (final IScopeObserver observer : options.getScopeObservers()) { + observer.setEnvironment(environment); + } + } + /** * Returns the Scope's transaction name. * @@ -562,7 +590,8 @@ public ITransaction getTransaction() { /** Resets the Scope to its default state */ @Override public void clear() { - level = null; + setLevel(null); + setEnvironment(null); user = null; request = null; screen = null; @@ -1034,6 +1063,11 @@ public interface IWithSession { @Nullable @Override public SessionPair startSession() { + return startSession(environment); + } + + @Nullable + SessionPair startSession(final @Nullable String sessionEnvironment) { Session previousSession; SessionPair pair = null; try (final @NotNull ISentryLifecycleToken ignored = sessionLock.acquire()) { @@ -1048,7 +1082,10 @@ public SessionPair startSession() { if (options.getRelease() != null) { session = new Session( - options.getDistinctId(), user, options.getEnvironment(), options.getRelease()); + options.getDistinctId(), + user, + sessionEnvironment != null ? sessionEnvironment : options.getEnvironment(), + options.getRelease()); final Session previousClone = previousSession != null ? previousSession.clone() : null; pair = new SessionPair(session.clone(), previousClone); diff --git a/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java b/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java index 4f6a5ac842c..e80d8b6b208 100644 --- a/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java +++ b/sentry/src/main/java/io/sentry/ScopeObserverAdapter.java @@ -46,6 +46,9 @@ public void setFingerprint(@NotNull Collection fingerprint) {} @Override public void setLevel(@Nullable SentryLevel level) {} + @Override + public void setEnvironment(@Nullable String environment) {} + @Override public void setContexts(@NotNull Contexts contexts) {} diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index d3926d56993..2ac8178d655 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -905,7 +905,7 @@ public void flush(long timeoutMillis) { "Instance is disabled and this 'captureTransaction' call is a no-op."); } else { try { - sentryId = getClient().captureProfileChunk(profilingContinuousData, getScope()); + sentryId = getClient().captureProfileChunk(profilingContinuousData, getCombinedScopeView()); } catch (Throwable e) { getOptions() .getLogger() diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 03288ae00ad..00f5729535a 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -597,6 +597,12 @@ private static void notifyOptionsObservers(final @NotNull SentryOptions options) options.findPersistingScopeObserver(); if (scopeCache != null) { scopeCache.resetCache(); + // the scope environment is configuration, not telemetry state, so we persist it + // again after the reset to keep it available for the next app launch + final @Nullable String environment = globalScope.getEnvironment(); + if (environment != null) { + scopeCache.setEnvironment(environment); + } } }); } catch (Throwable e) { diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index c0758a4efd4..2b0a97ff5c3 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -1034,6 +1034,13 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint if (shouldApplyScopeData(transaction, hint)) { addScopeAttachmentsToHint(scope, hint); + + if (profilingTraceData != null && scope != null) { + final @Nullable String scopeEnvironment = scope.getEnvironment(); + if (scopeEnvironment != null) { + profilingTraceData.setEnvironment(scopeEnvironment); + } + } } options @@ -1139,6 +1146,13 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint .getLogger() .log(SentryLevel.DEBUG, "Capturing profile chunk: %s", profileChunk.getChunkId()); + if (scope != null) { + final @Nullable String scopeEnvironment = scope.getEnvironment(); + if (scopeEnvironment != null) { + profileChunk.setEnvironment(scopeEnvironment); + } + } + @NotNull SentryId sentryId = profileChunk.getChunkId(); final DebugMeta debugMeta = DebugMeta.buildDebugMeta(profileChunk.getDebugMeta(), options); if (debugMeta != null) { @@ -1180,6 +1194,10 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint hint = new Hint(); } + if (shouldApplyScopeData(checkIn, hint)) { + checkIn = applyScope(checkIn, scope); + } + if (checkIn.getEnvironment() == null) { checkIn.setEnvironment(options.getEnvironment()); } @@ -1188,10 +1206,6 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint checkIn.setRelease(options.getRelease()); } - if (shouldApplyScopeData(checkIn, hint)) { - checkIn = applyScope(checkIn, scope); - } - if (CheckInUtils.isIgnored(options.getIgnoredCheckIns(), checkIn.getMonitorSlug())) { options .getLogger() @@ -1502,6 +1516,9 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri private @Nullable SentryEvent applyFeedbackScope( @NotNull SentryEvent event, final @NotNull IScope scope, final @NotNull Hint hint) { + if (event.getEnvironment() == null) { + event.setEnvironment(scope.getEnvironment()); + } if (event.getUser() == null) { event.setUser(scope.getUser()); } @@ -1538,6 +1555,9 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri private @NotNull CheckIn applyScope(@NotNull CheckIn checkIn, final @Nullable IScope scope) { if (scope != null) { + if (checkIn.getEnvironment() == null) { + checkIn.setEnvironment(scope.getEnvironment()); + } // Set trace data from active span to connect events with transactions final ISpan span = scope.getSpan(); if (checkIn.getContexts().getTrace() == null) { @@ -1563,6 +1583,9 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri if (replayEvent.getUser() == null) { replayEvent.setUser(scope.getUser()); } + if (replayEvent.getEnvironment() == null) { + replayEvent.setEnvironment(scope.getEnvironment()); + } if (replayEvent.getTags() == null) { replayEvent.setTags(scope.getTags()); } else { @@ -1603,6 +1626,9 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri if (sentryBaseEvent.getUser() == null) { sentryBaseEvent.setUser(scope.getUser()); } + if (sentryBaseEvent.getEnvironment() == null) { + sentryBaseEvent.setEnvironment(scope.getEnvironment()); + } if (sentryBaseEvent.getTags() == null) { sentryBaseEvent.setTags(scope.getTags()); } else { diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 781cf51417e..475729f3b6a 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -734,14 +734,20 @@ private void updateBaggageValues(final @NotNull Baggage baggage) { try (final @NotNull ISentryLifecycleToken ignored = tracerLock.acquire()) { if (baggage.isMutable()) { final AtomicReference replayId = new AtomicReference<>(); + final AtomicReference environment = new AtomicReference<>(); + // the combined view resolves current -> isolation -> global, matching what event capture + // sees, so the DSC cannot disagree with the environment on the events of the same trace scopes.configureScope( + ScopeType.COMBINED, scope -> { replayId.set(scope.getReplayId()); + environment.set(scope.getEnvironment()); }); baggage.setValuesFromTransaction( getSpanContext().getTraceId(), replayId.get(), scopes.getOptions(), + environment.get(), this.getSamplingDecision(), getName(), getTransactionNameSource()); diff --git a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java index 8ebf494dc99..756f36f65da 100644 --- a/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java +++ b/sentry/src/main/java/io/sentry/cache/PersistingScopeObserver.java @@ -57,6 +57,7 @@ public final class PersistingScopeObserver extends ScopeObserverAdapter { public static final String CONTEXTS_FILENAME = "contexts.json"; public static final String REQUEST_FILENAME = "request.json"; public static final String LEVEL_FILENAME = "level.json"; + public static final String ENVIRONMENT_FILENAME = "environment.json"; public static final String FINGERPRINT_FILENAME = "fingerprint.json"; public static final String TRANSACTION_FILENAME = "transaction.json"; public static final String TRACE_FILENAME = "trace.json"; @@ -189,6 +190,11 @@ public void setLevel(@Nullable SentryLevel level) { enqueue(LEVEL_FILENAME, level); } + @Override + public void setEnvironment(@Nullable String environment) { + enqueue(ENVIRONMENT_FILENAME, environment); + } + @Override public void setTransaction(@Nullable String transaction) { enqueue(TRANSACTION_FILENAME, transaction); @@ -374,6 +380,7 @@ public void resetCache() { // the rest we can safely delete delete(USER_FILENAME); delete(LEVEL_FILENAME); + delete(ENVIRONMENT_FILENAME); delete(REQUEST_FILENAME); delete(FINGERPRINT_FILENAME); delete(CONTEXTS_FILENAME); diff --git a/sentry/src/main/java/io/sentry/logger/LoggerApi.java b/sentry/src/main/java/io/sentry/logger/LoggerApi.java index 77de6902614..1933d7f0fe3 100644 --- a/sentry/src/main/java/io/sentry/logger/LoggerApi.java +++ b/sentry/src/main/java/io/sentry/logger/LoggerApi.java @@ -205,7 +205,9 @@ private void captureLog( new SentryLogEventAttributeValue(SentryAttributeType.STRING, sdkVersion.getVersion())); } - final @Nullable String environment = scopes.getOptions().getEnvironment(); + final @Nullable String scopeEnvironment = scopes.getCombinedScopeView().getEnvironment(); + final @Nullable String environment = + scopeEnvironment != null ? scopeEnvironment : scopes.getOptions().getEnvironment(); if (environment != null) { attributes.put( "sentry.environment", diff --git a/sentry/src/main/java/io/sentry/metrics/MetricsApi.java b/sentry/src/main/java/io/sentry/metrics/MetricsApi.java index fa78511d224..9648b99d37f 100644 --- a/sentry/src/main/java/io/sentry/metrics/MetricsApi.java +++ b/sentry/src/main/java/io/sentry/metrics/MetricsApi.java @@ -190,7 +190,9 @@ private void captureMetrics( new SentryLogEventAttributeValue(SentryAttributeType.STRING, sdkVersion.getVersion())); } - final @Nullable String environment = scopes.getOptions().getEnvironment(); + final @Nullable String scopeEnvironment = scopes.getCombinedScopeView().getEnvironment(); + final @Nullable String environment = + scopeEnvironment != null ? scopeEnvironment : scopes.getOptions().getEnvironment(); if (environment != null) { attributes.put( "sentry.environment", diff --git a/sentry/src/main/java/io/sentry/util/TracingUtils.java b/sentry/src/main/java/io/sentry/util/TracingUtils.java index 9de7adec4be..8a1aa0ac660 100644 --- a/sentry/src/main/java/io/sentry/util/TracingUtils.java +++ b/sentry/src/main/java/io/sentry/util/TracingUtils.java @@ -8,6 +8,7 @@ import io.sentry.ISpan; import io.sentry.NoOpLogger; import io.sentry.PropagationContext; +import io.sentry.ScopeType; import io.sentry.SentryOptions; import io.sentry.SentryTraceHeader; import io.sentry.SpanContext; @@ -75,7 +76,10 @@ public static void setTrace( return new TracingHeaders(sentryTraceHeader, baggageHeader, w3cTraceparentHeader); } else { final @NotNull PropagationContextHolder returnValue = new PropagationContextHolder(); + // the combined view writes the propagation context to the same default scope as before, but + // reads scope data such as the environment across current -> isolation -> global scopes.configureScope( + ScopeType.COMBINED, (scope) -> { returnValue.propagationContext = maybeUpdateBaggage(scope, sentryOptions); }); diff --git a/sentry/src/test/java/io/sentry/BaggageTest.kt b/sentry/src/test/java/io/sentry/BaggageTest.kt index e177645734d..1a5ca8da418 100644 --- a/sentry/src/test/java/io/sentry/BaggageTest.kt +++ b/sentry/src/test/java/io/sentry/BaggageTest.kt @@ -1,6 +1,7 @@ package io.sentry import com.github.javafaker.Faker +import com.google.common.truth.Truth.assertThat import io.sentry.Baggage.MAX_BAGGAGE_LIST_MEMBER_COUNT import io.sentry.Baggage.MAX_BAGGAGE_STRING_LENGTH import io.sentry.protocol.SentryId @@ -737,6 +738,80 @@ class BaggageTest { assertNull(baggage.sampleRate) } + @Test + fun `setValuesFromScope prefers the scope environment over the options environment`() { + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/456" + environment = "options-env" + } + val scope = Scope(options).apply { environment = "scope-env" } + val baggage = Baggage(logger) + + baggage.setValuesFromScope(scope, options) + + assertThat(baggage.environment).isEqualTo("scope-env") + } + + @Test + fun `setValuesFromScope uses the options environment when the scope has none`() { + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/456" + environment = "options-env" + } + val scope = Scope(options) + val baggage = Baggage(logger) + + baggage.setValuesFromScope(scope, options) + + assertThat(baggage.environment).isEqualTo("options-env") + } + + @Test + fun `setValuesFromTransaction prefers the scope environment over the options environment`() { + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/456" + environment = "options-env" + } + val baggage = Baggage(logger) + + baggage.setValuesFromTransaction( + SentryId(), + SentryId(), + options, + "scope-env", + TracesSamplingDecision(true, 1.0), + "test-transaction", + TransactionNameSource.CUSTOM, + ) + + assertThat(baggage.environment).isEqualTo("scope-env") + } + + @Test + fun `setValuesFromTransaction uses the options environment when the scope has none`() { + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/456" + environment = "options-env" + } + val baggage = Baggage(logger) + + baggage.setValuesFromTransaction( + SentryId(), + SentryId(), + options, + null, + TracesSamplingDecision(true, 1.0), + "test-transaction", + TransactionNameSource.CUSTOM, + ) + + assertThat(baggage.environment).isEqualTo("options-env") + } + @Test fun `setValuesFromScope falls back to DSN org id when explicit orgId is empty`() { val options = @@ -780,6 +855,7 @@ class BaggageTest { SentryId(), SentryId(), options, + null, TracesSamplingDecision(true, 1.0), "test-transaction", TransactionNameSource.CUSTOM, diff --git a/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt b/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt index fd187235a92..9439306eec5 100644 --- a/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt +++ b/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt @@ -1115,6 +1115,17 @@ class CombinedScopeViewTest { assertNull(fixture.globalScope.session) } + @Test + fun `starts session with the combined environment`() { + val combined = fixture.getSut() + fixture.options.environment = "options-environment" + fixture.scope.environment = "current-environment" + + combined.startSession() + + assertEquals("current-environment", fixture.isolationScope.session!!.environment) + } + @Test fun `ends session on default scope`() { val combined = fixture.getSut() diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 69f19b6d421..3fb19078c27 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -292,6 +292,7 @@ class ScopeTest { scope.addBreadcrumb(Breadcrumb()) scope.setTag("some", "tag") scope.screen = "MainActivity" + scope.environment = "staging" scope.setExtra("some", "extra") scope.setAttribute("some", "attribute") scope.setContexts("some", "context") @@ -306,6 +307,7 @@ class ScopeTest { assertNull(scope.user) assertNull(scope.request) assertNull(scope.screen) + assertNull(scope.environment) assertEquals(0, scope.fingerprint.size) assertEquals(0, scope.breadcrumbs.size) assertEquals(0, scope.tags.size) @@ -494,6 +496,21 @@ class ScopeTest { } } + @Test + fun `Scope starts a new session with scope environment`() { + val options = + SentryOptions().apply { + release = "rel" + environment = "options-env" + } + val scope = Scope(options) + scope.environment = "scope-env" + + val sessionPair = scope.startSession() + + assertNotNull(sessionPair) { assertEquals("scope-env", it.current.environment) } + } + @Test fun `Scope ends a session and returns it if theres one`() { val options = SentryOptions().apply { release = "0.0.1" } @@ -834,6 +851,47 @@ class ScopeTest { verify(observer).setLevel(eq(WARNING)) } + @Test + fun `Scope clear level sync scopes`() { + val observer = mock() + val options = SentryOptions().apply { addScopeObserver(observer) } + val scope = Scope(options) + + scope.clear() + + verify(observer).setLevel(null) + } + + @Test + fun `Scope set environment sync scopes`() { + val observer = mock() + val options = SentryOptions().apply { addScopeObserver(observer) } + val scope = Scope(options) + + scope.environment = "staging" + verify(observer).setEnvironment(eq("staging")) + } + + @Test + fun `Scope set environment null sync scopes`() { + val observer = mock() + val options = SentryOptions().apply { addScopeObserver(observer) } + val scope = Scope(options) + + scope.environment = null + verify(observer).setEnvironment(null) + } + + @Test + fun `Scope clone copies environment`() { + val scope = Scope(SentryOptions()) + scope.environment = "staging" + + val clone = scope.clone() + + assertEquals("staging", clone.environment) + } + @Test fun `Scope set transaction name sync scopes`() { val observer = mock() diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index ba0ec629b6e..8e55d4fbc5b 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -2877,6 +2877,24 @@ class ScopesTest { ) } + @Test + fun `log environment attribute uses the scope environment`() { + val (sut, mockClient) = + getEnabledScopes { + it.logs.isEnabled = true + it.environment = "testenv" + } + sut.configureScope(ScopeType.GLOBAL) { it.environment = "scope-env" } + + sut.logger().log(SentryLogLevel.WARN, "log message") + + verify(mockClient) + .captureLog( + check { assertEquals("scope-env", it.attributes?.get("sentry.environment")?.value) }, + anyOrNull(), + ) + } + @Test fun `creating log with timestamp works`() { val (sut, mockClient) = getEnabledScopes { it.logs.isEnabled = true } @@ -3505,6 +3523,21 @@ class ScopesTest { ) } + @Test + fun `metric environment attribute uses the scope environment`() { + val (sut, mockClient) = getEnabledScopes { it.environment = "testenv" } + sut.configureScope(ScopeType.GLOBAL) { it.environment = "scope-env" } + + sut.metrics().count("metric name") + + verify(mockClient) + .captureMetric( + check { assertEquals("scope-env", it.attributes?.get("sentry.environment")?.value) }, + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `metric with manual origin does not have origin attribute`() { val (sut, mockClient) = getEnabledScopes() diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index e3bc59c549a..e04cadf624f 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -785,24 +785,35 @@ class SentryClientTest { } @Test - fun `when event has environment, value from options not applied`() { + fun `when scope has environment, it takes precedence over options`() { val event = SentryEvent() - val expected = "original" - fixture.sentryOptions.environment = "not to be applied" - event.environment = expected + val scope = createScope() + scope.environment = "from-scope" + fixture.sentryOptions.environment = "from-options" val sut = fixture.getSut() - sut.captureEvent(event) - assertEquals(expected, event.environment) + sut.captureEvent(event, scope) + assertEquals("from-scope", event.environment) } @Test - fun `when event doesn't have environment, value from options applied`() { + fun `when event has environment, scope environment not applied`() { val event = SentryEvent() - val expected = "original" - fixture.sentryOptions.environment = expected + event.environment = "from-event" + val scope = createScope() + scope.environment = "from-scope" val sut = fixture.getSut() - sut.captureEvent(event) - assertEquals(expected, event.environment) + sut.captureEvent(event, scope) + assertEquals("from-event", event.environment) + } + + @Test + fun `when scope has no environment, value from options applied`() { + val event = SentryEvent() + val scope = createScope() + fixture.sentryOptions.environment = "from-options" + val sut = fixture.getSut() + sut.captureEvent(event, scope) + assertEquals("from-options", event.environment) } @Test @@ -1110,6 +1121,16 @@ class SentryClientTest { ) } + @Test + fun `when captureCheckIn, scope environment takes precedence over options`() { + val scope = Scope(fixture.sentryOptions).apply { environment = "scope-environment" } + val sut = fixture.getSut { it.environment = "options-environment" } + + sut.captureCheckIn(checkIn, scope, null) + + assertEquals("scope-environment", checkIn.environment) + } + @Test fun `when captureCheckIn, envelope is sent if ignored slug does not match`() { val sut = fixture.getSut { options -> options.setIgnoredCheckIns(listOf("non_matching_slug")) } @@ -2176,6 +2197,43 @@ class SentryClientTest { verifyProfileChunkInEnvelope(fixture.profileChunk.chunkId) } + @Test + fun `captureProfileChunk applies the scope environment`() { + fixture.sentryOptions.environment = "options-env" + val scope = Scope(fixture.sentryOptions).apply { environment = "scope-env" } + + fixture.getSut().captureProfileChunk(fixture.profileChunk, scope) + + assertEquals("scope-env", fixture.profileChunk.environment) + } + + @Test + fun `captureProfileChunk keeps the chunk environment when the scope has none`() { + val environmentAtCreation = fixture.profileChunk.environment + + fixture.getSut().captureProfileChunk(fixture.profileChunk, Scope(fixture.sentryOptions)) + + assertEquals(environmentAtCreation, fixture.profileChunk.environment) + } + + @Test + fun `captureTransaction applies the scope environment to the profiling trace data`() { + fixture.sentryOptions.environment = "options-env" + val scope = Scope(fixture.sentryOptions).apply { environment = "scope-env" } + + fixture + .getSut() + .captureTransaction( + SentryTransaction(fixture.sentryTracer), + null, + scope, + null, + fixture.profilingTraceData, + ) + + assertEquals("scope-env", fixture.profilingTraceData.environment) + } + @Test fun `captureProfileChunk adds options proguard debug meta`() { fixture.sentryOptions.proguardUuid = "current-uuid" @@ -4107,6 +4165,21 @@ class SentryClientTest { assertTrue(invoked) } + @Test + fun `when captureReplayEvent, scope environment overrides options`() { + var capturedEnvironment: String? = null + fixture.sentryOptions.environment = "from-options" + fixture.sentryOptions.setBeforeSendReplay { replay: SentryReplayEvent, _: Hint -> + capturedEnvironment = replay.environment + replay + } + val scope = Scope(fixture.sentryOptions).apply { environment = "from-scope" } + + fixture.getSut().captureReplayEvent(SentryReplayEvent(), scope, Hint()) + + assertEquals("from-scope", capturedEnvironment) + } + @Test fun `when beforeSendReplay returns null, event is dropped`() { fixture.sentryOptions.setBeforeSendReplay { replay: SentryReplayEvent, _: Hint -> null } @@ -4206,6 +4279,7 @@ class SentryClientTest { val scope = createScope() val scopeReplayId = SentryId() scope.contexts.setTrace(SpanContext("test")) + scope.environment = "scope-environment" scope.setContexts("context-key", "context-value") scope.screen = "screen" scope.replayId = scopeReplayId @@ -4215,6 +4289,7 @@ class SentryClientTest { assertNotNull(sentFeedback) // User, tags and contexts are applied to the feedback assertEquals(scope.user, sentEvent!!.user) + assertEquals("scope-environment", sentEvent!!.environment) assertEquals("tags", sentEvent!!.tags!!["tags"]) assertEquals( scope.contexts.trace!!.traceId.toString(), diff --git a/sentry/src/test/java/io/sentry/SentryTest.kt b/sentry/src/test/java/io/sentry/SentryTest.kt index 8888d81c39a..0ccfb79d699 100644 --- a/sentry/src/test/java/io/sentry/SentryTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTest.kt @@ -8,6 +8,7 @@ import io.sentry.backpressure.BackpressureMonitor import io.sentry.backpressure.NoOpBackpressureMonitor import io.sentry.cache.EnvelopeCache import io.sentry.cache.IEnvelopeCache +import io.sentry.cache.PersistingScopeObserver import io.sentry.internal.debugmeta.IDebugMetaLoader import io.sentry.internal.debugmeta.ResourcesDebugMetaLoader import io.sentry.internal.modules.CompositeModulesLoader @@ -177,6 +178,32 @@ class SentryTest { assertFalse(Sentry.getGlobalScope().client.isEnabled) } + @Test + fun `init persists pre-init global scope environment`() { + val options = + SentryOptions().apply { + dsn = this@SentryTest.dsn + cacheDirPath = getTempPath() + executorService = ImmediateExecutorService() + addScopeObserver(PersistingScopeObserver(this)) + } + Sentry.getGlobalScope().environment = "pre-init-environment" + + try { + initForTest(options) + + assertEquals( + "pre-init-environment", + PersistingScopeObserver(options) + .read(options, PersistingScopeObserver.ENVIRONMENT_FILENAME, String::class.java), + ) + } finally { + Sentry.close() + Sentry.getGlobalScope().replaceOptions(SentryOptions.empty()) + Sentry.getGlobalScope().environment = null + } + } + @Test fun `manual Logs and Metrics reach transport when automatic collection is disabled`() { val transport = mock() diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 95f19344243..de23f5be2af 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -929,6 +929,26 @@ class SentryTracerTest { } } + @Test + fun `trace state uses the scope environment when set`() { + val transaction = fixture.getSut({ it.isTraceSampling = true }) + fixture.scopes.configureScope { it.environment = "scope-environment" } + + val trace = transaction.traceContext() + + assertNotNull(trace) { assertEquals("scope-environment", it.environment) } + } + + @Test + fun `trace state uses the global scope environment when set`() { + val transaction = fixture.getSut({ it.isTraceSampling = true }) + fixture.scopes.configureScope(ScopeType.GLOBAL) { it.environment = "global-environment" } + + val trace = transaction.traceContext() + + assertNotNull(trace) { assertEquals("global-environment", it.environment) } + } + @Test fun `returns trace state without userId if not send pii`() { val transaction = fixture.getSut({ it.isTraceSampling = true }) diff --git a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt index 1ee1d41b71b..f0152ab9a00 100644 --- a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt @@ -67,6 +67,7 @@ class TraceContextSerializationTest { release = "1.0.17" tracesSampleRate = sRate }, + null, TracesSamplingDecision(sRate > 0.5, sRate), "name", TransactionNameSource.ROUTE, diff --git a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt index f682ac4c474..b98c5b8a319 100644 --- a/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt +++ b/sentry/src/test/java/io/sentry/cache/PersistingScopeObserverTest.kt @@ -9,6 +9,7 @@ import io.sentry.SpanContext import io.sentry.SpanId import io.sentry.cache.PersistingScopeObserver.BREADCRUMBS_FILENAME import io.sentry.cache.PersistingScopeObserver.CONTEXTS_FILENAME +import io.sentry.cache.PersistingScopeObserver.ENVIRONMENT_FILENAME import io.sentry.cache.PersistingScopeObserver.EXTRAS_FILENAME import io.sentry.cache.PersistingScopeObserver.FINGERPRINT_FILENAME import io.sentry.cache.PersistingScopeObserver.LEVEL_FILENAME @@ -196,6 +197,15 @@ class PersistingScopeObserverTest( DeletedEntityProvider { null }, ) + private fun environment(): Array = + arrayOf( + "staging", + StoreScopeValue { environment, _ -> setEnvironment(environment) }, + ENVIRONMENT_FILENAME, + DeleteScopeValue { setEnvironment(null) }, + DeletedEntityProvider { null }, + ) + private fun transaction(): Array = arrayOf( "MainActivity", @@ -296,6 +306,7 @@ class PersistingScopeObserverTest( request(), fingerprint(), level(), + environment(), transaction(), trace(), contexts(), diff --git a/sentry/src/test/java/io/sentry/util/TracingUtilsTest.kt b/sentry/src/test/java/io/sentry/util/TracingUtilsTest.kt index edfcc361b08..8fa8eb05ef3 100644 --- a/sentry/src/test/java/io/sentry/util/TracingUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/TracingUtilsTest.kt @@ -7,6 +7,7 @@ import io.sentry.NoOpSpan import io.sentry.PropagationContext import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeType import io.sentry.SentryOptions import io.sentry.SentryTracer import io.sentry.Span @@ -16,6 +17,7 @@ import io.sentry.TracesSamplingDecision import io.sentry.TransactionContext import io.sentry.W3CTraceparentHeader import io.sentry.protocol.SentryId +import io.sentry.test.createTestScopes import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals @@ -25,6 +27,7 @@ import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -42,6 +45,9 @@ class TracingUtilsTest { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + doAnswer { (it.arguments[1] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(anyOrNull(), any()) span = Span( TransactionContext("name", "op", TracesSamplingDecision(true)), @@ -300,6 +306,23 @@ class TracingUtilsTest { assertNotEquals(propagationContextBefore.spanId, fixture.scope.propagationContext.spanId) } + @Test + fun `baggage picks up the environment from the global scope`() { + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + environment = "options-environment" + } + val scopes = createTestScopes(options) + scopes.configureScope(ScopeType.GLOBAL) { it.environment = "global-environment" } + + val headers = TracingUtils.trace(scopes, null, null) + + assertNotNull(headers?.baggageHeader) { + assertTrue(it.value.contains("sentry-environment=global-environment")) + } + } + @Test fun `updates mutable baggage`() { fixture.setup() From 8c17e6d2928567425a24c5346da33964e4f9f236 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 28 Oct 2026 16:05:29 +0100 Subject: [PATCH 10/40] test(spring-boot): [Sample Tests 41] Drop Spring Boot 2.4 to 2.6 from the test matrix (#6381) * test(spring-boot): Drop Spring Boot 2.4 to 2.6 from the test matrix Spring Boot 2.4 to 2.6 are long past end of life. They were the only reason the Boot 2 suites left GraphQL and Kafka out of their compilation below 2.7, which stands in the way of compiling the suites once and running them on each Boot version's classpath (JAVA-802). The Kafka beans stay in a profile, so only the Kafka tests need a broker. Co-Authored-By: Claude * test(spring): [Sample Tests 42] Run each Spring Boot matrix version as its own Gradle task (#6382) * test(spring): Run each Spring Boot matrix version as its own Gradle task The Spring Boot matrix workflows tested each version by rewriting the catalog with perl, so a version could only be tested locally by editing the catalog, and every job recompiled the SDK and the suites. The new io.sentry.spring-boot-matrix plugin registers a test task per suite and version, e.g. integrationTestWebfluxBoot3_2_12. It reuses the suite's compiled classes on a runtime classpath resolved for that version, which swaps every org.springframework.boot artifact, the BOM included, as the catalog edit did. The resolved classpaths match the catalog edit's exactly. So the SDK and the suites now compile once, against the catalog's version, and only the runtime varies, which is how users run our jars. Suite code may only use APIs of the oldest version of its line. allIntegrationTestsBoot runs one version's suites in a module, and allIntegrationTestsBoot every version's. Each workflow runs the latter from the root in one job per Boot line, with --continue so a failing version doesn't hide the others. The versions live only in SpringBootVersions in build-logic. Resolves JAVA-802 Co-Authored-By: Claude * test(spring): Run each Spring Boot version in its own CI job One job per Boot line doubled the matrix wall time, e.g. 12.5 minutes instead of about 6 for Boot 3. A single runner ran every version's suites, and the bare task name made Gradle configure every project to find it. The versions move to gradle/spring-boot-versions.json. build-logic reads it, and each workflow turns its line into one job per version, which runs that version's aggregate in both modules by project path. The version list stays in one place, and the check names match main's again. Co-Authored-By: Claude Opus 5.5 * test(spring): Create the Spring Boot matrix classpaths as resolvable configurations They're only ever resolved, so configurations.resolvable() fits them better than create() with isCanBeConsumed = false: nothing can consume them or declare dependencies on them. Every matrix classpath resolves the same as before. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude --------- Co-authored-by: Claude --- .cursor/rules/e2e_tests.mdc | 15 ++- .github/workflows/spring-boot-2-matrix.yml | 37 +++--- .github/workflows/spring-boot-3-matrix.yml | 38 +++--- .github/workflows/spring-boot-4-matrix.yml | 39 ++++--- build-logic/build.gradle.kts | 4 + .../sentry/gradle/SpringBootMatrixPlugin.kt | 109 ++++++++++++++++++ gradle/spring-boot-versions.json | 5 + sentry-spring-7/build.gradle.kts | 7 ++ sentry-spring-boot-4/build.gradle.kts | 27 ++++- sentry-spring-boot-jakarta/build.gradle.kts | 26 ++++- sentry-spring-boot/build.gradle.kts | 88 +++++++------- .../boot/agentfixture/KafkaController.kt | 4 +- .../boot/integrationtest/KafkaController.kt | 3 +- .../integrationtest/KafkaIntegrationTest.kt | 4 +- .../KafkaOtelCoexistenceIntegrationTest.kt | 4 +- .../KafkaController.kt | 4 +- .../KafkaOtelCoexistenceIntegrationTest.kt | 4 +- sentry-spring-jakarta/build.gradle.kts | 7 ++ sentry-spring/build.gradle.kts | 7 ++ 19 files changed, 320 insertions(+), 112 deletions(-) create mode 100644 build-logic/src/main/kotlin/io/sentry/gradle/SpringBootMatrixPlugin.kt create mode 100644 gradle/spring-boot-versions.json diff --git a/.cursor/rules/e2e_tests.mdc b/.cursor/rules/e2e_tests.mdc index 1785e61b5c1..3603794cd5c 100644 --- a/.cursor/rules/e2e_tests.mdc +++ b/.cursor/rules/e2e_tests.mdc @@ -64,9 +64,18 @@ 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. +- The `springBootMatrix` block in each Spring module's build file runs its suites on every version + of its Spring Boot line in `gradle/spring-boot-versions.json`, with a task per suite and version, + e.g. `integrationTestWebfluxBoot3_2_12`. The suites and the SDK compile once, against the + catalog's version; only the runtime classpath changes. `allIntegrationTestsBoot3_2_12` runs one + version's suites, `allIntegrationTestsBoot3` every version's. +- The `spring-boot-{2,3,4}-matrix.yml` workflows read the same file and run each version in its own + job, e.g. `./gradlew :sentry-spring-jakarta:allIntegrationTestsBoot3_2_12 + :sentry-spring-boot-jakarta:allIntegrationTestsBoot3_2_12`. The suites aren't part of `check`, so + a new suite only runs on CI once it's added to its module's `springBootMatrix` block, and a new + module once it's added to its workflow. +- Suite code may only use APIs that exist in the oldest version of its line. Compiled against a + newer one, a missing API fails at runtime with `NoSuchMethodError` or `NoClassDefFoundError`. ## Gotchas diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index edfe9e74fc0..02b166fca40 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -15,13 +15,30 @@ concurrency: cancel-in-progress: true jobs: + versions: + runs-on: ubuntu-latest + name: Spring Boot 2.x versions + outputs: + versions: ${{ steps.versions.outputs.versions }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: gradle + + - name: Read Spring Boot 2.x versions + id: versions + run: echo "versions=$(jq -c .boot2 gradle/spring-boot-versions.json)" >> "$GITHUB_OUTPUT" + spring-boot-2-matrix: + needs: versions timeout-minutes: 45 runs-on: ubuntu-latest strategy: fail-fast: false matrix: - springboot-version: [ '2.4.13', '2.5.15', '2.6.15', '2.7.0', '2.7.18' ] + springboot-version: ${{ fromJSON(needs.versions.outputs.versions) }} name: Spring Boot ${{ matrix.springboot-version }} env: @@ -51,21 +68,13 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Update Spring Boot 2.x version - run: | - springboot_version="${{ matrix.springboot-version }}" - 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: Test integration suites + # Project paths, unlike a bare task name, keep Gradle from configuring every project. run: | - ./gradlew \ - :sentry-spring:integrationTest \ - :sentry-spring-boot:integrationTest \ - :sentry-spring-boot:integrationTestWebflux \ - :sentry-spring-boot:integrationTestOtelNoAgent \ - :sentry-spring-boot:integrationTestOtelAgent \ - :sentry-spring-boot:integrationTestOtelAgentWithoutAutoInit + task="allIntegrationTestsBoot${SPRINGBOOT_VERSION//./_}" + ./gradlew ":sentry-spring:$task" ":sentry-spring-boot:$task" + env: + SPRINGBOOT_VERSION: ${{ matrix.springboot-version }} - name: Upload test results if: always() diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index f698683cdc6..0cbe496cdae 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -15,13 +15,30 @@ concurrency: cancel-in-progress: true jobs: + versions: + runs-on: ubuntu-latest + name: Spring Boot 3.x versions + outputs: + versions: ${{ steps.versions.outputs.versions }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: gradle + + - name: Read Spring Boot 3.x versions + id: versions + run: echo "versions=$(jq -c .boot3 gradle/spring-boot-versions.json)" >> "$GITHUB_OUTPUT" + spring-boot-3-matrix: + needs: versions timeout-minutes: 45 runs-on: ubuntu-latest strategy: fail-fast: false matrix: - springboot-version: [ '3.2.12', '3.3.13', '3.4.13', '3.5.13' ] + springboot-version: ${{ fromJSON(needs.versions.outputs.versions) }} name: Spring Boot ${{ matrix.springboot-version }} env: @@ -51,22 +68,13 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Update Spring Boot 3.x version - run: | - springboot_version="${{ matrix.springboot-version }}" - 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: Test integration suites + # Project paths, unlike a bare task name, keep Gradle from configuring every project. run: | - ./gradlew \ - :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 + task="allIntegrationTestsBoot${SPRINGBOOT_VERSION//./_}" + ./gradlew ":sentry-spring-jakarta:$task" ":sentry-spring-boot-jakarta:$task" + env: + SPRINGBOOT_VERSION: ${{ matrix.springboot-version }} - name: Upload test results if: always() diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 8f91289db98..75070612eb4 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -15,13 +15,30 @@ concurrency: cancel-in-progress: true jobs: + versions: + runs-on: ubuntu-latest + name: Spring Boot 4.x versions + outputs: + versions: ${{ steps.versions.outputs.versions }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: gradle + + - name: Read Spring Boot 4.x versions + id: versions + run: echo "versions=$(jq -c .boot4 gradle/spring-boot-versions.json)" >> "$GITHUB_OUTPUT" + spring-boot-4-matrix: + needs: versions timeout-minutes: 45 runs-on: ubuntu-latest strategy: fail-fast: false matrix: - springboot-version: [ '4.0.0', '4.0.5', '4.1.0' ] + springboot-version: ${{ fromJSON(needs.versions.outputs.versions) }} name: Spring Boot ${{ matrix.springboot-version }} env: @@ -51,23 +68,13 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - name: Update Spring Boot 4.x version - run: | - springboot_version="${{ matrix.springboot-version }}" - 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: Test integration suites + # Project paths, unlike a bare task name, keep Gradle from configuring every project. run: | - ./gradlew \ - :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 + task="allIntegrationTestsBoot${SPRINGBOOT_VERSION//./_}" + ./gradlew ":sentry-spring-7:$task" ":sentry-spring-boot-4:$task" + env: + SPRINGBOOT_VERSION: ${{ matrix.springboot-version }} - name: Upload test results if: always() diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 385d1b08386..8acc2b4e999 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -28,5 +28,9 @@ gradlePlugin { id = "io.sentry.animalsniffer.android" implementationClass = "io.sentry.gradle.SentryAnimalSnifferAndroidPlugin" } + register("sentrySpringBootMatrix") { + id = "io.sentry.spring-boot-matrix" + implementationClass = "io.sentry.gradle.SpringBootMatrixPlugin" + } } } diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SpringBootMatrixPlugin.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SpringBootMatrixPlugin.kt new file mode 100644 index 00000000000..c75a316651e --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/SpringBootMatrixPlugin.kt @@ -0,0 +1,109 @@ +package io.sentry.gradle + +import groovy.json.JsonSlurper +import javax.inject.Inject +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.FileCollection +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.testing.Test +import org.gradle.language.base.plugins.LifecycleBasePlugin + +/** + * Runs integration suites on each Spring Boot version of a line, without recompiling anything. + * + * The suites and the SDK compile once, against the version catalog's Spring Boot version. For each + * version, a suite's test task reuses its compiled classes on a runtime classpath resolved for that + * version, which is how users run our published jars on their Spring Boot version. + */ +class SpringBootMatrixPlugin : Plugin { + override fun apply(project: Project) { + project.extensions.create("springBootMatrix", SpringBootMatrixExtension::class.java) + } +} + +abstract class SpringBootMatrixExtension @Inject constructor(private val project: Project) { + private var versions = emptyList() + + /** + * Registers `allIntegrationTestsBoot` for each version of Spring Boot [line] in + * `gradle/spring-boot-versions.json`, and `allIntegrationTestsBoot` for all of them. The CI + * workflows read the same file and run each version in its own job. A WAR module uses its Boot + * module's line, because the Boot BOM decides its Spring version. + */ + fun versions(line: Int) { + val file = project.layout.settingsDirectory.file("gradle/spring-boot-versions.json") + val lines = + JsonSlurper().parseText(project.providers.fileContents(file).asText.get()) as Map<*, *> + val versions = (lines["boot$line"] as List<*>).map { it as String } + this.versions = versions + project.tasks.register("allIntegrationTestsBoot$line") { + description = "Runs the integration suites on Spring Boot ${versions.joinToString()}." + group = LifecycleBasePlugin.VERIFICATION_GROUP + dependsOn(versions.map { aggregateName(it) }) + } + versions.forEach { version -> + project.tasks.register(aggregateName(version)) { + description = "Runs the integration suites on Spring Boot $version." + group = LifecycleBasePlugin.VERIFICATION_GROUP + } + } + } + + /** + * Registers `Boot` for each version, running [suite]'s tests. [name] tells apart + * several tasks of one suite, and [configure] adds what the suite's own task has beyond its + * classes and classpath. + */ + fun test(suite: String, name: String = suite, configure: Test.(version: String) -> Unit = {}) { + check(versions.isNotEmpty()) { "Call versions() before test()" } + versions.forEach { version -> + // Created now rather than with the task, so `dependencies --configuration` finds it. + val runtimeClasspath = runtimeClasspath(suite, version) + val task = + project.tasks.register("$name${suffix(version)}", Test::class.java) { + description = "Runs $name on Spring Boot $version." + group = LifecycleBasePlugin.VERIFICATION_GROUP + testClassesDirs = sourceSet(suite).output.classesDirs + classpath = runtimeClasspath + useJUnit() + configure(version) + } + project.tasks.named(aggregateName(version)) { dependsOn(task) } + } + } + + /** [sourceSet]'s output and runtime dependencies, with Spring Boot at [version]. */ + fun runtimeClasspath(sourceSet: String, version: String): FileCollection { + val sources = sourceSet(sourceSet) + val name = "$sourceSet${suffix(version)}RuntimeClasspath" + val configurations = project.configurations + val dependencies = + configurations.findByName(name) + ?: configurations + .resolvable(name) { + extendsFrom( + configurations.getByName(sources.implementationConfigurationName), + configurations.getByName(sources.runtimeOnlyConfigurationName), + ) + attributes.addAllLater( + configurations.getByName(sources.runtimeClasspathConfigurationName).attributes + ) + // What setting the version in the catalog does: every Spring Boot artifact, the BOM + // included, is in this group. Libraries pinned to their own version still win + // conflicts. + resolutionStrategy.eachDependency { + if (requested.group == "org.springframework.boot") useVersion(version) + } + } + .get() + return sources.output + dependencies + } + + private fun sourceSet(name: String) = + project.extensions.getByType(SourceSetContainer::class.java).getByName(name) + + private fun aggregateName(version: String) = "allIntegrationTests${suffix(version)}" + + private fun suffix(version: String) = "Boot" + version.replace('.', '_') +} diff --git a/gradle/spring-boot-versions.json b/gradle/spring-boot-versions.json new file mode 100644 index 00000000000..efe0fdd0624 --- /dev/null +++ b/gradle/spring-boot-versions.json @@ -0,0 +1,5 @@ +{ + "boot2": ["2.7.0", "2.7.18"], + "boot3": ["3.2.12", "3.3.13", "3.4.13", "3.5.13"], + "boot4": ["4.0.0", "4.0.5", "4.1.0"] +} diff --git a/sentry-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts index eb920eea82f..90b7614867b 100644 --- a/sentry-spring-7/build.gradle.kts +++ b/sentry-spring-7/build.gradle.kts @@ -5,6 +5,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spring-boot-matrix") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -143,3 +144,9 @@ testing { } } } + +// The suites on each Spring Boot 4 version the CI matrix tests. +springBootMatrix { + versions(line = 4) + test("integrationTest") +} diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 90d40e162de..5ec8e2a6aef 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -6,6 +6,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spring-boot-matrix") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) alias(libs.plugins.errorprone) @@ -388,13 +389,17 @@ testing { /** * Has the OpenTelemetry agent tests start agentFixture's app in a child JVM, with the built agent * attached. [autoInit] decides whether the agent initializes Sentry or, as with - * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. + * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. [appClasspath] is the app's classpath for + * another Spring Boot version. */ -fun Test.runAgentFixture(autoInit: Boolean) { +fun Test.runAgentFixture( + autoInit: Boolean, + appClasspath: FileCollection = agentFixture.runtimeClasspath, +) { jvmArgumentProviders.add( objects.newInstance().apply { agentJar.from(otelAgentJar) - appClasspath.from(agentFixture.runtimeClasspath) + this.appClasspath.from(appClasspath) } ) systemProperty("sentry.test.agentAutoInit", autoInit) @@ -424,3 +429,19 @@ listOf( .forEach { tasks.named(it) { compilerOptions.javaParameters = true } } + +// The suites on each Spring Boot 4 version the CI matrix tests. +springBootMatrix { + versions(line = 4) + test("integrationTest") + test("integrationTestLog4j2") + test("integrationTestWebflux") + test("integrationTestOtelNoAgent") + test("integrationTestOtlp") + test("integrationTestOtelAgent") { version -> + runAgentFixture(autoInit = true, appClasspath = runtimeClasspath("agentFixture", version)) + } + test("integrationTestOtelAgent", name = "integrationTestOtelAgentWithoutAutoInit") { version -> + runAgentFixture(autoInit = false, appClasspath = runtimeClasspath("agentFixture", version)) + } +} diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index be264b890f2..c303f1f81ee 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -6,6 +6,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spring-boot-matrix") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -328,13 +329,17 @@ testing { /** * Has the OpenTelemetry agent tests start agentFixture's app in a child JVM, with the built agent * attached. [autoInit] decides whether the agent initializes Sentry or, as with - * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. + * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. [appClasspath] is the app's classpath for + * another Spring Boot version. */ -fun Test.runAgentFixture(autoInit: Boolean) { +fun Test.runAgentFixture( + autoInit: Boolean, + appClasspath: FileCollection = agentFixture.runtimeClasspath, +) { jvmArgumentProviders.add( objects.newInstance().apply { agentJar.from(otelAgentJar) - appClasspath.from(agentFixture.runtimeClasspath) + this.appClasspath.from(appClasspath) } ) systemProperty("sentry.test.agentAutoInit", autoInit) @@ -363,3 +368,18 @@ listOf( .forEach { tasks.named(it) { compilerOptions.javaParameters = true } } + +// The suites on each Spring Boot 3 version the CI matrix tests. +springBootMatrix { + versions(line = 3) + test("integrationTest") + test("integrationTestLog4j2") + test("integrationTestWebflux") + test("integrationTestOtelNoAgent") + test("integrationTestOtelAgent") { version -> + runAgentFixture(autoInit = true, appClasspath = runtimeClasspath("agentFixture", version)) + } + test("integrationTestOtelAgent", name = "integrationTestOtelAgentWithoutAutoInit") { version -> + runAgentFixture(autoInit = false, appClasspath = runtimeClasspath("agentFixture", version)) + } +} diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index b20f500e64f..2cc37bec381 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -6,6 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spring-boot-matrix") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -22,9 +23,8 @@ tasks.named("compileKotlin") { // Spring scans the integration test apps' own bytecode with its repackaged ASM at startup, and // the ASM in Spring before 5.3.26 fails on class files as new as the toolchain's: "Unsupported -// class file major version 65". In the Spring Boot 2.x matrix, 2.4.13 (Spring 5.3.13) reads up -// to Java 18 and 2.7.0 (Spring 5.3.20) up to Java 19, so the suites target 17. Remove this once -// the matrix drops 2.4.13 and moves 2.7.0 to 2.7.10 or later. +// class file major version 65". The Spring Boot 2.x matrix's 2.7.0 (Spring 5.3.20) reads up to +// Java 19, so the suites target 17. Remove this once the matrix moves 2.7.0 to 2.7.10 or later. // The suites are Kotlin only. Their Java tasks keep the toolchain's level, because it decides // which Java version their classpaths ask for, and sentry-test-support needs the toolchain's. tasks @@ -138,13 +138,6 @@ tasks.jar { } } -// Spring Boot supports GraphQL only from 2.7, and its Kafka auto-configuration needs 2.7 for the -// spring-kafka version Sentry integrates with. The Spring Boot 2 matrix also covers older versions. -val bootSupportsGraphqlAndKafka = - libs.versions.springboot2.get().split(".").let { (major, minor) -> - major.toInt() > 2 || minor.toInt() >= 7 - } - // The OpenTelemetry agent tests run this app in a child JVM, on its own classpath like a real app // with the agent attached, rather than on the test's classpath. val agentFixture = sourceSets.create("agentFixture") @@ -165,20 +158,18 @@ dependencies { "agentFixtureImplementation"(platform(libs.springboot2.bom)) "agentFixtureImplementation"(projects.sentrySpringBootStarter) "agentFixtureImplementation"(projects.sentryAsyncProfiler) + "agentFixtureImplementation"(projects.sentryGraphql) "agentFixtureImplementation"(projects.sentryJdbc) + "agentFixtureImplementation"(projects.sentryKafka) "agentFixtureImplementation"(projects.sentryLogback) "agentFixtureImplementation"(kotlin(Config.kotlinStdLib)) "agentFixtureImplementation"(libs.otel) + "agentFixtureImplementation"(libs.springboot.starter.graphql) "agentFixtureImplementation"(libs.springboot.starter.jdbc) "agentFixtureImplementation"(libs.springboot.starter.web) "agentFixtureImplementation"(libs.springboot.starter.webflux) + "agentFixtureImplementation"(libs.spring.kafka2) "agentFixtureRuntimeOnly"(libs.hsqldb) - if (bootSupportsGraphqlAndKafka) { - "agentFixtureImplementation"(projects.sentryGraphql) - "agentFixtureImplementation"(projects.sentryKafka) - "agentFixtureImplementation"(libs.springboot.starter.graphql) - "agentFixtureImplementation"(libs.spring.kafka2) - } otelAgentJar(projects.sentryOpentelemetry.sentryOpentelemetryAgent) } @@ -193,7 +184,9 @@ testing { implementation(platform(libs.springboot2.bom)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql) implementation(projects.sentryJdbc) + implementation(projects.sentryKafka) implementation(projects.sentryLogback) implementation(projects.sentryOkhttp) implementation(projects.sentryTestSupport) @@ -203,18 +196,14 @@ testing { implementation(libs.okhttp) implementation(libs.springboot.starter.aop) implementation(libs.springboot.starter.cache) + implementation(libs.springboot.starter.graphql) implementation(libs.springboot.starter.jdbc) implementation(libs.springboot.starter.test) implementation(libs.springboot.starter.web) implementation(libs.springboot.starter.webflux) + implementation(libs.spring.kafka2) + implementation(libs.spring.kafka2.test) runtimeOnly(libs.hsqldb) - if (bootSupportsGraphqlAndKafka) { - implementation(projects.sentryGraphql) - implementation(projects.sentryKafka) - implementation(libs.springboot.starter.graphql) - implementation(libs.spring.kafka2) - implementation(libs.spring.kafka2.test) - } } } @@ -225,16 +214,14 @@ testing { implementation(platform(libs.springboot2.bom)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql) implementation(projects.sentryLogback) implementation(projects.sentryTestSupport) implementation(libs.kotlin.test.junit) implementation(libs.google.truth) + implementation(libs.springboot.starter.graphql) implementation(libs.springboot.starter.test) implementation(libs.springboot.starter.webflux) - if (bootSupportsGraphqlAndKafka) { - implementation(projects.sentryGraphql) - implementation(libs.springboot.starter.graphql) - } } } @@ -247,24 +234,22 @@ testing { implementation(projects.sentrySpringBootStarter) implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql) implementation(projects.sentryJdbc) + implementation(projects.sentryKafka) implementation(projects.sentryLogback) implementation(projects.sentryTestSupport) implementation(libs.kotlin.test.junit) implementation(libs.google.truth) implementation(libs.springboot.starter.aop) + implementation(libs.springboot.starter.graphql) implementation(libs.springboot.starter.jdbc) implementation(libs.springboot.starter.test) implementation(libs.springboot.starter.web) implementation(libs.springboot.starter.webflux) + implementation(libs.spring.kafka2) + implementation(libs.spring.kafka2.test) runtimeOnly(libs.hsqldb) - if (bootSupportsGraphqlAndKafka) { - implementation(projects.sentryGraphql) - implementation(projects.sentryKafka) - implementation(libs.springboot.starter.graphql) - implementation(libs.spring.kafka2) - implementation(libs.spring.kafka2.test) - } } } @@ -279,9 +264,7 @@ testing { // For TestRestTemplate. Only the app's JVM runs the agent, so it doesn't trace the tests. implementation(libs.springboot.starter.test) implementation(libs.springboot.starter.web) - if (bootSupportsGraphqlAndKafka) { - implementation(libs.spring.kafka2.test) - } + implementation(libs.spring.kafka2.test) } targets.all { testTask.configure { runAgentFixture(autoInit = true) } } } @@ -291,13 +274,17 @@ testing { /** * Has the OpenTelemetry agent tests start agentFixture's app in a child JVM, with the built agent * attached. [autoInit] decides whether the agent initializes Sentry or, as with - * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. + * `SENTRY_AUTO_INIT=false`, the Spring Boot starter. [appClasspath] is the app's classpath for + * another Spring Boot version. */ -fun Test.runAgentFixture(autoInit: Boolean) { +fun Test.runAgentFixture( + autoInit: Boolean, + appClasspath: FileCollection = agentFixture.runtimeClasspath, +) { jvmArgumentProviders.add( objects.newInstance().apply { agentJar.from(otelAgentJar) - appClasspath.from(agentFixture.runtimeClasspath) + this.appClasspath.from(appClasspath) } ) systemProperty("sentry.test.agentAutoInit", autoInit) @@ -325,10 +312,19 @@ listOf( "compileIntegrationTestOtelAgentKotlin", ) .forEach { - tasks.named(it) { - compilerOptions.javaParameters = true - if (!bootSupportsGraphqlAndKafka) { - exclude("**/Graphql*", "**/Greeting*", "**/Kafka*") - } - } + tasks.named(it) { compilerOptions.javaParameters = true } } + +// The suites on each Spring Boot 2 version the CI matrix tests. +springBootMatrix { + versions(line = 2) + test("integrationTest") + test("integrationTestWebflux") + test("integrationTestOtelNoAgent") + test("integrationTestOtelAgent") { version -> + runAgentFixture(autoInit = true, appClasspath = runtimeClasspath("agentFixture", version)) + } + test("integrationTestOtelAgent", name = "integrationTestOtelAgentWithoutAutoInit") { version -> + runAgentFixture(autoInit = false, appClasspath = runtimeClasspath("agentFixture", version)) + } +} diff --git a/sentry-spring-boot/src/agentFixture/kotlin/io/sentry/spring/boot/agentfixture/KafkaController.kt b/sentry-spring-boot/src/agentFixture/kotlin/io/sentry/spring/boot/agentfixture/KafkaController.kt index 7566632434e..28cf59e7376 100644 --- a/sentry-spring-boot/src/agentFixture/kotlin/io/sentry/spring/boot/agentfixture/KafkaController.kt +++ b/sentry-spring-boot/src/agentFixture/kotlin/io/sentry/spring/boot/agentfixture/KafkaController.kt @@ -11,8 +11,8 @@ import org.springframework.web.bind.annotation.RestController const val KAFKA_TOPIC = "sentry-topic" -// Kafka only works with Spring Boot 2.7 and later, so its beans belong to a profile rather than to -// the app most tests share. See KafkaOtelCoexistenceIntegrationTest. +// The beans belong to a profile, so only the app KafkaOtelCoexistenceIntegrationTest starts needs a +// broker. @RestController @Profile("kafka") diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt index 04922bdae08..16138098859 100644 --- a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt @@ -11,8 +11,7 @@ import org.springframework.web.bind.annotation.RestController const val KAFKA_TOPIC = "sentry-topic" -// Kafka only works with Spring Boot 2.7 and later, so its beans and broker belong to a profile -// rather than to every test's context. See KafkaIntegrationTest. +// The beans belong to a profile, so only KafkaIntegrationTest's context needs a broker. @RestController @Profile("kafka") diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaIntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaIntegrationTest.kt index 6bd290baf78..e6e82412680 100644 --- a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaIntegrationTest.kt @@ -11,8 +11,8 @@ import org.springframework.test.context.TestPropertySource /** * A message sent through Kafka is traced on both sides: where it's published and processed. * - * Unlike the other tests, this one runs its own application context with an in-JVM broker, because - * the shared context has to work on Spring Boot versions without Kafka support. + * Unlike the other tests, this one runs its own application context with an in-JVM broker, so the + * shared context doesn't need one. */ @ActiveProfiles("kafka") @EmbeddedKafka(topics = [KAFKA_TOPIC], bootstrapServersProperty = "spring.kafka.bootstrap-servers") diff --git a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt index c786274b9ee..6913a908fa2 100644 --- a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt @@ -12,8 +12,8 @@ import org.springframework.kafka.test.EmbeddedKafkaBroker * With the agent active, OpenTelemetry traces Kafka instead of Sentry's own Spring Kafka * integration. * - * Unlike the other tests, this one starts an app of its own, with Kafka and an in-JVM broker. The - * shared app leaves them out, because it has to work on Spring Boot versions without Kafka support. + * Unlike the other tests, this one starts an app of its own, with Kafka and an in-JVM broker, so + * the shared app doesn't need a broker. */ class KafkaOtelCoexistenceIntegrationTest : IntegrationTest() { override val app: AgentApp diff --git a/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaController.kt b/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaController.kt index e5641460439..24f6ed7b6b1 100644 --- a/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaController.kt +++ b/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaController.kt @@ -11,8 +11,8 @@ import org.springframework.web.bind.annotation.RestController const val KAFKA_TOPIC = "sentry-topic" -// Kafka only works with Spring Boot 2.7 and later, so its beans and broker belong to a profile -// rather than to every test's context. See KafkaOtelCoexistenceIntegrationTest. +// The beans belong to a profile, so only KafkaOtelCoexistenceIntegrationTest's context needs a +// broker. @RestController @Profile("kafka") diff --git a/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt b/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt index a2a01ce0c44..5efef8875b1 100644 --- a/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt @@ -11,8 +11,8 @@ import org.springframework.test.context.TestPropertySource /** * With OpenTelemetry active, it traces Kafka instead of Sentry's own Spring Kafka integration. * - * Unlike the other tests, this one adds Kafka and an in-JVM broker to its application context. The - * others leave them out, because they have to work on Spring Boot versions without Kafka support. + * Unlike the other tests, this one adds Kafka and an in-JVM broker to its application context, so + * the others don't need a broker. */ @ActiveProfiles("kafka") @EmbeddedKafka(topics = [KAFKA_TOPIC], bootstrapServersProperty = "spring.kafka.bootstrap-servers") diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index 4b5e3b00107..ee9b057ec6e 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -5,6 +5,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spring-boot-matrix") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -130,3 +131,9 @@ testing { } } } + +// The suites on each Spring Boot 3 version the CI matrix tests. +springBootMatrix { + versions(line = 3) + test("integrationTest") +} diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index 9836686857d..556e4ecc8d8 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -5,6 +5,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") + id("io.sentry.spring-boot-matrix") alias(libs.plugins.kotlin.jvm) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -118,3 +119,9 @@ testing { } } } + +// The suites on each Spring Boot 2 version the CI matrix tests. +springBootMatrix { + versions(line = 2) + test("integrationTest") +} From cf6da462777ca5dd548b4ffdd7b21b967cb20953 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:26:58 +0000 Subject: [PATCH 11/40] chore: update scripts/update-sentry-native-ndk.sh to 0.17.5 (#6409) Co-authored-by: GitHub --- CHANGELOG.md | 3 +++ gradle/libs.versions.toml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee444a8d3da..d5d16525733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ - Bump epitaph (Android tombstone parsing) from 0.1.1 to 0.2.0 ([#6402](https://github.com/getsentry/sentry-java/pull/6402)) - Reject malformed protobuf tags, oversized lengths, invalid varints, and invalid reader bounds instead of silently accepting partial/corrupt tombstone data. +- Bump Native SDK from v0.17.4 to v0.17.5 ([#6409](https://github.com/getsentry/sentry-java/pull/6409)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0175) + - [diff](https://github.com/getsentry/sentry-native/compare/0.17.4...0.17.5) ## 8.62.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09ac7a2113e..e2cbd3feb46 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -188,7 +188,7 @@ quartz = { module = "org.quartz-scheduler:quartz", version = "2.3.0" } reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } -sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.17.4" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.17.5" } servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" } servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } From 054686ddd4ff3a0b10e5730acab0823f34332b07 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 28 Oct 2026 16:47:04 +0100 Subject: [PATCH 12/40] test: Fix follow-ups from the sample tests stack review (#6390) Fix issues the review of the sample tests stack found in the new in-module end-to-end tests: - MockSentryServer keeps the error when it can't read a body and rethrows it while waiting, so a serialization bug no longer looks like a missing envelope. - ChildJvmApp only takes a port that two reads agree on. A partly written port such as "54" of "54321" parsed and pointed the test at the wrong port. - The agentless test starts a span through the OpenTelemetry API and checks that it reaches Sentry. Before, it passed with the OpenTelemetry wiring broken. - The Person tests use id 11 and match their transaction by URL, so envelopes from the tracing tests' /person/1 calls can't satisfy them. - The Boot 2 Kafka test shares the suite's context and broker, as on Boot 3 and 4. Its second context re-ran Sentry.init and made later tests depend on the run order. Boot 2.4 to 2.6 were the reason for the split and are gone. - Tests fail clearly when a system property the Gradle task sets is missing, rather than running with -javaagent:null. - Remove the Boot 2 version gating from the samples, the runner's files from .gitignore, and the unused jackson-kotlin entry. Co-authored-by: Nelson Osacky --- .claude/skills/test/SKILL.md | 1 + .gitignore | 3 -- gradle/libs.versions.toml | 1 - .../opentelemetry/agent/AgentSmokeTest.kt | 3 +- .../agentless/AgentlessEndToEndTest.kt | 11 +++++- .../build.gradle.kts | 39 ++----------------- .../build.gradle.kts | 39 ++----------------- .../build.gradle.kts | 27 +------------ .../build.gradle.kts | 39 ++----------------- .../integrationtest/PersonIntegrationTest.kt | 16 ++++++-- .../otelagentintegrationtest/AgentApp.kt | 7 ++-- .../PersonIntegrationTest.kt | 12 ++++-- .../PersonIntegrationTest.kt | 16 ++++++-- .../PersonIntegrationTest.kt | 16 ++++++-- .../integrationtest/PersonIntegrationTest.kt | 16 ++++++-- .../otelagentintegrationtest/AgentApp.kt | 7 ++-- .../PersonIntegrationTest.kt | 12 ++++-- .../PersonIntegrationTest.kt | 16 ++++++-- .../boot/integrationtest/IntegrationTest.kt | 16 +++++--- .../boot/integrationtest/KafkaController.kt | 5 --- .../integrationtest/KafkaIntegrationTest.kt | 16 +------- .../integrationtest/PersonIntegrationTest.kt | 16 ++++++-- .../boot/otelagentintegrationtest/AgentApp.kt | 7 ++-- .../PersonIntegrationTest.kt | 12 ++++-- .../PersonIntegrationTest.kt | 16 ++++++-- .../main/kotlin/io/sentry/test/ChildJvmApp.kt | 14 ++++++- .../kotlin/io/sentry/test/MockSentryServer.kt | 18 ++++++++- .../io/sentry/test/MockSentryServerTest.kt | 15 +++++++ 28 files changed, 202 insertions(+), 214 deletions(-) diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md index 0a066c76de0..5e19619b1b2 100644 --- a/.claude/skills/test/SKILL.md +++ b/.claude/skills/test/SKILL.md @@ -32,6 +32,7 @@ Extract the module name and optional test class filter from the argument. | Signal | Test Type | |--------|-----------| | Path contains `/src/integrationTest*/` or `/src/agentFixture/` | Integration test suite | +| Module name only, and the user asks for integration or end-to-end tests | Integration test suite: each `src/integrationTest*/` directory of the module | | Path contains `sentry-samples/`, or module name starts with `sentry-samples-` | None: samples have no tests | | Everything else | Unit test | diff --git a/.gitignore b/.gitignore index f252087a5ab..b6301586e81 100644 --- a/.gitignore +++ b/.gitignore @@ -22,9 +22,6 @@ distributions/ *.vscode/ sentry-spring-boot-starter-jakarta/src/main/resources/META-INF/spring.factories sentry-samples/sentry-samples-spring-boot-jakarta/spy.log -sentry-mock-server.txt -tomcat-server.txt -spring-server.txt *.pid spy.log .kotlin diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e2cbd3feb46..2d1f9bd47cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -143,7 +143,6 @@ graphql-java22 = { module = "com.graphql-java:graphql-java", version = "22.1" } graphql-java24 = { module = "com.graphql-java:graphql-java", version = "24.0" } jackson-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } -jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } jetbrains-annotations = { module = "org.jetbrains:annotations", version = "23.0.0" } kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } 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 index 2764a7d183a..a401af0f32a 100644 --- 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 @@ -4,6 +4,7 @@ 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.requiredTestProperty import io.sentry.test.transaction import java.io.File import java.net.URL @@ -32,7 +33,7 @@ class AgentSmokeTest { mainClass = HelloServer::class.java.name, classpath = File(HelloServer::class.java.protectionDomain.codeSource.location.toURI()).path, - jvmArgs = listOf("-javaagent:${System.getProperty("sentry.test.agentJar")}"), + jvmArgs = listOf("-javaagent:${requiredTestProperty("sentry.test.agentJar")}"), env = mapOf( "SENTRY_DSN" to server.dsn, 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 index 8f9d5d0fb8a..6d519ed4174 100644 --- 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 @@ -41,7 +41,7 @@ class AgentlessEndToEndTest { } @Test - fun `Sentry set up by OpenTelemetry reports events, a profiled transaction and metrics`() { + fun `Sentry set up by OpenTelemetry reports events, a profiled transaction, OpenTelemetry spans 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") @@ -107,6 +107,11 @@ class AgentlessEndToEndTest { Thread.sleep(100) span.finish() transaction.finish() + // A span started through the OpenTelemetry API only reaches Sentry if the customizer wired + // Sentry's span processor into the SDK. + val otelSpan = + GlobalOpenTelemetry.getTracer("agentless-test").spanBuilder("otel span").startSpan() + otelSpan.end() Sentry.flush(5000) // Sentry exports OpenTelemetry spans in batches every 5 seconds. openTelemetry.sdkTracerProvider.forceFlush().join(5, TimeUnit.SECONDS) @@ -138,6 +143,10 @@ class AgentlessEndToEndTest { val sent = server.awaitEnvelope { it.transaction()?.transaction == "transaction name" }.transaction()!! assertThat(sent.spans.map { it.op }).contains("child") + val otelTransaction = + server.awaitEnvelope { it.transaction()?.transaction == "otel span" }.transaction()!! + assertThat(otelTransaction.contexts.trace!!.traceId.toString()) + .isEqualTo(otelSpan.spanContext.traceId) val profilerId = sent.contexts.profile?.profilerId assertThat(profilerId).isNotNull() server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts index a5ccb8d3224..62c5bcbd3aa 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts @@ -25,18 +25,6 @@ group = "io.sentry.sample.spring-boot" version = "0.0.1-SNAPSHOT" -fun springBoot2SupportsOptionalIntegrations(): Boolean { - val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") - val parts = version.split(".").map { it.toIntOrNull() ?: 0 } - val major = parts.getOrElse(0) { 0 } - val minor = parts.getOrElse(1) { 0 } - return major > 2 || (major == 2 && minor >= 7) -} - -val includeGraphql = - !project.hasProperty("excludeGraphql") && springBoot2SupportsOptionalIntegrations() -val includeKafka = !project.hasProperty("excludeKafka") && springBoot2SupportsOptionalIntegrations() - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") @@ -49,9 +37,7 @@ dependencies { implementation(libs.springboot.starter) implementation(libs.springboot.starter.actuator) implementation(libs.springboot.starter.aop) - if (includeGraphql) { - implementation(libs.springboot.starter.graphql) - } + implementation(libs.springboot.starter.graphql) implementation(libs.springboot.starter.jdbc) implementation(libs.springboot.starter.quartz) implementation(libs.springboot.starter.security) @@ -63,17 +49,13 @@ dependencies { implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryLogback) - if (includeGraphql) { - implementation(projects.sentryGraphql) - } + implementation(projects.sentryGraphql) implementation(projects.sentryQuartz) implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) implementation(projects.sentryAsyncProfiler) - if (includeKafka) { - implementation(libs.spring.kafka2) - implementation(projects.sentryKafka) - } + implementation(libs.spring.kafka2) + implementation(projects.sentryKafka) // database query tracing implementation(projects.sentryJdbc) @@ -101,16 +83,3 @@ tasks.jar { } tasks.startScripts { dependsOn(tasks.shadowJar) } - -configure { - main { - if (!includeGraphql) { - java.exclude("**/graphql/**") - resources.exclude("graphql/**") - } - if (!includeKafka) { - java.exclude("**/queues/kafka/**") - resources.exclude("application-kafka.properties") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts index dbb4d66dea3..652727f7468 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -25,18 +25,6 @@ group = "io.sentry.sample.spring-boot" version = "0.0.1-SNAPSHOT" -fun springBoot2SupportsOptionalIntegrations(): Boolean { - val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") - val parts = version.split(".").map { it.toIntOrNull() ?: 0 } - val major = parts.getOrElse(0) { 0 } - val minor = parts.getOrElse(1) { 0 } - return major > 2 || (major == 2 && minor >= 7) -} - -val includeGraphql = - !project.hasProperty("excludeGraphql") && springBoot2SupportsOptionalIntegrations() -val includeKafka = !project.hasProperty("excludeKafka") && springBoot2SupportsOptionalIntegrations() - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") @@ -48,9 +36,7 @@ dependencies { implementation(libs.springboot.starter) implementation(libs.springboot.starter.actuator) implementation(libs.springboot.starter.aop) - if (includeGraphql) { - implementation(libs.springboot.starter.graphql) - } + implementation(libs.springboot.starter.graphql) implementation(libs.springboot.starter.jdbc) implementation(libs.springboot.starter.quartz) implementation(libs.springboot.starter.security) @@ -62,17 +48,13 @@ dependencies { implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryLogback) - if (includeGraphql) { - implementation(projects.sentryGraphql) - } + implementation(projects.sentryGraphql) implementation(projects.sentryQuartz) implementation(projects.sentryAsyncProfiler) implementation(libs.otel) - if (includeKafka) { - implementation(libs.spring.kafka2) - implementation(projects.sentryKafka) - } + implementation(libs.spring.kafka2) + implementation(projects.sentryKafka) // database query tracing implementation(projects.sentryJdbc) @@ -101,19 +83,6 @@ tasks.jar { tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { - main { - if (!includeGraphql) { - java.exclude("**/graphql/**") - resources.exclude("graphql/**") - } - if (!includeKafka) { - java.exclude("**/queues/kafka/**") - resources.exclude("application-kafka.properties") - } - } -} - tasks.register("bootRunWithAgent").configure { group = "application" diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index 0294f940e9c..d01da9f56c5 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -25,30 +25,16 @@ group = "io.sentry.sample.spring-boot" version = "0.0.1-SNAPSHOT" -fun springBoot2SupportsGraphql(): Boolean { - val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") - val parts = version.split(".").map { it.toIntOrNull() ?: 0 } - val major = parts.getOrElse(0) { 0 } - val minor = parts.getOrElse(1) { 0 } - return major > 2 || (major == 2 && minor >= 7) -} - -val includeGraphql = !project.hasProperty("excludeGraphql") && springBoot2SupportsGraphql() - dependencies { implementation(platform(libs.springboot2.bom)) implementation(libs.springboot.starter.actuator) - if (includeGraphql) { - implementation(libs.springboot.starter.graphql) - } + implementation(libs.springboot.starter.graphql) implementation(libs.springboot.starter.webflux) implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryLogback) - if (includeGraphql) { - implementation(projects.sentryGraphql) - } + implementation(projects.sentryGraphql) implementation(projects.sentryAsyncProfiler) } @@ -74,15 +60,6 @@ tasks.jar { tasks.startScripts { dependsOn(tasks.shadowJar) } -configure { - main { - if (!includeGraphql) { - java.exclude("**/graphql/**") - resources.exclude("graphql/**") - } - } -} - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 15b90447529..b4f8bc105ec 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -25,18 +25,6 @@ group = "io.sentry.sample.spring-boot" version = "0.0.1-SNAPSHOT" -fun springBoot2SupportsOptionalIntegrations(): Boolean { - val version = libs.versions.springboot2.get().removeSuffix(".RELEASE") - val parts = version.split(".").map { it.toIntOrNull() ?: 0 } - val major = parts.getOrElse(0) { 0 } - val minor = parts.getOrElse(1) { 0 } - return major > 2 || (major == 2 && minor >= 7) -} - -val includeGraphql = - !project.hasProperty("excludeGraphql") && springBoot2SupportsOptionalIntegrations() -val includeKafka = !project.hasProperty("excludeKafka") && springBoot2SupportsOptionalIntegrations() - tasks.withType().configureEach { kotlin { compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") @@ -48,9 +36,7 @@ dependencies { implementation(libs.springboot.starter) implementation(libs.springboot.starter.actuator) implementation(libs.springboot.starter.aop) - if (includeGraphql) { - implementation(libs.springboot.starter.graphql) - } + implementation(libs.springboot.starter.graphql) implementation(libs.springboot.starter.jdbc) implementation(libs.springboot.starter.quartz) implementation(libs.springboot.starter.security) @@ -60,18 +46,14 @@ dependencies { implementation(libs.springboot.starter.websocket) implementation(libs.caffeine) - if (includeKafka) { - implementation(libs.spring.kafka2) - implementation(projects.sentryKafka) - } + implementation(libs.spring.kafka2) + implementation(projects.sentryKafka) implementation(Config.Libs.aspectj) implementation(Config.Libs.kotlinReflect) implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarter) implementation(projects.sentryLogback) - if (includeGraphql) { - implementation(projects.sentryGraphql) - } + implementation(projects.sentryGraphql) implementation(projects.sentryQuartz) implementation(projects.sentryAsyncProfiler) @@ -108,16 +90,3 @@ tasks.jar { } tasks.startScripts { dependsOn(tasks.shadowJar) } - -configure { - main { - if (!includeGraphql) { - java.exclude("**/graphql/**") - resources.exclude("graphql/**") - } - if (!includeKafka) { - java.exclude("**/queues/kafka/**") - resources.exclude("application-kafka.properties") - } - } -} diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt index 735ce890e3f..30543274d5d 100644 --- a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt @@ -19,24 +19,32 @@ class PersonIntegrationTest : IntegrationTest() { @Test fun `failed request reports errors, the transaction and logs`() { - val response = restTemplate.getForEntity("/person/1", String::class.java) + val response = restTemplate.getForEntity("/person/11", String::class.java) assertThat(response.statusCode.value()).isEqualTo(500) Sentry.flush(5000) val logged = - server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + server + .awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=11" } + .event()!! assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() val thrown = server .awaitEnvelope { - it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=11]" } == true } .event()!! assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() val transaction = - server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /person/{id}" && t.request?.url?.endsWith("/person/11") == true + } == true + } + .transaction()!! assertThat( transaction.hasOp( "http.server", diff --git a/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/AgentApp.kt b/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/AgentApp.kt index 82961625b3d..1ee54ed9795 100644 --- a/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/AgentApp.kt +++ b/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/AgentApp.kt @@ -2,6 +2,7 @@ package io.sentry.spring.boot4.otelagentintegrationtest import io.sentry.test.ChildJvmApp import io.sentry.test.MockSentryServer +import io.sentry.test.requiredTestProperty import org.springframework.boot.restclient.RestTemplateBuilder import org.springframework.boot.resttestclient.TestRestTemplate import org.springframework.kafka.test.EmbeddedKafkaKraftBroker @@ -15,7 +16,7 @@ import org.springframework.kafka.test.EmbeddedKafkaKraftBroker * Spring Boot starter. */ object AgentApp { - val autoInit = System.getProperty("sentry.test.agentAutoInit").toBoolean() + val autoInit = requiredTestProperty("sentry.test.agentAutoInit").toBoolean() val server = MockSentryServer() @@ -25,8 +26,8 @@ object AgentApp { private val app = ChildJvmApp.start( mainClass = "io.sentry.spring.boot4.agentfixture.AgentFixtureApplicationKt", - classpath = System.getProperty("sentry.test.appClasspath"), - jvmArgs = listOf("-javaagent:${System.getProperty("sentry.test.agentJar")}"), + classpath = requiredTestProperty("sentry.test.appClasspath"), + jvmArgs = listOf("-javaagent:${requiredTestProperty("sentry.test.agentJar")}"), // Environment variables, because the agent doesn't read Spring Boot's properties. env = mapOf( diff --git a/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/PersonIntegrationTest.kt index 4ad2d9f2b85..09d7aecda14 100644 --- a/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/PersonIntegrationTest.kt @@ -22,23 +22,27 @@ class PersonIntegrationTest : IntegrationTest() { @Test fun `failed request reports errors, spans with their flags, and logs`() { - val response = restTemplate.getForEntity("/person/1", String::class.java) + val response = restTemplate.getForEntity("/person/11", String::class.java) assertThat(response.statusCode.value()).isEqualTo(500) val logged = - server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + server + .awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=11" } + .event()!! assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() val thrown = server .awaitEnvelope { - it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=11]" } == true } .event()!! assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() val transactionEnvelope = server.awaitEnvelope { - it.transaction()?.transaction == "GET /person/{id}" + it.transaction()?.let { t -> + t.transaction == "GET /person/{id}" && t.request?.url?.endsWith("/person/11") == true + } == true } // Whichever initializes Sentry names the SDK, which shows that each mode takes its own path. assertThat(transactionEnvelope.header.sdkVersion!!.name) diff --git a/sentry-spring-boot-4/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot4/otelnoagentintegrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot4/otelnoagentintegrationtest/PersonIntegrationTest.kt index 036a22717e3..6e0359c459b 100644 --- a/sentry-spring-boot-4/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot4/otelnoagentintegrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot4/otelnoagentintegrationtest/PersonIntegrationTest.kt @@ -23,24 +23,32 @@ class PersonIntegrationTest : IntegrationTest() { @Test fun `failed request reports errors, nested spans with their flags, and logs`() { - val response = restTemplate.getForEntity("/person/1", String::class.java) + val response = restTemplate.getForEntity("/person/11", String::class.java) assertThat(response.statusCode.value()).isEqualTo(500) Sentry.flush(5000) val logged = - server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + server + .awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=11" } + .event()!! assertThat(logged.hasFlag("inner-feature-flag", true)).isTrue() val thrown = server .awaitEnvelope { - it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=11]" } == true } .event()!! assertThat(thrown.hasFlag("inner-feature-flag", true)).isTrue() val transaction = - server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /person/{id}" && t.request?.url?.endsWith("/person/11") == true + } == true + } + .transaction()!! assertThat(transaction.hasOp("http.server")).isTrue() assertThat( transaction.hasSpanWithOp( diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/PersonIntegrationTest.kt index 3c0e5ab9feb..50b1c660bb0 100644 --- a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/PersonIntegrationTest.kt @@ -48,24 +48,32 @@ class PersonIntegrationTest : IntegrationTest() { @Test fun `failed request reports errors, the transaction and logs`() { - val response = restTemplate.getForEntity("/person/1", String::class.java) + val response = restTemplate.getForEntity("/person/11", String::class.java) assertThat(response.statusCode.value()).isEqualTo(500) Sentry.flush(5000) val logged = - server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + server + .awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=11" } + .event()!! assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() val thrown = server .awaitEnvelope { - it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=11]" } == true } .event()!! assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() val transaction = - server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /person/{id}" && t.request?.url?.endsWith("/person/11") == true + } == true + } + .transaction()!! assertThat( transaction.hasOp( "http.server", diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt index a0838b6fee2..3f8ba722099 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt @@ -19,24 +19,32 @@ class PersonIntegrationTest : IntegrationTest() { @Test fun `failed request reports errors, the transaction and logs`() { - val response = restTemplate.getForEntity("/person/1", String::class.java) + val response = restTemplate.getForEntity("/person/11", String::class.java) assertThat(response.statusCode.value()).isEqualTo(500) Sentry.flush(5000) val logged = - server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + server + .awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=11" } + .event()!! assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() val thrown = server .awaitEnvelope { - it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=11]" } == true } .event()!! assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() val transaction = - server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /person/{id}" && t.request?.url?.endsWith("/person/11") == true + } == true + } + .transaction()!! assertThat( transaction.hasOp( "http.server", diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt index 9f9b0fedb98..c70f8df301a 100644 --- a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt @@ -2,6 +2,7 @@ package io.sentry.spring.boot.jakarta.otelagentintegrationtest import io.sentry.test.ChildJvmApp import io.sentry.test.MockSentryServer +import io.sentry.test.requiredTestProperty import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.boot.web.client.RestTemplateBuilder import org.springframework.kafka.test.EmbeddedKafkaKraftBroker @@ -15,7 +16,7 @@ import org.springframework.kafka.test.EmbeddedKafkaKraftBroker * Spring Boot starter. */ object AgentApp { - val autoInit = System.getProperty("sentry.test.agentAutoInit").toBoolean() + val autoInit = requiredTestProperty("sentry.test.agentAutoInit").toBoolean() val server = MockSentryServer() @@ -25,8 +26,8 @@ object AgentApp { private val app = ChildJvmApp.start( mainClass = "io.sentry.spring.boot.jakarta.agentfixture.AgentFixtureApplicationKt", - classpath = System.getProperty("sentry.test.appClasspath"), - jvmArgs = listOf("-javaagent:${System.getProperty("sentry.test.agentJar")}"), + classpath = requiredTestProperty("sentry.test.appClasspath"), + jvmArgs = listOf("-javaagent:${requiredTestProperty("sentry.test.agentJar")}"), // Environment variables, because the agent doesn't read Spring Boot's properties. env = mapOf( diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/PersonIntegrationTest.kt index eb76ffbf21a..62b2012163a 100644 --- a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/PersonIntegrationTest.kt @@ -22,23 +22,27 @@ class PersonIntegrationTest : IntegrationTest() { @Test fun `failed request reports errors, spans with their flags, and logs`() { - val response = restTemplate.getForEntity("/person/1", String::class.java) + val response = restTemplate.getForEntity("/person/11", String::class.java) assertThat(response.statusCode.value()).isEqualTo(500) val logged = - server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + server + .awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=11" } + .event()!! assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() val thrown = server .awaitEnvelope { - it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=11]" } == true } .event()!! assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() val transactionEnvelope = server.awaitEnvelope { - it.transaction()?.transaction == "GET /person/{id}" + it.transaction()?.let { t -> + t.transaction == "GET /person/{id}" && t.request?.url?.endsWith("/person/11") == true + } == true } // Whichever initializes Sentry names the SDK, which shows that each mode takes its own path. assertThat(transactionEnvelope.header.sdkVersion!!.name) diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/PersonIntegrationTest.kt index 25ead319e9c..e2ca6eb7b56 100644 --- a/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/jakarta/otelnoagentintegrationtest/PersonIntegrationTest.kt @@ -23,24 +23,32 @@ class PersonIntegrationTest : IntegrationTest() { @Test fun `failed request reports errors, nested spans with their flags, and logs`() { - val response = restTemplate.getForEntity("/person/1", String::class.java) + val response = restTemplate.getForEntity("/person/11", String::class.java) assertThat(response.statusCode.value()).isEqualTo(500) Sentry.flush(5000) val logged = - server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + server + .awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=11" } + .event()!! assertThat(logged.hasFlag("inner-feature-flag", true)).isTrue() val thrown = server .awaitEnvelope { - it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=11]" } == true } .event()!! assertThat(thrown.hasFlag("inner-feature-flag", true)).isTrue() val transaction = - server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /person/{id}" && t.request?.url?.endsWith("/person/11") == true + } == true + } + .transaction()!! assertThat(transaction.hasOp("http.server")).isTrue() assertThat( transaction.hasSpanWithOp( diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt index 4717ff9ac38..a83f50a73f3 100644 --- a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt @@ -7,6 +7,7 @@ import org.springframework.beans.factory.annotation.Value import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.boot.web.client.RestTemplateBuilder +import org.springframework.kafka.test.context.EmbeddedKafka import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.springframework.test.context.junit4.SpringRunner @@ -14,11 +15,14 @@ import org.springframework.test.context.junit4.SpringRunner /** * Drives [IntegrationTestApplication] over HTTP and asserts what reaches [server]. * - * Subclasses share one application context and one server, so the app starts once per suite. - * Envelopes that arrive late can outlive [server]'s reset, so tests match on values only they - * produce. + * Subclasses share one application context, one server and one Kafka broker, so each starts once + * per suite. Envelopes that arrive late can outlive [server]'s reset, so tests match on values only + * they produce. */ @RunWith(SpringRunner::class) +// An in-JVM broker, so no Docker is needed. As part of the context, it starts before the app's +// Kafka listener connects and stops after it. +@EmbeddedKafka(topics = [KAFKA_TOPIC], bootstrapServersProperty = "spring.kafka.bootstrap-servers") @SpringBootTest( classes = [IntegrationTestApplication::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @@ -27,14 +31,14 @@ import org.springframework.test.context.junit4.SpringRunner "sentry.traces-sample-rate=1.0", "sentry.clients.ok-http-enabled=true", "sentry.enable-cache-tracing=true", + "sentry.enable-queue-tracing=true", "sentry.logs.enabled=true", "sentry.profile-session-sample-rate=1.0", "sentry.profile-lifecycle=TRACE", "sentry.profiling-traces-dir-path=\${java.io.tmpdir}/sentry-integration-test/profiling-traces", - // A database per context, because KafkaIntegrationTest runs a second one that would - // otherwise find the first one's tables. - "spring.datasource.url=jdbc:p6spy:hsqldb:mem:\${random.uuid}", + "spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb", "spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver", + "spring.kafka.consumer.auto-offset-reset=earliest", ], ) abstract class IntegrationTest { diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt index 16138098859..063724a044c 100644 --- a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/KafkaController.kt @@ -1,6 +1,5 @@ package io.sentry.spring.boot.integrationtest -import org.springframework.context.annotation.Profile import org.springframework.kafka.annotation.KafkaListener import org.springframework.kafka.core.KafkaTemplate import org.springframework.stereotype.Component @@ -11,10 +10,7 @@ import org.springframework.web.bind.annotation.RestController const val KAFKA_TOPIC = "sentry-topic" -// The beans belong to a profile, so only KafkaIntegrationTest's context needs a broker. - @RestController -@Profile("kafka") @RequestMapping("/kafka") open class KafkaController(private val kafkaTemplate: KafkaTemplate) { @GetMapping("/produce") @@ -25,7 +21,6 @@ open class KafkaController(private val kafkaTemplate: KafkaTemplate e.value == "Something went wrong [id=1]" } == true + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=11]" } == true } .event()!! assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() val transaction = - server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /person/{id}" && t.request?.url?.endsWith("/person/11") == true + } == true + } + .transaction()!! assertThat( transaction.hasOp( "http.server", diff --git a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/AgentApp.kt b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/AgentApp.kt index 9f936920b1d..a6bd0bb35ff 100644 --- a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/AgentApp.kt +++ b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/AgentApp.kt @@ -2,6 +2,7 @@ package io.sentry.spring.boot.otelagentintegrationtest import io.sentry.test.ChildJvmApp import io.sentry.test.MockSentryServer +import io.sentry.test.requiredTestProperty import java.io.Closeable import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.boot.web.client.RestTemplateBuilder @@ -19,8 +20,8 @@ class AgentApp(args: List = emptyList()) : Closeable { private val app = ChildJvmApp.start( mainClass = "io.sentry.spring.boot.agentfixture.AgentFixtureApplicationKt", - classpath = System.getProperty("sentry.test.appClasspath"), - jvmArgs = listOf("-javaagent:${System.getProperty("sentry.test.agentJar")}"), + classpath = requiredTestProperty("sentry.test.appClasspath"), + jvmArgs = listOf("-javaagent:${requiredTestProperty("sentry.test.agentJar")}"), // Environment variables, because the agent doesn't read Spring Boot's properties. env = mapOf( @@ -58,7 +59,7 @@ class AgentApp(args: List = emptyList()) : Closeable { } companion object { - val autoInit = System.getProperty("sentry.test.agentAutoInit").toBoolean() + val autoInit = requiredTestProperty("sentry.test.agentAutoInit").toBoolean() /** * The app most test classes share. It takes seconds to start, so each test JVM starts it once diff --git a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/PersonIntegrationTest.kt b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/PersonIntegrationTest.kt index 42422c109ca..53a52f77412 100644 --- a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/PersonIntegrationTest.kt @@ -22,23 +22,27 @@ class PersonIntegrationTest : IntegrationTest() { @Test fun `failed request reports errors, spans with their flags, and logs`() { - val response = restTemplate.getForEntity("/person/1", String::class.java) + val response = restTemplate.getForEntity("/person/11", String::class.java) assertThat(response.statusCode.value()).isEqualTo(500) val logged = - server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + server + .awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=11" } + .event()!! assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() val thrown = server .awaitEnvelope { - it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=11]" } == true } .event()!! assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() val transactionEnvelope = server.awaitEnvelope { - it.transaction()?.transaction == "GET /person/{id}" + it.transaction()?.let { t -> + t.transaction == "GET /person/{id}" && t.request?.url?.endsWith("/person/11") == true + } == true } // Whichever initializes Sentry names the SDK, which shows that each mode takes its own path. assertThat(transactionEnvelope.header.sdkVersion!!.name) diff --git a/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/PersonIntegrationTest.kt b/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/PersonIntegrationTest.kt index 8e97eba4244..6e9b1c1038b 100644 --- a/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTestOtelNoAgent/kotlin/io/sentry/spring/boot/otelnoagentintegrationtest/PersonIntegrationTest.kt @@ -23,24 +23,32 @@ class PersonIntegrationTest : IntegrationTest() { @Test fun `failed request reports errors, nested spans with their flags, and logs`() { - val response = restTemplate.getForEntity("/person/1", String::class.java) + val response = restTemplate.getForEntity("/person/11", String::class.java) assertThat(response.statusCode.value()).isEqualTo(500) Sentry.flush(5000) val logged = - server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + server + .awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=11" } + .event()!! assertThat(logged.hasFlag("inner-feature-flag", true)).isTrue() val thrown = server .awaitEnvelope { - it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=11]" } == true } .event()!! assertThat(thrown.hasFlag("inner-feature-flag", true)).isTrue() val transaction = - server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /person/{id}" && t.request?.url?.endsWith("/person/11") == true + } == true + } + .transaction()!! assertThat(transaction.hasOp("http.server")).isTrue() assertThat( transaction.hasSpanWithOp( diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt index 21eeda7239b..eaf737043cb 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt @@ -26,12 +26,16 @@ class ChildJvmApp private constructor(private val process: Process, private val fun awaitPort(timeoutSeconds: Long = 60): Int { val deadline = Deadline.after(JavaMonotonicTicker.getInstance(), timeoutSeconds, TimeUnit.SECONDS) + var previous: Int? = null while (true) { - // The app may not have finished writing the file yet, in which case it doesn't parse. + // The app may not have finished writing the file yet, and a partly written port such as "54" + // of "54321" still parses. Neither Spring Boot nor the fixtures write it atomically, so only + // a value that two reads agree on counts. val port = portFile.takeIf { it.exists() }?.readText()?.trim()?.toIntOrNull() - if (port != null) { + if (port != null && port == previous) { return port } + previous = port check(process.isAlive) { "App exited with ${process.exitValue()}" } check(!deadline.hasPassed()) { "App did not start within ${timeoutSeconds}s" } Thread.sleep(100) @@ -82,3 +86,9 @@ class ChildJvmApp private constructor(private val process: Process, private val } } } + +/** Returns a system property that the Gradle test task sets, failing clearly when it isn't set. */ +fun requiredTestProperty(name: String): String = + requireNotNull(System.getProperty(name)) { + "System property $name is not set. Run the test through its Gradle task, which sets it." + } diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt index 158d282e388..0275036526a 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/MockSentryServer.kt @@ -27,6 +27,9 @@ import java.util.zip.GZIPInputStream class MockSentryServer(private val beforeEachCheck: () -> Unit = {}) : Closeable { private val serializer = JsonSerializer(SentryOptions.empty()) private val received = CopyOnWriteArrayList() + // Rethrown while waiting, so an envelope the server can't read fails the test with its cause + // rather than looking like an envelope that never arrived. + private val receiveErrors = CopyOnWriteArrayList() private val server = HttpServer.create(InetSocketAddress(InetAddress.getByName(HOST), 0), 0).apply { createContext("/api/") { exchange -> receive(exchange) } @@ -47,6 +50,7 @@ class MockSentryServer(private val beforeEachCheck: () -> Unit = {}) : Closeable Deadline.after(JavaMonotonicTicker.getInstance(), timeoutMillis, TimeUnit.MILLISECONDS) while (true) { beforeEachCheck() + throwReceiveError() received.firstOrNull(predicate)?.let { return it } @@ -69,6 +73,7 @@ class MockSentryServer(private val beforeEachCheck: () -> Unit = {}) : Closeable val deadline = Deadline.after(JavaMonotonicTicker.getInstance(), millis, TimeUnit.MILLISECONDS) while (true) { beforeEachCheck() + throwReceiveError() if (received.any(predicate)) { throw AssertionError("Received an envelope that should not have been sent") } @@ -81,6 +86,7 @@ class MockSentryServer(private val beforeEachCheck: () -> Unit = {}) : Closeable fun reset() { received.clear() + receiveErrors.clear() } override fun close() { @@ -95,13 +101,23 @@ class MockSentryServer(private val beforeEachCheck: () -> Unit = {}) : Closeable } else { exchange.requestBody } - serializer.deserializeEnvelope(body)?.let { received.add(it) } + val envelope = + serializer.deserializeEnvelope(body) + ?: throw IllegalStateException("Received a body that is not an envelope") + received.add(envelope) exchange.sendResponseHeaders(200, -1) + } catch (e: Exception) { + receiveErrors.add(e) + exchange.sendResponseHeaders(500, -1) } finally { exchange.close() } } + private fun throwReceiveError() { + receiveErrors.firstOrNull()?.let { throw AssertionError("Could not read an envelope", it) } + } + private companion object { // An IP rather than "localhost", which can resolve to IPv4 for the server and IPv6 for the SDK. const val HOST = "127.0.0.1" diff --git a/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt b/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt index 0f40f27e9af..128c359622b 100644 --- a/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt +++ b/sentry-test-support/src/test/kotlin/io/sentry/test/MockSentryServerTest.kt @@ -3,6 +3,8 @@ package io.sentry.test import com.google.common.truth.Truth.assertThat import io.sentry.Sentry import io.sentry.SentryItemType +import java.net.HttpURLConnection +import java.net.URI import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertFailsWith @@ -64,4 +66,17 @@ class MockSentryServerTest { server.assertNoEnvelope(millis = 5000) { it.header.eventId == eventId } } } + + @Test + fun `awaitEnvelope fails with the cause when a body is not an envelope`() { + val url = URI(server.dsn.replace("key@", "")).resolve("/api/0/envelope/").toURL() + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.doOutput = true + connection.outputStream.use { it.write("not an envelope".toByteArray()) } + assertThat(connection.responseCode).isEqualTo(500) + + val error = assertFailsWith { server.awaitEnvelope { true } } + assertThat(error).hasCauseThat().isNotNull() + } } From 21a827f28e13720130a10ef57988cf8bbace6830 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:25:53 +0100 Subject: [PATCH 13/40] perf(replay): Use hash set for mask view class lookups (#6410) * perf(replay): Use hash set for mask view class lookups maskViewClasses and unmaskViewClasses were backed by a CopyOnWriteArraySet, whose contains() is a linear scan. The view hierarchy traversal checks both sets for every superclass of every view on the main thread, so capture cost grew with the size of the configured class lists. Back both sets with a ConcurrentHashMap key set for O(1) lookups while keeping thread-safe, non-throwing iteration. Co-Authored-By: Roman Zavarnitsyn * changelog Co-Authored-By: Roman Zavarnitsyn * fix(replay): Ignore null mask view class names ConcurrentHashMap-backed sets reject null keys, while the previous CopyOnWriteArraySet accepted them. Hybrid SDKs (e.g. React Native via ReadableArray.getString) can pass null despite the @NotNull annotation, which would now throw during SDK init. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Roman Zavarnitsyn Co-authored-by: Roman Zavarnitsyn Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ .../capture/SessionCaptureStrategyTest.kt | 15 +++++--------- .../java/io/sentry/SentryMaskingOptions.java | 15 +++++++++++--- .../io/sentry/SentryMaskingOptionsTest.kt | 20 +++++++++++++++++++ 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d16525733..68ac4ae42ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Added `Environment` to the `Scope`, allowing you to overwrite the `Environment` set on the options ([#5772](https://github.com/getsentry/sentry-java/pull/5772)) - The scope `Environment` is also applied to the trace envelope header (DSC), logs, metrics and profiles, so all telemetry of a trace reports the same environment +### Improvements + +- Speed up Session Replay and screenshot masking for large `maskViewClasses`/`unmaskViewClasses` lists by using constant-time class lookups ([#6410](https://github.com/getsentry/sentry-java/pull/6410)) + ### Fixes - Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291)) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index 281eb1da3be..4c31090cee9 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -46,7 +46,6 @@ import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.TimeUnit.SECONDS import kotlin.test.Test -import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull @@ -696,8 +695,8 @@ class SessionCaptureStrategyTest { assertEquals(true, optionsEvent[0].optionsPayload["maskAllText"]) assertEquals(false, optionsEvent[0].optionsPayload["maskAllImages"]) assertEquals("high", optionsEvent[0].optionsPayload["quality"]) - assertContentEquals( - listOf( + assertThat(optionsEvent[0].optionsPayload["maskedViewClasses"] as Collection<*>) + .containsExactly( "android.widget.TextView", "android.webkit.WebView", "android.widget.VideoView", @@ -706,13 +705,9 @@ class SessionCaptureStrategyTest { "com.google.android.exoplayer2.ui.PlayerView", "com.google.android.exoplayer2.ui.StyledPlayerView", "my.custom.View", - ), - optionsEvent[0].optionsPayload["maskedViewClasses"] as Collection<*>, - ) - assertContentEquals( - listOf("android.widget.ImageView"), - optionsEvent[0].optionsPayload["unmaskedViewClasses"] as Collection<*>, - ) + ) + assertThat(optionsEvent[0].optionsPayload["unmaskedViewClasses"] as Collection<*>) + .containsExactly("android.widget.ImageView") }, ) } diff --git a/sentry/src/main/java/io/sentry/SentryMaskingOptions.java b/sentry/src/main/java/io/sentry/SentryMaskingOptions.java index dd7c5f9a304..44edff39a3d 100644 --- a/sentry/src/main/java/io/sentry/SentryMaskingOptions.java +++ b/sentry/src/main/java/io/sentry/SentryMaskingOptions.java @@ -1,7 +1,8 @@ package io.sentry; +import java.util.Collections; import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -32,7 +33,7 @@ public abstract class SentryMaskingOptions { * *

Default is empty. */ - protected Set maskViewClasses = new CopyOnWriteArraySet<>(); + protected Set maskViewClasses = Collections.newSetFromMap(new ConcurrentHashMap<>()); /** * Ignore all views with the specified class names from masking. The class name is the fully @@ -44,7 +45,7 @@ public abstract class SentryMaskingOptions { * *

Default is empty. */ - protected Set unmaskViewClasses = new CopyOnWriteArraySet<>(); + protected Set unmaskViewClasses = Collections.newSetFromMap(new ConcurrentHashMap<>()); /** The class name of the view container that masks all of its children. */ protected @Nullable String maskViewContainerClass = null; @@ -92,6 +93,10 @@ public Set getMaskViewClasses() { } public void addMaskViewClass(final @NotNull String className) { + // Hybrid SDKs may pass null despite the annotation, and the backing set rejects null keys + if (className == null) { + return; + } this.maskViewClasses.add(className); this.unmaskViewClasses.remove(className); } @@ -102,6 +107,10 @@ public Set getUnmaskViewClasses() { } public void addUnmaskViewClass(final @NotNull String className) { + // Hybrid SDKs may pass null despite the annotation, and the backing set rejects null keys + if (className == null) { + return; + } this.unmaskViewClasses.add(className); this.maskViewClasses.remove(className); } diff --git a/sentry/src/test/java/io/sentry/SentryMaskingOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryMaskingOptionsTest.kt index 9f661710dc7..3f2a62361eb 100644 --- a/sentry/src/test/java/io/sentry/SentryMaskingOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryMaskingOptionsTest.kt @@ -40,6 +40,26 @@ class SentryMaskingOptionsTest { assertTrue(options.unmaskViewClasses.contains("com.example.MyView")) } + @Test + fun `addMaskViewClass ignores null`() { + val options = TestMaskingOptions() + SentryMaskingOptions::class + .java + .getMethod("addMaskViewClass", String::class.java) + .invoke(options, null) + assertTrue(options.maskViewClasses.isEmpty()) + } + + @Test + fun `addUnmaskViewClass ignores null`() { + val options = TestMaskingOptions() + SentryMaskingOptions::class + .java + .getMethod("addUnmaskViewClass", String::class.java) + .invoke(options, null) + assertTrue(options.unmaskViewClasses.isEmpty()) + } + @Test fun `setMaskAllText true adds TextView to maskViewClasses`() { val options = TestMaskingOptions() From bdfdc765715d95191d84cb8772fea9826f6e3196 Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Thu, 29 Oct 2026 15:34:22 +0500 Subject: [PATCH 14/40] feat(apollo): Add a flag to stop attaching tracing headers in SentryApolloInterceptor (#6414) * feat(apollo): Add a flag to stop attaching tracing headers in SentryApolloInterceptor * changelog * fix(apollo): Preserve interceptor constructor compatibility Keep the existing Kotlin default-argument constructor ABI while exposing the tracing-header opt-out through a secondary constructor. This prevents runtime linkage failures for precompiled consumers upgrading the SDK. Co-Authored-By: Claude --------- Co-authored-by: Alexander Dinauer Co-authored-by: Claude --- CHANGELOG.md | 2 ++ sentry-apollo/api/sentry-apollo.api | 2 ++ .../io/sentry/apollo/SentryApolloInterceptor.kt | 12 +++++++++++- .../sentry/apollo/SentryApolloInterceptorTest.kt | 15 +++++++++++++-- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ac4ae42ef..505aa075b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Added `Environment` to the `Scope`, allowing you to overwrite the `Environment` set on the options ([#5772](https://github.com/getsentry/sentry-java/pull/5772)) - The scope `Environment` is also applied to the trace envelope header (DSC), logs, metrics and profiles, so all telemetry of a trace reports the same environment +- Add an `attachTracingHeaders` flag to `SentryApolloInterceptor` to stop attaching `sentry-trace` and `baggage` headers to Apollo v2 requests ([#6414](https://github.com/getsentry/sentry-java/pull/6414)) + - Apollo v2 has no request URL in the interceptor, so these headers are attached without checking `tracePropagationTargets`. Pass `attachTracingHeaders = false` to opt out. ### Improvements diff --git a/sentry-apollo/api/sentry-apollo.api b/sentry-apollo/api/sentry-apollo.api index 63eac6a1935..0514689d3cd 100644 --- a/sentry-apollo/api/sentry-apollo.api +++ b/sentry-apollo/api/sentry-apollo.api @@ -8,6 +8,8 @@ public final class io/sentry/apollo/SentryApolloInterceptor : com/apollographql/ public fun (Lio/sentry/IScopes;)V public fun (Lio/sentry/IScopes;Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;)V public synthetic fun (Lio/sentry/IScopes;Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/IScopes;Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;Z)V + public synthetic fun (Lio/sentry/IScopes;Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lio/sentry/apollo/SentryApolloInterceptor$BeforeSpanCallback;)V public fun dispose ()V public fun interceptAsync (Lcom/apollographql/apollo/interceptor/ApolloInterceptor$InterceptorRequest;Lcom/apollographql/apollo/interceptor/ApolloInterceptorChain;Ljava/util/concurrent/Executor;Lcom/apollographql/apollo/interceptor/ApolloInterceptor$CallBack;)V diff --git a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt index 3524d32dc1f..907217bea7f 100644 --- a/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt +++ b/sentry-apollo/src/main/java/io/sentry/apollo/SentryApolloInterceptor.kt @@ -44,8 +44,18 @@ class SentryApolloInterceptor( } } + private var attachTracingHeaders: Boolean = true + constructor(scopes: IScopes) : this(scopes, null) + constructor( + scopes: IScopes = ScopesAdapter.getInstance(), + beforeSpan: BeforeSpanCallback? = null, + attachTracingHeaders: Boolean, + ) : this(scopes, beforeSpan) { + this.attachTracingHeaders = attachTracingHeaders + } + constructor(beforeSpan: BeforeSpanCallback) : this(ScopesAdapter.getInstance(), beforeSpan) init { @@ -134,7 +144,7 @@ class SentryApolloInterceptor( private fun addTracingHeaders(request: InterceptorRequest, span: ISpan?): RequestHeaders { val requestHeaderBuilder = request.requestHeaders.toBuilder() - if (scopes.options.isTraceSampling && !isIgnored()) { + if (attachTracingHeaders && scopes.options.isTraceSampling && !isIgnored()) { // we have no access to URI, no way to verify tracing origins TracingUtils.trace( scopes, diff --git a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt index 9c35f7249c2..b212e2212c8 100644 --- a/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt +++ b/sentry-apollo/src/test/java/io/sentry/apollo/SentryApolloInterceptorTest.kt @@ -88,6 +88,7 @@ class SentryApolloInterceptorTest { } }""", socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + attachTracingHeaders: Boolean = true, beforeSpan: SentryApolloInterceptor.BeforeSpanCallback? = null, ): ApolloClient { server.enqueue( @@ -97,8 +98,8 @@ class SentryApolloInterceptorTest { .setResponseCode(httpStatusCode) ) - if (beforeSpan != null) { - interceptor = SentryApolloInterceptor(scopes, beforeSpan) + if (beforeSpan != null || !attachTracingHeaders) { + interceptor = SentryApolloInterceptor(scopes, beforeSpan, attachTracingHeaders) } return ApolloClient.builder() .serverUrl(server.url("/")) @@ -206,6 +207,16 @@ class SentryApolloInterceptorTest { assertNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) } + @Test + fun `does not add sentry-trace header when attaching tracing headers is disabled`() { + executeQuery(fixture.getSut(attachTracingHeaders = false)) + + val recorderRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + @Test fun `when there is an active span, adds sentry trace headers to the request`() { executeQuery() From fe41a5e9b356a72fa52d9aa28f2c141eae777a9c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:17:06 +0100 Subject: [PATCH 15/40] chore(deps): bump org.quartz-scheduler:quartz from 2.3.0 to 2.5.2 (#6413) Bumps [org.quartz-scheduler:quartz](https://github.com/quartz-scheduler/quartz) from 2.3.0 to 2.5.2. - [Release notes](https://github.com/quartz-scheduler/quartz/releases) - [Commits](https://github.com/quartz-scheduler/quartz/compare/quartz-2.3.0...v2.5.2) --- updated-dependencies: - dependency-name: org.quartz-scheduler:quartz dependency-version: 2.5.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2d1f9bd47cb..1e1c4496475 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -183,7 +183,7 @@ otel-semconv-incubating = { module = "io.opentelemetry.semconv:opentelemetry-sem p6spy = { module = "p6spy:p6spy", version = "3.9.1" } epitaph = { module = "com.abovevacant:epitaph", version = "0.2.0" } jcache = { module = "javax.cache:cache-api", version = "1.1.1" } -quartz = { module = "org.quartz-scheduler:quartz", version = "2.3.0" } +quartz = { module = "org.quartz-scheduler:quartz", version = "2.5.2" } reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } From 1527b14b20a67da5135bb22c7e661ec74476b7d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:17:56 +0100 Subject: [PATCH 16/40] chore(deps): bump getsentry/craft (#6420) Bumps the github-actions group with 1 update: [getsentry/craft](https://github.com/getsentry/craft). Updates `getsentry/craft` from 3.0.0 to 3.3.0 - [Release notes](https://github.com/getsentry/craft/releases) - [Changelog](https://github.com/getsentry/craft/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/craft/compare/924b450ede4af30e30de4821a4b357d676c8df92...4b763f5419255bb520e3e5a49f5b91426dcd374d) --- updated-dependencies: - dependency-name: getsentry/craft dependency-version: 3.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a415025fae..25eae33a019 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@924b450ede4af30e30de4821a4b357d676c8df92 # v3 + uses: getsentry/craft@4b763f5419255bb520e3e5a49f5b91426dcd374d # v3 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From 8937206dc3714280200a65aa398b4dd774cf30d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:18:33 +0100 Subject: [PATCH 17/40] chore(deps): bump com.vanniktech.maven.publish from 0.30.0 to 0.37.0 (#6412) Bumps [com.vanniktech.maven.publish](https://github.com/vanniktech/gradle-maven-publish-plugin) from 0.30.0 to 0.37.0. - [Release notes](https://github.com/vanniktech/gradle-maven-publish-plugin/releases) - [Changelog](https://github.com/vanniktech/gradle-maven-publish-plugin/blob/main/CHANGELOG.md) - [Commits](https://github.com/vanniktech/gradle-maven-publish-plugin/compare/0.30.0...0.37.0) --- updated-dependencies: - dependency-name: com.vanniktech.maven.publish dependency-version: 0.37.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e1c4496475..3e616d7908f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,7 +72,7 @@ errorprone = { id = "net.ltgt.errorprone", version = "5.1.1" } gradle-versions = { id = "com.github.ben-manes.versions", version = "0.42.0" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.8" } -vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.30.0" } +vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.37.0" } springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } From 6fe2c2e6f3161b5f3c281da1666c274654f3266a Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 30 Oct 2026 10:36:05 +0100 Subject: [PATCH 18/40] fix(transport): Reject leaked eMudhra intermediate certificates (#6336) * fix(transport): Reject leaked eMudhra intermediate certificates The private keys of eight eMudhra intermediates can be recovered from their certificates (CVE-2026-111943). Their Sectigo cross-signs chain to AAA Certificate Services, which is trusted on every Android version, so platform trust store updates won't reach every device. The envelope transport and the distribution client now reject servers that present one of these keys, right after the TLS handshake and before any request is written. Fixes JAVA-783 Co-Authored-By: Claude Opus 5.5 * changelog * ref(transport): Pass DistrustedCertificates to HttpConnection Inject the check through HttpConnection's constructor like AuthenticatorWrapper, so its tests can stub it instead of loading the real eMudhra certificate. Co-Authored-By: Claude Opus 5.5 * fix(transport): Check distrusted eMudhra CAs on every TLS handshake Checking the server certificates once after connect() missed the new connections HttpURLConnection opens on its own to retry a request, and chains Android completes from intermediates cached on earlier connections. Wrap the SSLSocketFactory so every handshake is checked, and match the leaf's issuer by name. Co-Authored-By: Claude Opus 5.5 * changelog: Shorten the eMudhra entry Co-Authored-By: Claude Opus 5.5 * changelog: Move the eMudhra entry to Unreleased Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + gradle/libs.versions.toml | 1 + .../distribution/DistributionHttpClient.kt | 6 +- sentry/api/sentry.api | 4 + sentry/build.gradle.kts | 2 + .../transport/DistrustedCertificates.java | 81 ++++ .../DistrustedCertificatesSocket.java | 403 ++++++++++++++++++ .../DistrustedCertificatesSocketFactory.java | 104 +++++ .../io/sentry/transport/HttpConnection.java | 10 +- .../io/sentry/cache/tape/QueueFileTest.kt | 7 +- .../transport/DistrustedCertificatesTest.kt | 204 +++++++++ .../io/sentry/transport/HttpConnectionTest.kt | 20 +- .../test/resources/emsign-root-tls-ca-g3.pem | 14 + .../resources/emudhra-em-dv-tls-ca-g3b.pem | 23 + .../resources/emudhra-em-ov-tls-ca-g3c.pem | 21 + 15 files changed, 888 insertions(+), 13 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java create mode 100644 sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocket.java create mode 100644 sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocketFactory.java create mode 100644 sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt create mode 100644 sentry/src/test/resources/emsign-root-tls-ca-g3.pem create mode 100644 sentry/src/test/resources/emudhra-em-dv-tls-ca-g3b.pem create mode 100644 sentry/src/test/resources/emudhra-em-ov-tls-ca-g3c.pem diff --git a/CHANGELOG.md b/CHANGELOG.md index 505aa075b5e..90ddcf1f068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291)) - Before, the start call was ignored and the profiler still stopped at the end of the chunk, even in `TRACE` mode while sampled spans were active. On API 34 and lower, every later profile also stopped after one chunk. +- Refuse to send data to servers whose certificate was issued by one of the leaked eMudhra intermediates (CVE-2026-111943) ([#6336](https://github.com/getsentry/sentry-java/pull/6336)) ### Dependencies diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3e616d7908f..ff6e1e20dc1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -290,6 +290,7 @@ mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.4. mockito-core = { module = "org.mockito:mockito-core", version = "5.24.0" } msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" } # apollo-runtime 5.x pulls in okhttp 5.x; mockwebserver 4.x is incompatible with that classpath okhttp-mockwebserver5 = { module = "com.squareup.okhttp3:mockwebserver", version = "5.3.0" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } diff --git a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt index ed2bbd18b1d..9ffaf19949b 100644 --- a/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt +++ b/sentry-android-distribution/src/main/java/io/sentry/android/distribution/DistributionHttpClient.kt @@ -2,6 +2,7 @@ package io.sentry.android.distribution import io.sentry.SentryLevel import io.sentry.SentryOptions +import io.sentry.transport.DistrustedCertificates import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader @@ -89,8 +90,9 @@ internal class DistributionHttpClient(private val options: SentryOptions) { connection.connectTimeout = options.connectionTimeoutMillis connection.readTimeout = options.readTimeoutMillis - if (connection is HttpsURLConnection && options.sslSocketFactory != null) { - connection.sslSocketFactory = options.sslSocketFactory + if (connection is HttpsURLConnection) { + connection.sslSocketFactory = + DistrustedCertificates.wrap(options.sslSocketFactory ?: connection.sslSocketFactory) } val responseCode = connection.responseCode diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 2890298efb5..6e1ebe4ff30 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7777,6 +7777,10 @@ public final class io/sentry/transport/CurrentDateProvider : io/sentry/transport public static fun getInstance ()Lio/sentry/transport/ICurrentDateProvider; } +public final class io/sentry/transport/DistrustedCertificates { + public static fun wrap (Ljavax/net/ssl/SSLSocketFactory;)Ljavax/net/ssl/SSLSocketFactory; +} + public abstract interface class io/sentry/transport/ICurrentDateProvider { public abstract fun getCurrentTimeMillis ()J } diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 70f0068a5c3..b1621c440dc 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -31,6 +31,8 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) testImplementation(libs.msgpack) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.okhttp.tls) testImplementation(libs.okio) testImplementation(projects.sentryTestSupport) } diff --git a/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java b/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java new file mode 100644 index 00000000000..390586999cc --- /dev/null +++ b/sentry/src/main/java/io/sentry/transport/DistrustedCertificates.java @@ -0,0 +1,81 @@ +package io.sentry.transport; + +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSocketFactory; +import javax.security.auth.x500.X500Principal; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Rejects servers whose certificate was issued by a CA that is known to be compromised but still + * trusted by platform trust stores. + * + *

Browsers block such certificates on their own and require Certificate Transparency, but {@link + * javax.net.ssl.HttpsURLConnection} does neither. Android only updates its CA store with OS updates + * (or Mainline on Android 14+) and the JVM only with JDK updates, so a distrust by the root + * programs doesn't reach every device. + */ +@ApiStatus.Internal +public final class DistrustedCertificates { + + /** + * The eight eMudhra intermediates whose private keys can be recovered from their certificates + * (CVE-2026-111943). Each key is in two certificates with the same subject: one issued by eMudhra + * and a cross-sign by Sectigo's AAA Certificate Services root, which is trusted on every Android + * version. + * + *

They are matched by name rather than by key because the server doesn't have to send them: + * Android caches intermediates from earlier connections and completes a chain that is missing + * one. The server always sends the leaf, which names its issuer, and since none of these CAs may + * issue further CAs ({@code pathlen:0}), any certificate they issued is a leaf. + * + * @see Compromised + * eMudhra Intermediate Certificates + */ + private static final Set DISTRUSTED_CAS = + new HashSet<>( + Arrays.asList( + new X500Principal("CN=EM DV TLS CA - G3B, O=eMudhra BV, C=NL"), + new X500Principal("CN=EM EV TLS CA - G3B, O=eMudhra BV, C=NL"), + new X500Principal("CN=EM OV TLS CA - G3B, O=eMudhra BV, C=NL"), + new X500Principal("CN=EM OV CS CA - G3B, O=eMudhra BV, C=NL"), + new X500Principal("CN=EM DV TLS CA - G3C, O=eMudhra Inc, C=US"), + new X500Principal("CN=EM EV TLS CA - G3C, O=eMudhra Inc, C=US"), + new X500Principal("CN=EM OV TLS CA - G3C, O=eMudhra Inc, C=US"), + new X500Principal("CN=EM OV CS CA - G3C, O=eMudhra Inc, C=US"))); + + private DistrustedCertificates() {} + + /** + * Wraps {@code delegate} so that every TLS handshake of its sockets is checked, including the + * ones {@link java.net.HttpURLConnection} makes on its own to retry a request on a new + * connection. + */ + public static @NotNull SSLSocketFactory wrap(final @NotNull SSLSocketFactory delegate) { + return new DistrustedCertificatesSocketFactory(delegate); + } + + /** + * @param peerCertificates the certificates the server sent, leaf first + */ + static void check(final @NotNull Certificate[] peerCertificates) + throws SSLPeerUnverifiedException { + for (final Certificate certificate : peerCertificates) { + if (certificate instanceof X509Certificate && isDistrusted((X509Certificate) certificate)) { + throw new SSLPeerUnverifiedException( + "The server's certificate chain contains a distrusted certificate."); + } + } + } + + private static boolean isDistrusted(final @NotNull X509Certificate certificate) { + return DISTRUSTED_CAS.contains(certificate.getIssuerX500Principal()) + || DISTRUSTED_CAS.contains(certificate.getSubjectX500Principal()); + } +} diff --git a/sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocket.java b/sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocket.java new file mode 100644 index 00000000000..9957f32a974 --- /dev/null +++ b/sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocket.java @@ -0,0 +1,403 @@ +package io.sentry.transport; + +import java.io.FilterInputStream; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.SocketAddress; +import java.net.SocketException; +import java.nio.channels.SocketChannel; +import javax.net.ssl.HandshakeCompletedListener; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; +import org.jetbrains.annotations.NotNull; + +/** + * Delegates to an {@link SSLSocket} and runs {@link DistrustedCertificates#check} once its + * handshake is done. + * + *

The HTTP stacks of the JVM and Android call {@link #startHandshake()} before they send + * anything. The streams check again before every read and write, so that application data can't + * flow over a handshake that was started implicitly. They can't check when they are created, + * because the JVM gets the output stream before it configures the socket for the handshake. + */ +// The delegate synchronizes its own state, so the methods Socket synchronizes on the wrapper don't +// need to. +@SuppressWarnings("UnsynchronizedOverridesSynchronized") +final class DistrustedCertificatesSocket extends SSLSocket { + + private final @NotNull SSLSocket delegate; + private volatile boolean verified; + + DistrustedCertificatesSocket(final @NotNull SSLSocket delegate) { + this.delegate = delegate; + } + + @Override + public void startHandshake() throws IOException { + delegate.startHandshake(); + verify(); + } + + @Override + public InputStream getInputStream() throws IOException { + return new VerifyingInputStream(delegate.getInputStream()); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return new VerifyingOutputStream(delegate.getOutputStream()); + } + + private void verify() throws IOException { + if (verified) { + return; + } + try { + DistrustedCertificates.check(delegate.getSession().getPeerCertificates()); + } catch (SSLPeerUnverifiedException e) { + delegate.close(); + throw e; + } + verified = true; + } + + private final class VerifyingInputStream extends FilterInputStream { + VerifyingInputStream(final @NotNull InputStream in) { + super(in); + } + + @Override + public int read() throws IOException { + verify(); + return in.read(); + } + + @Override + public int read(final byte[] b, final int off, final int len) throws IOException { + verify(); + return in.read(b, off, len); + } + + @Override + public long skip(final long n) throws IOException { + verify(); + return in.skip(n); + } + } + + private final class VerifyingOutputStream extends FilterOutputStream { + VerifyingOutputStream(final @NotNull OutputStream out) { + super(out); + } + + @Override + public void write(final int b) throws IOException { + verify(); + out.write(b); + } + + @Override + public void write(final byte[] b, final int off, final int len) throws IOException { + verify(); + out.write(b, off, len); + } + } + + // Everything below delegates unchanged. + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public String[] getEnabledCipherSuites() { + return delegate.getEnabledCipherSuites(); + } + + @Override + public void setEnabledCipherSuites(final String[] suites) { + delegate.setEnabledCipherSuites(suites); + } + + @Override + public String[] getSupportedProtocols() { + return delegate.getSupportedProtocols(); + } + + @Override + public String[] getEnabledProtocols() { + return delegate.getEnabledProtocols(); + } + + @Override + public void setEnabledProtocols(final String[] protocols) { + delegate.setEnabledProtocols(protocols); + } + + @Override + public SSLSession getSession() { + return delegate.getSession(); + } + + @Override + public void addHandshakeCompletedListener(final HandshakeCompletedListener listener) { + delegate.addHandshakeCompletedListener(listener); + } + + @Override + public void removeHandshakeCompletedListener(final HandshakeCompletedListener listener) { + delegate.removeHandshakeCompletedListener(listener); + } + + @Override + public void setUseClientMode(final boolean mode) { + delegate.setUseClientMode(mode); + } + + @Override + public boolean getUseClientMode() { + return delegate.getUseClientMode(); + } + + @Override + public void setNeedClientAuth(final boolean need) { + delegate.setNeedClientAuth(need); + } + + @Override + public boolean getNeedClientAuth() { + return delegate.getNeedClientAuth(); + } + + @Override + public void setWantClientAuth(final boolean want) { + delegate.setWantClientAuth(want); + } + + @Override + public boolean getWantClientAuth() { + return delegate.getWantClientAuth(); + } + + @Override + public void setEnableSessionCreation(final boolean flag) { + delegate.setEnableSessionCreation(flag); + } + + @Override + public boolean getEnableSessionCreation() { + return delegate.getEnableSessionCreation(); + } + + @Override + public SSLParameters getSSLParameters() { + return delegate.getSSLParameters(); + } + + @Override + public void setSSLParameters(final SSLParameters params) { + delegate.setSSLParameters(params); + } + + @Override + public void connect(final SocketAddress endpoint) throws IOException { + delegate.connect(endpoint); + } + + @Override + public void connect(final SocketAddress endpoint, final int timeout) throws IOException { + delegate.connect(endpoint, timeout); + } + + @Override + public void bind(final SocketAddress bindpoint) throws IOException { + delegate.bind(bindpoint); + } + + @Override + public InetAddress getInetAddress() { + return delegate.getInetAddress(); + } + + @Override + public InetAddress getLocalAddress() { + return delegate.getLocalAddress(); + } + + @Override + public int getPort() { + return delegate.getPort(); + } + + @Override + public int getLocalPort() { + return delegate.getLocalPort(); + } + + @Override + public SocketAddress getRemoteSocketAddress() { + return delegate.getRemoteSocketAddress(); + } + + @Override + public SocketAddress getLocalSocketAddress() { + return delegate.getLocalSocketAddress(); + } + + @Override + public SocketChannel getChannel() { + return delegate.getChannel(); + } + + @Override + public void setTcpNoDelay(final boolean on) throws SocketException { + delegate.setTcpNoDelay(on); + } + + @Override + public boolean getTcpNoDelay() throws SocketException { + return delegate.getTcpNoDelay(); + } + + @Override + public void setSoLinger(final boolean on, final int linger) throws SocketException { + delegate.setSoLinger(on, linger); + } + + @Override + public int getSoLinger() throws SocketException { + return delegate.getSoLinger(); + } + + @Override + public void sendUrgentData(final int data) throws IOException { + delegate.sendUrgentData(data); + } + + @Override + public void setOOBInline(final boolean on) throws SocketException { + delegate.setOOBInline(on); + } + + @Override + public boolean getOOBInline() throws SocketException { + return delegate.getOOBInline(); + } + + @Override + public void setSoTimeout(final int timeout) throws SocketException { + delegate.setSoTimeout(timeout); + } + + @Override + public int getSoTimeout() throws SocketException { + return delegate.getSoTimeout(); + } + + @Override + public void setSendBufferSize(final int size) throws SocketException { + delegate.setSendBufferSize(size); + } + + @Override + public int getSendBufferSize() throws SocketException { + return delegate.getSendBufferSize(); + } + + @Override + public void setReceiveBufferSize(final int size) throws SocketException { + delegate.setReceiveBufferSize(size); + } + + @Override + public int getReceiveBufferSize() throws SocketException { + return delegate.getReceiveBufferSize(); + } + + @Override + public void setKeepAlive(final boolean on) throws SocketException { + delegate.setKeepAlive(on); + } + + @Override + public boolean getKeepAlive() throws SocketException { + return delegate.getKeepAlive(); + } + + @Override + public void setTrafficClass(final int tc) throws SocketException { + delegate.setTrafficClass(tc); + } + + @Override + public int getTrafficClass() throws SocketException { + return delegate.getTrafficClass(); + } + + @Override + public void setReuseAddress(final boolean on) throws SocketException { + delegate.setReuseAddress(on); + } + + @Override + public boolean getReuseAddress() throws SocketException { + return delegate.getReuseAddress(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + + @Override + public void shutdownInput() throws IOException { + delegate.shutdownInput(); + } + + @Override + public void shutdownOutput() throws IOException { + delegate.shutdownOutput(); + } + + @Override + public boolean isConnected() { + return delegate.isConnected(); + } + + @Override + public boolean isBound() { + return delegate.isBound(); + } + + @Override + public boolean isClosed() { + return delegate.isClosed(); + } + + @Override + public boolean isInputShutdown() { + return delegate.isInputShutdown(); + } + + @Override + public boolean isOutputShutdown() { + return delegate.isOutputShutdown(); + } + + @Override + public void setPerformancePreferences( + final int connectionTime, final int latency, final int bandwidth) { + delegate.setPerformancePreferences(connectionTime, latency, bandwidth); + } + + @Override + public String toString() { + return delegate.toString(); + } +} diff --git a/sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocketFactory.java b/sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocketFactory.java new file mode 100644 index 00000000000..13bc120be49 --- /dev/null +++ b/sentry/src/main/java/io/sentry/transport/DistrustedCertificatesSocketFactory.java @@ -0,0 +1,104 @@ +package io.sentry.transport; + +import io.sentry.util.Platform; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.net.InetAddress; +import java.net.Socket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import org.jetbrains.annotations.NotNull; + +/** Creates sockets that run {@link DistrustedCertificates#check} after every TLS handshake. */ +final class DistrustedCertificatesSocketFactory extends SSLSocketFactory { + + /** + * Conscrypt on Android 5 only sends SNI after {@code setHostname} has been called, which + * Android's HTTP stack does by reflection on the socket it gets. That call silently does nothing + * on our wrapper, so we make it on the delegate instead. Later versions send SNI by default, and + * calling the method there would access a hidden API. + */ + private static final boolean ENABLE_SNI_BY_REFLECTION = + Platform.isAndroid() && androidSdkInt() < 23; + + private final @NotNull SSLSocketFactory delegate; + + DistrustedCertificatesSocketFactory(final @NotNull SSLSocketFactory delegate) { + this.delegate = delegate; + } + + @Override + public String[] getDefaultCipherSuites() { + return delegate.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return delegate.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket() throws IOException { + return wrap(delegate.createSocket()); + } + + @Override + public Socket createSocket( + final Socket socket, final String host, final int port, final boolean autoClose) + throws IOException { + final Socket sslSocket = delegate.createSocket(socket, host, port, autoClose); + if (ENABLE_SNI_BY_REFLECTION) { + setHostname(sslSocket, host); + } + return wrap(sslSocket); + } + + @Override + public Socket createSocket(final String host, final int port) throws IOException { + return wrap(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket( + final String host, final int port, final InetAddress localHost, final int localPort) + throws IOException { + return wrap(delegate.createSocket(host, port, localHost, localPort)); + } + + @Override + public Socket createSocket(final InetAddress host, final int port) throws IOException { + return wrap(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket( + final InetAddress address, + final int port, + final InetAddress localAddress, + final int localPort) + throws IOException { + return wrap(delegate.createSocket(address, port, localAddress, localPort)); + } + + private static @NotNull Socket wrap(final @NotNull Socket socket) { + return socket instanceof SSLSocket + ? new DistrustedCertificatesSocket((SSLSocket) socket) + : socket; + } + + private static void setHostname(final @NotNull Socket socket, final @NotNull String host) { + try { + socket.getClass().getMethod("setHostname", String.class).invoke(socket, host); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + // Not a Conscrypt socket, which leaves SNI as the delegate configured it. + } + } + + private static int androidSdkInt() { + try { + return Class.forName("android.os.Build$VERSION").getField("SDK_INT").getInt(null); + } catch (ClassNotFoundException | NoSuchFieldException | IllegalAccessException e) { + return Integer.MAX_VALUE; + } + } +} diff --git a/sentry/src/main/java/io/sentry/transport/HttpConnection.java b/sentry/src/main/java/io/sentry/transport/HttpConnection.java index e7935e76cf3..34abeb31835 100644 --- a/sentry/src/main/java/io/sentry/transport/HttpConnection.java +++ b/sentry/src/main/java/io/sentry/transport/HttpConnection.java @@ -136,10 +136,12 @@ HttpURLConnection open() throws IOException { connection.setConnectTimeout(options.getConnectionTimeoutMillis()); connection.setReadTimeout(options.getReadTimeoutMillis()); - final SSLSocketFactory sslSocketFactory = options.getSslSocketFactory(); - - if (connection instanceof HttpsURLConnection && sslSocketFactory != null) { - ((HttpsURLConnection) connection).setSSLSocketFactory(sslSocketFactory); + if (connection instanceof HttpsURLConnection) { + final HttpsURLConnection httpsConnection = (HttpsURLConnection) connection; + final @Nullable SSLSocketFactory sslSocketFactory = options.getSslSocketFactory(); + httpsConnection.setSSLSocketFactory( + DistrustedCertificates.wrap( + sslSocketFactory != null ? sslSocketFactory : httpsConnection.getSSLSocketFactory())); } connection.connect(); diff --git a/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt b/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt index faa2b2e0ba4..f288a1b7c56 100644 --- a/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt +++ b/sentry/src/test/java/io/sentry/cache/tape/QueueFileTest.kt @@ -30,7 +30,8 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail import okio.BufferedSource -import okio.Okio +import okio.buffer +import okio.source import org.junit.Assert import org.junit.Assert.assertArrayEquals import org.junit.Before @@ -432,7 +433,7 @@ class QueueFileTest { queueFile.remove() queueFile.close() - val source: BufferedSource = Okio.buffer(Okio.source(file)) + val source: BufferedSource = file.source().buffer() source.skip(headerLength.toLong()) source.skip(Element.HEADER_LENGTH.toLong()) assertEquals(source.readByteString(4).hex(), "00000000") @@ -445,7 +446,7 @@ class QueueFileTest { queueFile.remove() queueFile.close() - val source: BufferedSource = Okio.buffer(Okio.source(file)) + val source: BufferedSource = file.source().buffer() source.skip(headerLength.toLong()) source.skip(Element.HEADER_LENGTH.toLong()) assertEquals(source.readByteString(4).hex(), "04030201") diff --git a/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt b/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt new file mode 100644 index 00000000000..994a0a438e7 --- /dev/null +++ b/sentry/src/test/java/io/sentry/transport/DistrustedCertificatesTest.kt @@ -0,0 +1,204 @@ +package io.sentry.transport + +import com.google.common.truth.Truth.assertThat +import io.sentry.JsonSerializer +import io.sentry.RequestDetails +import io.sentry.SentryEnvelope +import io.sentry.SentryEvent +import io.sentry.SentryOptions +import java.io.IOException +import java.math.BigInteger +import java.net.InetAddress +import java.net.Socket +import java.security.AlgorithmParameters +import java.security.KeyFactory +import java.security.KeyPair +import java.security.PrivateKey +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.ECGenParameterSpec +import java.security.spec.ECParameterSpec +import java.security.spec.ECPrivateKeySpec +import javax.net.ssl.SSLPeerUnverifiedException +import javax.net.ssl.SSLSocketFactory +import kotlin.test.Test +import kotlin.test.assertFailsWith +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.junit.Rule + +class DistrustedCertificatesTest { + + @get:Rule val server = MockWebServer() + + private val emSignRoot = certificate("emsign-root-tls-ca-g3.pem") + + /** + * EM OV TLS CA - G3C, issued by emSign Root TLS CA - G3. Its private key is 4, as OkHttp found: + * https://github.com/square/okhttp/blob/master/okhttp/src/commonTest/kotlin/okhttp3/CompromisedIntermediateTest.kt + */ + private val distrustedCa = + certificate("emudhra-em-ov-tls-ca-g3c.pem").let { + HeldCertificate(KeyPair(it.publicKey, secp384r1PrivateKey(4)), it) + } + + private val trustedCa = HeldCertificate.Builder().certificateAuthority(0).build() + + private val options = SentryOptions().apply { setSerializer(JsonSerializer(this)) } + + @Test + fun `rejects a certificate issued by a distrusted CA`() { + assertFailsWith { + DistrustedCertificates.check(arrayOf(leafIssuedBy(distrustedCa).certificate)) + } + } + + @Test + fun `rejects a distrusted CA cross-signed by AAA Certificate Services`() { + assertFailsWith { + DistrustedCertificates.check(arrayOf(certificate("emudhra-em-dv-tls-ca-g3b.pem"))) + } + } + + @Test + fun `accepts a chain without distrusted CAs`() { + DistrustedCertificates.check( + arrayOf(leafIssuedBy(trustedCa).certificate, trustedCa.certificate) + ) + } + + @Test + fun `sends the envelope to a server whose certificate a trusted CA issued`() { + server.useHttps(serverCertificates(leafIssuedBy(trustedCa)), false) + server.enqueue(MockResponse()) + + val result = send(trusting(emSignRoot, trustedCa.certificate)) + + assertThat(result.isSuccess).isTrue() + assertThat(server.requestCount).isEqualTo(1) + } + + @Test + fun `still verifies the hostname`() { + server.useHttps(serverCertificates(leafIssuedBy(trustedCa, "sentry.example")), false) + + assertFailsWith { send(trusting(trustedCa.certificate)) } + + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `does not send the envelope to a server whose certificate a distrusted CA issued`() { + server.useHttps( + serverCertificates(leafIssuedBy(distrustedCa), distrustedCa.certificate), + false, + ) + + assertFailsWith { send(trusting(emSignRoot)) } + + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `does not send the envelope when the client completes the chain with a distrusted CA the server left out`() { + // Like Android, which completes the chain from intermediates it cached on earlier connections. + server.useHttps(serverCertificates(leafIssuedBy(distrustedCa)), false) + + assertFailsWith { send(trusting(distrustedCa.certificate)) } + + assertThat(server.requestCount).isEqualTo(0) + } + + @Test + fun `does not send the envelope again when HttpURLConnection retries it on a connection to a distrusted CA`() { + server.useHttps( + PerConnectionSocketFactory( + serverCertificates(leafIssuedBy(trustedCa)), + serverCertificates(leafIssuedBy(distrustedCa), distrustedCa.certificate), + ), + false, + ) + // The JVM sends a POST again on a new connection when the first one closes before a response. + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) + server.enqueue(MockResponse()) + + val result = send(trusting(emSignRoot, trustedCa.certificate)) + + assertThat(result.isSuccess).isFalse() + assertThat(server.requestCount).isEqualTo(1) + } + + private fun send(sslSocketFactory: SSLSocketFactory): TransportResult { + options.sslSocketFactory = sslSocketFactory + val requestDetails = RequestDetails(server.url("/api/1/envelope/").toString(), emptyMap()) + val connection = HttpConnection(options, requestDetails, RateLimiter(options)) + return connection.send(SentryEnvelope.from(options.serializer, SentryEvent(), null)) + } + + private fun leafIssuedBy( + issuer: HeldCertificate, + hostname: String = server.hostName, + ): HeldCertificate = + HeldCertificate.Builder().addSubjectAlternativeName(hostname).signedBy(issuer).build() + + private fun serverCertificates( + leaf: HeldCertificate, + vararg intermediates: X509Certificate, + ): SSLSocketFactory = + HandshakeCertificates.Builder().heldCertificate(leaf, *intermediates).build().sslSocketFactory() + + private fun trusting(vararg certificates: X509Certificate): SSLSocketFactory = + HandshakeCertificates.Builder() + .apply { certificates.forEach { addTrustedCertificate(it) } } + .build() + .sslSocketFactory() + + private fun certificate(resource: String): X509Certificate = + javaClass.classLoader!!.getResourceAsStream(resource)!!.use { + CertificateFactory.getInstance("X.509").generateCertificate(it) as X509Certificate + } + + private fun secp384r1PrivateKey(value: Long): PrivateKey { + val secp384r1 = + AlgorithmParameters.getInstance("EC").run { + init(ECGenParameterSpec("secp384r1")) + getParameterSpec(ECParameterSpec::class.java) + } + return KeyFactory.getInstance("EC") + .generatePrivate(ECPrivateKeySpec(BigInteger.valueOf(value), secp384r1)) + } + + /** Presents the certificates of [first] on the first connection and of [then] on later ones. */ + private class PerConnectionSocketFactory( + private val first: SSLSocketFactory, + private val then: SSLSocketFactory, + ) : SSLSocketFactory() { + private var connections = 0 + + private fun next(): SSLSocketFactory = if (connections++ == 0) first else then + + override fun createSocket(socket: Socket, host: String?, port: Int, autoClose: Boolean) = + next().createSocket(socket, host, port, autoClose) + + override fun getDefaultCipherSuites(): Array = first.defaultCipherSuites + + override fun getSupportedCipherSuites(): Array = first.supportedCipherSuites + + override fun createSocket(host: String, port: Int) = throw UnsupportedOperationException() + + override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int) = + throw UnsupportedOperationException() + + override fun createSocket(host: InetAddress, port: Int) = throw UnsupportedOperationException() + + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int, + ) = throw UnsupportedOperationException() + } +} diff --git a/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt b/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt index 4c17c71fecc..63a297d950c 100644 --- a/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt +++ b/sentry/src/test/java/io/sentry/transport/HttpConnectionTest.kt @@ -24,6 +24,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.check import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -149,23 +150,34 @@ class HttpConnectionTest { } @Test - fun `When SSLSocketFactory is given, set to connection`() { + fun `When SSLSocketFactory is given, set it to connection wrapped in a distrusted certificates check`() { val factory = mock() fixture.sslSocketFactory = factory val transport = fixture.getSUT() transport.send(createEnvelope()) - verify(fixture.connection).sslSocketFactory = eq(factory) + assertWraps(factory) } @Test - fun `When SSLSocketFactory is not given, do not set to connection`() { + fun `When SSLSocketFactory is not given, wrap the connection's own factory in a distrusted certificates check`() { + val factory = mock() + whenever(fixture.connection.sslSocketFactory).thenReturn(factory) val transport = fixture.getSUT() transport.send(createEnvelope()) - verify(fixture.connection, never()).sslSocketFactory = any() + assertWraps(factory) + } + + private fun assertWraps(delegate: SSLSocketFactory) { + val captor = argumentCaptor() + verify(fixture.connection).sslSocketFactory = captor.capture() + val wrapper = captor.firstValue + assertTrue(wrapper is DistrustedCertificatesSocketFactory) + wrapper.defaultCipherSuites + verify(delegate).defaultCipherSuites } @Test diff --git a/sentry/src/test/resources/emsign-root-tls-ca-g3.pem b/sentry/src/test/resources/emsign-root-tls-ca-g3.pem new file mode 100644 index 00000000000..3b8be38cb38 --- /dev/null +++ b/sentry/src/test/resources/emsign-root-tls-ca-g3.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICKTCCAa+gAwIBAgIQDnYGcvFDRZ/I/gqwvAXjlDAKBggqhkjOPQQDAzBWMQsw +CQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl +ZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjQwMjA4MDgx +OTMyWhcNNDkwMjA3MDgxOTMyWjBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11 +ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3Qg +VExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARfD9w/q1cWxL6D9bMG +H6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8mWO/RwXQy10DCSxT8vSIJClHKp+Ao +4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFwLKYf8EdDXAyjQjBAMB0GA1UdDgQW +BBThOqshfPHjTVSu1HK3/YtZQiCQVDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/ +BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjBhCFTkly7Yl7+s4hiw+k6ZJTXT2AiI +rvmsmfqPr5E7ecwXb6kXA5LTFd6rjwTuV3sCMQC9w6ZSbVq1mveMO6dKFziOzClT +KCT2RFT6yXtq6nzgRICgBmiXbJAXsmZ8yAwjyWc= +-----END CERTIFICATE----- diff --git a/sentry/src/test/resources/emudhra-em-dv-tls-ca-g3b.pem b/sentry/src/test/resources/emudhra-em-dv-tls-ca-g3b.pem new file mode 100644 index 00000000000..470f0f7e77d --- /dev/null +++ b/sentry/src/test/resources/emudhra-em-dv-tls-ca-g3b.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw +ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV +BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y +ODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW +MRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA +IgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO +L4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2 +PM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd +BgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG +A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC +MDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h +AQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB +QUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF +BQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB +AQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE +wqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM +QkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ +7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl +bN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V +GUyRCEIDhF5TBUuKF4E3kf1h +-----END CERTIFICATE----- diff --git a/sentry/src/test/resources/emudhra-em-ov-tls-ca-g3c.pem b/sentry/src/test/resources/emudhra-em-ov-tls-ca-g3c.pem new file mode 100644 index 00000000000..0f4083092ff --- /dev/null +++ b/sentry/src/test/resources/emudhra-em-ov-tls-ca-g3c.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ +BgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk +MSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3 +NDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk +aHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ +fjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+ +N7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L +WUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG +CCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow +KAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB +BAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY +aHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw +b3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f +BDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j +cmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E +AwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c +NVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79 +4rSb9vGqxZzelQth +-----END CERTIFICATE----- From 639374e60ad5673e7bcd5937b0b027e9816708fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:08:54 +0100 Subject: [PATCH 19/40] chore(deps): bump the github-actions group with 2 updates (#6442) Bumps the github-actions group with 2 updates: [gradle/actions/setup-gradle](https://github.com/gradle/actions) and [getsentry/craft](https://github.com/getsentry/craft). Updates `gradle/actions/setup-gradle` from 6.4.0 to 6.4.1 - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/3f5f9adaf7d9fecd50b5935e54106014257a94e6...8da46b539cd8fe6ab27ca75128919a18d757604c) Updates `getsentry/craft` from 3.3.0 to 3.4.1 - [Release notes](https://github.com/getsentry/craft/releases) - [Changelog](https://github.com/getsentry/craft/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/craft/compare/4b763f5419255bb520e3e5a49f5b91426dcd374d...90e8e203ad86c8ab8ee134e8d387def13e620e81) --- updated-dependencies: - dependency-name: gradle/actions/setup-gradle dependency-version: 6.4.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions - dependency-name: getsentry/craft dependency-version: 3.4.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/agp-matrix.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/format-code.yml | 2 +- .github/workflows/generate-javadocs.yml | 2 +- .github/workflows/integration-tests-macrobenchmark.yml | 2 +- .github/workflows/integration-tests-size.yml | 2 +- .github/workflows/integration-tests-ui-critical.yml | 2 +- .github/workflows/integration-tests-ui.yml | 2 +- .github/workflows/release-build.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/spring-boot-2-matrix.yml | 2 +- .github/workflows/spring-boot-3-matrix.yml | 2 +- .github/workflows/spring-boot-4-matrix.yml | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 6b0ecf7d1fc..545fa8a92da 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -39,7 +39,7 @@ jobs: java-version: '21' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b185217b4c..7076538f56f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 25c6b9fbf2b..a6ced1e58d8 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -31,7 +31,7 @@ jobs: java-version: '21' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 0ed7cbb4efc..20f089a42e5 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Set up Java uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 0fe0c84ccf6..840d50b41d2 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -19,7 +19,7 @@ jobs: java-version: '21' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index 262b0fb3f47..ae3e5e6a14f 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -20,7 +20,7 @@ jobs: java-version: '21' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Generate Aggregate Javadocs run: | diff --git a/.github/workflows/integration-tests-macrobenchmark.yml b/.github/workflows/integration-tests-macrobenchmark.yml index b85ed387133..654e8c17f76 100644 --- a/.github/workflows/integration-tests-macrobenchmark.yml +++ b/.github/workflows/integration-tests-macrobenchmark.yml @@ -29,7 +29,7 @@ jobs: java-version: '21' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Assemble target app and Macrobenchmark apk if: env.SAUCE_USERNAME != null diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index fb61149ac58..3b44736e691 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -36,7 +36,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 72d660c5053..715a4679454 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -36,7 +36,7 @@ jobs: java-version: '21' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 2bd88afd5ad..ec08da78156 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -33,7 +33,7 @@ jobs: java-version: '21' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 9b04020e6c1..18f61faa2b1 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -26,7 +26,7 @@ jobs: java-version: '21' - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - name: Build artifacts run: make publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25eae33a019..b3fc96f40ee 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@4b763f5419255bb520e3e5a49f5b91426dcd374d # v3 + uses: getsentry/craft@90e8e203ad86c8ab8ee134e8d387def13e620e81 # 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 02b166fca40..099a4b9a5bc 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -64,7 +64,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 0cbe496cdae..367288ff114 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -64,7 +64,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 75070612eb4..bdfb54d6b04 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -64,7 +64,7 @@ jobs: key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} From 5f109a6d770d2c3b71f53ba432c45f452fbd09f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:09:24 +0100 Subject: [PATCH 20/40] chore(deps): bump com.gradle.develocity from 4.5.0 to 4.6.1 (#6441) Bumps com.gradle.develocity from 4.5.0 to 4.6.1. --- updated-dependencies: - dependency-name: com.gradle.develocity dependency-version: 4.6.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- settings.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 5947411dd56..ec94cc6ccdf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,7 +18,7 @@ pluginManagement { } plugins { - id("com.gradle.develocity") version "4.5.0" + id("com.gradle.develocity") version "4.6.1" id("com.gradle.common-custom-user-data-gradle-plugin") version "2.8.0" } From c608820dcb5c123bc667ea7df39c0e60ef4a9101 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 3 Nov 2026 08:44:01 +0100 Subject: [PATCH 21/40] ci: Remove the dead submodules checkout option (#6447) The repository has had no submodules since the NDK moved to sentry-native (#3189), which left `.gitmodules` empty. The `submodules: 'recursive'` input on our checkout steps has been a no-op since, so drop it along with the empty file. Co-authored-by: Claude Opus 5.5 --- .github/workflows/agp-matrix.yml | 2 -- .github/workflows/build.yml | 1 - .github/workflows/codeql-analysis.yml | 2 -- .github/workflows/format-code.yml | 2 -- .github/workflows/generate-javadocs.yml | 2 -- .github/workflows/integration-tests-ui.yml | 2 -- .github/workflows/release-build.yml | 2 -- .github/workflows/release.yml | 1 - .github/workflows/spring-boot-2-matrix.yml | 2 -- .github/workflows/spring-boot-3-matrix.yml | 2 -- .github/workflows/spring-boot-4-matrix.yml | 2 -- .gitmodules | 0 12 files changed, 20 deletions(-) delete mode 100644 .gitmodules diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 545fa8a92da..7205577bdee 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -29,8 +29,6 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: 'recursive' - name: Setup Java Version uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7076538f56f..cc7e30414f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,6 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - submodules: 'recursive' - name: Setup Java Version uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a6ced1e58d8..726e78ff285 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -21,8 +21,6 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: 'recursive' - name: Setup Java Version uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 840d50b41d2..46ca0d01a38 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -9,8 +9,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: 'recursive' - name: set up JDK 17 uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 diff --git a/.github/workflows/generate-javadocs.yml b/.github/workflows/generate-javadocs.yml index ae3e5e6a14f..aaf447372de 100644 --- a/.github/workflows/generate-javadocs.yml +++ b/.github/workflows/generate-javadocs.yml @@ -10,8 +10,6 @@ jobs: steps: - name: Checkout 🛎️ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: 'recursive' - name: set up JDK 17 uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index ec08da78156..9589b3413e3 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -23,8 +23,6 @@ jobs: steps: - name: Git checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: 'recursive' - name: 'Set up Java: 17' uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 18f61faa2b1..46882ef200b 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -16,8 +16,6 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: 'recursive' - name: Setup Java Version uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3fc96f40ee..4d43e018a33 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,6 @@ jobs: token: ${{ steps.token.outputs.token }} # Needs to be set, otherwise git describe --tags will fail with: No names found, cannot describe anything fetch-depth: 0 - submodules: 'recursive' - name: Prepare release uses: getsentry/craft@90e8e203ad86c8ab8ee134e8d387def13e620e81 # v3 env: diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index 099a4b9a5bc..d17befbe5c8 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -47,8 +47,6 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: 'recursive' - name: Set up Java uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 367288ff114..de2e4f4feac 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -47,8 +47,6 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: 'recursive' - name: Set up Java uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index bdfb54d6b04..56e708b779a 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -47,8 +47,6 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: 'recursive' - name: Set up Java uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e69de29bb2d..00000000000 From 3eef68872a89b05736fbe9c1a34c8489bcc90727 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 3 Nov 2026 08:44:20 +0100 Subject: [PATCH 22/40] ci: Remove Gradle configuration cache keys and workarounds (#6445) Drop cache-encryption-key and GRADLE_ENCRYPTION_KEY from all workflows, and the buildSrc cache step that worked around gradle/actions#21. Co-authored-by: Claude Opus 5.5 --- .github/workflows/agp-matrix.yml | 3 --- .github/workflows/build.yml | 12 ------------ .github/workflows/codeql-analysis.yml | 5 ----- .github/workflows/format-code.yml | 2 -- .github/workflows/integration-tests-size.yml | 12 ------------ .github/workflows/integration-tests-ui-critical.yml | 5 ----- .github/workflows/integration-tests-ui.yml | 3 --- .github/workflows/spring-boot-2-matrix.yml | 11 ----------- .github/workflows/spring-boot-3-matrix.yml | 11 ----------- .github/workflows/spring-boot-4-matrix.yml | 11 ----------- 10 files changed, 75 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 7205577bdee..81127cb468f 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -24,7 +24,6 @@ jobs: env: VERSION_AGP: ${{ matrix.agp }} APPLY_SENTRY_INTEGRATIONS: ${{ matrix.integrations }} - GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: - name: Checkout Repo @@ -38,8 +37,6 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Enable KVM run: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cc7e30414f0..36c62fbe188 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,9 +15,6 @@ jobs: name: Build Job ubuntu-latest runs-on: ubuntu-latest - env: - GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -30,17 +27,8 @@ jobs: distribution: 'temurin' java-version: '21' - # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - - name: Cache buildSrc - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: buildSrc/build - key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - - name: Setup Gradle uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Run Tests and Lint run: make preMerge diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 726e78ff285..d0d096a77b7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -15,9 +15,6 @@ jobs: name: Analyze runs-on: macos-15 - env: - GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -30,8 +27,6 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Initialize CodeQL uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # pin@v2 diff --git a/.github/workflows/format-code.yml b/.github/workflows/format-code.yml index 46ca0d01a38..fb1a1da7102 100644 --- a/.github/workflows/format-code.yml +++ b/.github/workflows/format-code.yml @@ -18,8 +18,6 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Format with spotlessApply run: ./gradlew spotlessApply diff --git a/.github/workflows/integration-tests-size.yml b/.github/workflows/integration-tests-size.yml index 3b44736e691..9705499f430 100644 --- a/.github/workflows/integration-tests-size.yml +++ b/.github/workflows/integration-tests-size.yml @@ -15,9 +15,6 @@ jobs: name: Build and Analyze SDK Size runs-on: ubuntu-latest - env: - GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -28,17 +25,8 @@ jobs: distribution: "temurin" java-version: "21" - # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - - name: Cache buildSrc - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: buildSrc/build - key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - - name: Setup Gradle uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Size Analysis run: ./gradlew :sentry-android-integration-tests:test-app-size:bundleRelease diff --git a/.github/workflows/integration-tests-ui-critical.yml b/.github/workflows/integration-tests-ui-critical.yml index 715a4679454..de3613da606 100644 --- a/.github/workflows/integration-tests-ui-critical.yml +++ b/.github/workflows/integration-tests-ui-critical.yml @@ -22,9 +22,6 @@ jobs: name: Build runs-on: ubuntu-latest - env: - GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -37,8 +34,6 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Build debug APK run: make assembleUiTestCriticalRelease diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml index 9589b3413e3..5704347f497 100644 --- a/.github/workflows/integration-tests-ui.yml +++ b/.github/workflows/integration-tests-ui.yml @@ -18,7 +18,6 @@ jobs: env: SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} - GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: - name: Git checkout @@ -32,8 +31,6 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} # Clean, build and release a test apk, but only if we will run the benchmark - name: Make assembleUiTests diff --git a/.github/workflows/spring-boot-2-matrix.yml b/.github/workflows/spring-boot-2-matrix.yml index d17befbe5c8..3d18ffa28bf 100644 --- a/.github/workflows/spring-boot-2-matrix.yml +++ b/.github/workflows/spring-boot-2-matrix.yml @@ -41,8 +41,6 @@ jobs: springboot-version: ${{ fromJSON(needs.versions.outputs.versions) }} name: Spring Boot ${{ matrix.springboot-version }} - env: - GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: - name: Checkout Repo @@ -54,17 +52,8 @@ jobs: distribution: 'temurin' java-version: '21' - # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - - name: Cache buildSrc - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: buildSrc/build - key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - - name: Setup Gradle uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Test integration suites # Project paths, unlike a bare task name, keep Gradle from configuring every project. diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index de2e4f4feac..d72fa7c8d9e 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -41,8 +41,6 @@ jobs: springboot-version: ${{ fromJSON(needs.versions.outputs.versions) }} name: Spring Boot ${{ matrix.springboot-version }} - env: - GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: - name: Checkout Repo @@ -54,17 +52,8 @@ jobs: distribution: 'temurin' java-version: '21' - # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - - name: Cache buildSrc - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: buildSrc/build - key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - - name: Setup Gradle uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Test integration suites # Project paths, unlike a bare task name, keep Gradle from configuring every project. diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 56e708b779a..43bf788cf22 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -41,8 +41,6 @@ jobs: springboot-version: ${{ fromJSON(needs.versions.outputs.versions) }} name: Spring Boot ${{ matrix.springboot-version }} - env: - GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} steps: - name: Checkout Repo @@ -54,17 +52,8 @@ jobs: distribution: 'temurin' java-version: '21' - # Workaround for https://github.com/gradle/actions/issues/21 to use config cache - - name: Cache buildSrc - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: buildSrc/build - key: build-logic-${{ hashFiles('buildSrc/src/**', 'buildSrc/build.gradle.kts','buildSrc/settings.gradle.kts') }} - - name: Setup Gradle uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Test integration suites # Project paths, unlike a bare task name, keep Gradle from configuring every project. From 084c3cbefc1d2bb2874dc57976aa8872ff2db935 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Tue, 3 Nov 2026 09:47:15 +0100 Subject: [PATCH 23/40] fix(sample): Drop Nav3 Activity ui.load transactions when the toggle is off (#6401) The sample cancelled the Activity's ui.load transaction through the scope. When another transaction was already bound, such as the previous Activity's nav transaction, the SDK didn't bind ui.load, so the cancel missed and the transaction was still sent, often waiting the full deadline with the Activity's spans. Keep cancelling a bound ui.load transaction so it doesn't block the first nav transaction, and also drop Nav3 sample Activities' ui.load transactions in an event processor when the Activity was created with the toggle off. Co-Authored-By: Claude Opus 5.5 --- .../android/navigation/nav3/Nav3Activity.kt | 9 ++-- .../navigation/nav3/Nav3MultiActivity.kt | 8 +-- .../navigation/nav3/Nav3MultiFragment.kt | 4 +- .../navigation/nav3/Nav3SampleTracing.kt | 50 +++++++++++++++++++ 4 files changed, 57 insertions(+), 14 deletions(-) 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 e90e27573a9..449ac2b2995 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 @@ -92,11 +92,10 @@ class Nav3Activity : ComponentActivity() { configuration.applyToCurrentOptions() showActivityUiLoadTransactionDelayMessage = configuration.hasOnlyActivityUiLoadTransactions - if (!configuration.enableActivityUiLoadTransaction) { - // Cancel the already-started Activity transaction before Compose installs SentryNavEffect, - // otherwise the initial /Landing route transaction gets preempted by the lingering ui.load. - cancelCurrentActivityUiLoadTransaction() - } + // When the toggle is off, cancel the already-started Activity transaction before Compose + // installs SentryNavEffect, otherwise the lingering ui.load preempts the initial /Landing route + // transaction. + applyActivityUiLoadTransactionSetting(this, configuration.enableActivityUiLoadTransaction) NavigationTransactionHistory.install() 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 index 5e6ca0b9d07..e3c92bf8d7b 100644 --- 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 @@ -89,9 +89,7 @@ class Nav3ProductsActivity : ComponentActivity() { super.onCreate(savedInstanceState) val configuration = (intent.extras ?: Bundle()).nav3SampleConfiguration() NavigationTransactionHistory.install() - if (!configuration.enableActivityUiLoadTransaction) { - cancelCurrentActivityUiLoadTransaction() - } + applyActivityUiLoadTransactionSetting(this, configuration.enableActivityUiLoadTransaction) setContent { val stack = rememberSaveableNav3BackStack(Nav3Route.ProductList) SideEffect { @@ -174,9 +172,7 @@ open class Nav3CheckoutActivity : ComponentActivity() { val crashOnFirstComposition = savedInstanceState == null && intent.getBooleanExtra(EXTRA_CRASH_ON_FIRST_COMPOSITION, false) NavigationTransactionHistory.install() - if (!configuration.enableActivityUiLoadTransaction) { - cancelCurrentActivityUiLoadTransaction() - } + applyActivityUiLoadTransactionSetting(this, configuration.enableActivityUiLoadTransaction) setContent { Nav3SampleTheme { if (showAsSheet) { 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 index 846344103c6..d95be37b4af 100644 --- 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 @@ -76,9 +76,7 @@ class Nav3FragmentsActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NavigationTransactionHistory.install() - if (!configuration.enableActivityUiLoadTransaction) { - cancelCurrentActivityUiLoadTransaction() - } + applyActivityUiLoadTransactionSetting(this, configuration.enableActivityUiLoadTransaction) val controls = ComposeView(this).apply { 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 a23ed37c3f6..dd445ce47ca 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 @@ -1,8 +1,13 @@ package io.sentry.samples.android.navigation.nav3 +import android.app.Activity +import io.sentry.EventProcessor +import io.sentry.Hint import io.sentry.Sentry import io.sentry.SpanStatus import io.sentry.protocol.SentryTransaction +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.resume import kotlinx.coroutines.suspendCancellableCoroutine @@ -21,6 +26,51 @@ internal fun SentryTransaction.navigationSampleScenarioLabel(): String = ?: getTag(NAV3_SCENARIO_TAG) ?: UNKNOWN_NAVIGATION_SCENARIO_LABEL +/** + * Applies the sample's "Activity ui.load transaction" toggle to [activity]. + * + * Call from `onCreate` after `super.onCreate`, once the SDK has started the Activity's ui.load + * transaction. When [enabled] is false, the transaction is cancelled if it's bound to the scope. + * It's also dropped before sending, because the SDK doesn't bind it when another transaction (such + * as the previous Activity's nav transaction) is already bound, and the sample can't reach an + * unbound ui.load transaction to cancel it. + */ +internal fun applyActivityUiLoadTransactionSetting(activity: Activity, enabled: Boolean) { + // Matches the transaction name ActivityLifecycleIntegration uses. + val activityName = activity.javaClass.simpleName + if (enabled) { + SuppressedActivityUiLoadTransactions.activityNames.remove(activityName) + return + } + SuppressedActivityUiLoadTransactions.install() + SuppressedActivityUiLoadTransactions.activityNames.add(activityName) + cancelCurrentActivityUiLoadTransaction() +} + +/** Drops ui.load transactions for sample Activities created with the ui.load toggle off. */ +private object SuppressedActivityUiLoadTransactions : EventProcessor { + val activityNames: MutableSet = + Collections.newSetFromMap(ConcurrentHashMap()) + + @Volatile private var installed = false + + fun install() { + if (installed) return + synchronized(this) { + if (installed) return + Sentry.getCurrentScopes().options.addEventProcessor(this) + installed = true + } + } + + override fun process(transaction: SentryTransaction, hint: Hint): SentryTransaction? { + val isSuppressed = + transaction.contexts.trace?.operation == ACTIVITY_UI_LOAD_OP && + transaction.transaction in activityNames + return if (isSuppressed) null else transaction + } +} + internal fun cancelCurrentActivityUiLoadTransaction() { Sentry.configureScope { scope -> scope.withTransaction { transaction -> From 04bb98f482a11a2fb5a257605778b133e0888274 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:15:39 +0100 Subject: [PATCH 24/40] fix(android): Skip dist fallback when release has no version code (#6450) * fix(android): Skip dist fallback when release has no version code ApplicationExitInfoEventProcessor derived dist from the text after '+' in the release. Without a '+', indexOf returned -1 and the whole release became dist, which Relay rejects for characters like '@'. Co-Authored-By: Gene Wright * changelog Co-Authored-By: Gene Wright --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Gene Wright --- CHANGELOG.md | 2 ++ .../ApplicationExitInfoEventProcessor.java | 11 +++----- .../ApplicationExitInfoEventProcessorTest.kt | 26 +++++++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ddcf1f068..a5d48eeb558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ - Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291)) - Before, the start call was ignored and the profiler still stopped at the end of the chunk, even in `TRACE` mode while sampled spans were active. On API 34 and lower, every later profile also stopped after one chunk. - Refuse to send data to servers whose certificate was issued by one of the leaked eMudhra intermediates (CVE-2026-111943) ([#6336](https://github.com/getsentry/sentry-java/pull/6336)) +- Stop sending the whole release as `dist` on ANR and app-exit events when the release has no `+versionCode` ([#6450](https://github.com/getsentry/sentry-java/pull/6450)) + - Relay rejected these values (e.g. `invalid character '@'`). `dist` is now left empty unless `options.dist` is set or the release contains a version code. ### Dependencies diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java index a82beb74121..80da05967ea 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ApplicationExitInfoEventProcessor.java @@ -532,16 +532,13 @@ private void setDist( event.setDist(getLaunchOption(DIST_FILENAME, String.class, options.getDist(), optionsSource)); } // if there's no user-set dist, fall back to versionCode from the release string + // (package@versionName+versionCode). Releases without a versionCode leave dist empty. if (event.getDist() == null) { final String release = event.getRelease(); if (release != null) { - try { - final String versionCode = release.substring(release.indexOf('+') + 1); - event.setDist(versionCode); - } catch (Throwable e) { - options - .getLogger() - .log(SentryLevel.WARNING, "Failed to parse release from scope cache: %s", release); + final int versionCodeSeparator = release.indexOf('+'); + if (versionCodeSeparator != -1) { + event.setDist(release.substring(versionCodeSeparator + 1)); } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt index aa08875a34e..12c1bf08429 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ApplicationExitInfoEventProcessorTest.kt @@ -746,6 +746,32 @@ class ApplicationExitInfoEventProcessorTest { assertEquals("232", processed!!.dist) } + @Test + fun `if dist is not persisted and release has no version code, leaves dist empty`() { + val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) + + val processor = fixture.getSut(tmpDir) + fixture.persistOptions(RELEASE_FILENAME, "io.sentry.samples@1.2") + + val processed = processor.process(SentryEvent(), hint) + + assertEquals("io.sentry.samples@1.2", processed!!.release) + assertNull(processed.dist) + } + + @Test + fun `if dist is not persisted and options release has no version code, leaves dist empty`() { + val hint = HintUtils.createWithTypeCheckHint(AbnormalExitHint(timestamp = 2_000)) + val processor = fixture.getSut(tmpDir) + fixture.options.release = "io.sentry.samples@1.2" + setLastUpdateTime(1_000) + + val processed = processor.process(SentryEvent(), hint)!! + + assertEquals("io.sentry.samples@1.2", processed.release) + assertNull(processed.dist) + } + @Test fun `merges user`() { val hint = HintUtils.createWithTypeCheckHint(BackfillableHint()) From e1eb5338de9428aba993a6a9ae6551a1f3558959 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Wed, 4 Nov 2026 09:53:26 +0100 Subject: [PATCH 25/40] fix(anr): Skip ANR profiling while a debugger is connected (#6389) * fix(anr): Skip ANR profiling while a debugger is connected * changelog * ref(anr): Provide debugger detection through SentryOptions --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 5 ++ .../io/sentry/android/core/ANRWatchDog.java | 16 +++++-- .../android/core/AndroidDebugManager.java | 14 ++++++ .../core/AndroidOptionsInitializer.java | 3 ++ .../sentry/android/core/AnrIntegration.java | 3 +- .../core/anr/AnrProfilingIntegration.java | 12 +++++ .../io/sentry/android/core/ANRWatchDogTest.kt | 48 +++++++++++++++++-- .../core/AndroidOptionsInitializerTest.kt | 7 +++ .../core/anr/AnrProfilingIntegrationTest.kt | 29 +++++++++++ sentry/api/sentry.api | 6 +++ .../main/java/io/sentry/IDebugManager.java | 13 +++++ .../main/java/io/sentry/SentryOptions.java | 12 +++++ 13 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/AndroidDebugManager.java create mode 100644 sentry/src/main/java/io/sentry/IDebugManager.java diff --git a/CHANGELOG.md b/CHANGELOG.md index a5d48eeb558..c1c3c7416d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Improvements - Speed up Session Replay and screenshot masking for large `maskViewClasses`/`unmaskViewClasses` lists by using constant-time class lookups ([#6410](https://github.com/getsentry/sentry-java/pull/6410)) +- Skip ANR profiling while a debugger is connected ([#6389](https://github.com/getsentry/sentry-java/pull/6389)) ### Fixes diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index e8b95794ea0..62d9b0d4960 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -65,6 +65,11 @@ public final class io/sentry/android/core/AndroidDateUtils { public static fun getCurrentSentryDateTime ()Lio/sentry/SentryDate; } +public final class io/sentry/android/core/AndroidDebugManager : io/sentry/IDebugManager { + public fun ()V + public fun isDebuggerConnected ()Z +} + public final class io/sentry/android/core/AndroidFatalLogger : io/sentry/ILogger { public fun ()V public fun (Ljava/lang/String;)V diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java b/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java index b726dd0c881..c8f1bf5e361 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ANRWatchDog.java @@ -29,14 +29,15 @@ import android.app.ActivityManager; import android.content.Context; -import android.os.Debug; import android.os.SystemClock; +import io.sentry.IDebugManager; import io.sentry.ILogger; import io.sentry.SentryLevel; import io.sentry.transport.ICurrentDateProvider; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; /** A watchdog timer thread that detects when the UI thread has frozen. */ @@ -58,6 +59,7 @@ final class ANRWatchDog extends Thread { private final AtomicBoolean reported = new AtomicBoolean(false); private final @NotNull Context context; + private final @Nullable IDebugManager debugManager; @SuppressWarnings("UnnecessaryLambda") private final Runnable ticker; @@ -67,7 +69,8 @@ final class ANRWatchDog extends Thread { boolean reportInDebug, @NotNull ANRListener listener, @NotNull ILogger logger, - final @NotNull Context context) { + final @NotNull Context context, + final @Nullable IDebugManager debugManager) { // avoid method refs on Android due to some issues with older AGP setups // noinspection Convert2MethodRef this( @@ -78,7 +81,8 @@ final class ANRWatchDog extends Thread { listener, logger, new MainLooperHandler(), - context); + context, + debugManager); } @TestOnly @@ -90,7 +94,8 @@ final class ANRWatchDog extends Thread { @NotNull ANRListener listener, @NotNull ILogger logger, @NotNull MainLooperHandler uiHandler, - final @NotNull Context context) { + final @NotNull Context context, + final @Nullable IDebugManager debugManager) { super("|ANR-WatchDog|"); @@ -102,6 +107,7 @@ final class ANRWatchDog extends Thread { this.logger = logger; this.uiHandler = uiHandler; this.context = context; + this.debugManager = debugManager; this.ticker = () -> { lastKnownActiveUiTimestampMs = timeProvider.getCurrentTimeMillis(); @@ -145,7 +151,7 @@ public void run() { // If the main thread has not handled ticker, it is blocked. ANR. if (unresponsiveDurationMs > timeoutIntervalMillis) { - if (!reportInDebug && (Debug.isDebuggerConnected() || Debug.waitingForDebugger())) { + if (!reportInDebug && debugManager != null && debugManager.isDebuggerConnected()) { logger.log( SentryLevel.DEBUG, "An ANR was detected but ignored because the debugger is connected."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidDebugManager.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidDebugManager.java new file mode 100644 index 00000000000..75afd02f24c --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidDebugManager.java @@ -0,0 +1,14 @@ +package io.sentry.android.core; + +import android.os.Debug; +import io.sentry.IDebugManager; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public final class AndroidDebugManager implements IDebugManager { + + @Override + public boolean isDebuggerConnected() { + return Debug.isDebuggerConnected() || Debug.waitingForDebugger(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 7626acfb1ba..8ba216386b2 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -257,6 +257,9 @@ static void initializeIntegrationsAndProcessors( if (options.getThreadChecker() instanceof NoOpThreadChecker) { options.setThreadChecker(AndroidThreadChecker.getInstance()); } + if (options.getDebugManager() == null) { + options.setDebugManager(new AndroidDebugManager()); + } if (options.getSocketTagger() instanceof NoOpSocketTagger) { options.setSocketTagger(AndroidSocketTagger.getInstance()); } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java index f37d433d308..b9fd0b781d8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AnrIntegration.java @@ -100,7 +100,8 @@ private void startAnrWatchdog( options.isAnrReportInDebug(), error -> reportANR(scopes, options, error), options.getLogger(), - context); + context, + options.getDebugManager()); anrWatchDog.start(); options.getLogger().log(SentryLevel.DEBUG, "AnrIntegration installed."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java index 3cb0bea4b0f..bc41f3faf6a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/anr/AnrProfilingIntegration.java @@ -5,6 +5,7 @@ import android.os.Handler; import android.os.Looper; import android.os.SystemClock; +import io.sentry.IDebugManager; import io.sentry.ILogger; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; @@ -54,6 +55,7 @@ public class AnrProfilingIntegration private volatile @Nullable AnrProfileManager profileManager; private volatile @NotNull ILogger logger = NoOpLogger.getInstance(); private volatile @Nullable SentryAndroidOptions options; + private volatile @Nullable IDebugManager debugManager; private volatile @Nullable Thread thread = null; private volatile boolean sampled = false; private volatile boolean inForeground = false; @@ -67,6 +69,7 @@ public void register(final @NotNull IScopes scopes, final @NotNull SentryOptions (options instanceof SentryAndroidOptions) ? (SentryAndroidOptions) options : null, "SentryAndroidOptions is required"); this.logger = options.getLogger(); + this.debugManager = this.options.getDebugManager(); if (this.options.isAnrProfilingEnabled()) { if (this.options.getCacheDirPath() == null) { @@ -211,6 +214,15 @@ public void run() { @ApiStatus.Internal protected void checkMainThread(final @NotNull Thread mainThread) throws IOException { + if (debugManager != null && debugManager.isDebuggerConnected()) { + // The main thread can sit at a breakpoint for a long time, which is not an ANR. Reset the + // timestamp, so the paused time does not count against us after the debugger detaches. + mainThreadState = MainThreadState.IDLE; + sampled = false; + updater.run(); + return; + } + final long now = SystemClock.uptimeMillis(); final long diff = now - lastMainThreadExecutionTime; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt index 2f3b6791c5b..02522f725f1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ANRWatchDogTest.kt @@ -43,7 +43,17 @@ class ANRWatchDogTest { val interval = 10L val sut = - ANRWatchDog(timeProvider, interval, 1L, true, { a -> anr = a }, mock(), handler, mock()) + ANRWatchDog( + timeProvider, + interval, + 1L, + true, + { a -> anr = a }, + mock(), + handler, + mock(), + AndroidDebugManager(), + ) val es = Executors.newSingleThreadExecutor() try { es.submit { sut.run() } @@ -80,7 +90,17 @@ class ANRWatchDogTest { val interval = 10L val sut = - ANRWatchDog(timeProvider, interval, 1L, true, { a -> anr = a }, mock(), handler, mock()) + ANRWatchDog( + timeProvider, + interval, + 1L, + true, + { a -> anr = a }, + mock(), + handler, + mock(), + AndroidDebugManager(), + ) val es = Executors.newSingleThreadExecutor() try { es.submit { sut.run() } @@ -122,7 +142,17 @@ class ANRWatchDogTest { whenever(am.processesInErrorState).thenReturn(anrs) val sut = - ANRWatchDog(timeProvider, interval, 1L, true, { a -> anr = a }, mock(), handler, context) + ANRWatchDog( + timeProvider, + interval, + 1L, + true, + { a -> anr = a }, + mock(), + handler, + context, + AndroidDebugManager(), + ) val es = Executors.newSingleThreadExecutor() try { es.submit { sut.run() } @@ -168,7 +198,17 @@ class ANRWatchDogTest { whenever(am.processesInErrorState).thenReturn(anrs) val sut = - ANRWatchDog(timeProvider, interval, 1L, true, { a -> anr = a }, mock(), handler, context) + ANRWatchDog( + timeProvider, + interval, + 1L, + true, + { a -> anr = a }, + mock(), + handler, + context, + AndroidDebugManager(), + ) val es = Executors.newSingleThreadExecutor() try { es.submit { sut.run() } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 99b99b81120..7c7acf8ef3b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -900,6 +900,13 @@ class AndroidOptionsInitializerTest { assertTrue { fixture.sentryOptions.threadChecker is AndroidThreadChecker } } + @Test + fun `AndroidDebugManager is set to options`() { + fixture.initSut() + + assertTrue { fixture.sentryOptions.debugManager is AndroidDebugManager } + } + @Test fun `AndroidSocketTagger is set to options`() { fixture.initSut() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt index 2ae48fb3253..6d78a8f70c8 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/anr/AnrProfilingIntegrationTest.kt @@ -196,6 +196,35 @@ class AnrProfilingIntegrationTest { assertEquals(AnrProfilingIntegration.MAX_NUM_STACKS, integration.numCollectedStacks.get()) } + @Test + fun `debugger pauses are treated as idle`() { + val mainThread = Thread.currentThread() + SystemClock.setCurrentTimeMillis(1_000) + var debuggerConnected = true + + val androidOptions = + SentryAndroidOptions().apply { + cacheDirPath = tmpDir.root.absolutePath + setLogger(mockLogger) + anrProfilingSampleRate = 1.0 + debugManager = { debuggerConnected } + } + + val integration = AnrProfilingIntegration() + integration.register(mockScopes, androidOptions) + + SystemClock.setCurrentTimeMillis(20_000) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) + assertEquals(0, integration.numCollectedStacks.get()) + + // the time spent paused must not count towards an ANR once the debugger detaches + debuggerConnected = false + SystemClock.setCurrentTimeMillis(20_100) + integration.checkMainThread(mainThread) + assertEquals(AnrProfilingIntegration.MainThreadState.IDLE, integration.state) + } + @Test fun `background foreground transitions don't trigger an ANR`() { val mainThread = Thread.currentThread() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 6e1ebe4ff30..117a04565a0 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -908,6 +908,10 @@ public abstract interface class io/sentry/IContinuousProfiler { public abstract fun stopProfiler (Lio/sentry/ProfileLifecycle;)V } +public abstract interface class io/sentry/IDebugManager { + public abstract fun isDebuggerConnected ()Z +} + public abstract interface class io/sentry/IDistributionApi { public abstract fun checkForUpdate ()Ljava/util/concurrent/Future; public abstract fun checkForUpdateBlocking ()Lio/sentry/UpdateStatus; @@ -3798,6 +3802,7 @@ public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public fun getDataCollectionResolver ()Lio/sentry/DataCollectionResolver; public fun getDateProvider ()Lio/sentry/SentryDateProvider; public fun getDeadlineTimeout ()J + public fun getDebugManager ()Lio/sentry/IDebugManager; public fun getDebugMetaLoader ()Lio/sentry/internal/debugmeta/IDebugMetaLoader; public fun getDefaultScopeType ()Lio/sentry/ScopeType; public fun getDiagnosticLevel ()Lio/sentry/SentryLevel; @@ -3953,6 +3958,7 @@ public class io/sentry/SentryOptions : io/sentry/transport/RateLimiterConfig { public fun setDateProvider (Lio/sentry/SentryDateProvider;)V public fun setDeadlineTimeout (J)V public fun setDebug (Z)V + public fun setDebugManager (Lio/sentry/IDebugManager;)V public fun setDebugMetaLoader (Lio/sentry/internal/debugmeta/IDebugMetaLoader;)V public fun setDefaultScopeType (Lio/sentry/ScopeType;)V public fun setDiagnosticLevel (Lio/sentry/SentryLevel;)V diff --git a/sentry/src/main/java/io/sentry/IDebugManager.java b/sentry/src/main/java/io/sentry/IDebugManager.java new file mode 100644 index 00000000000..7d0bee68f9c --- /dev/null +++ b/sentry/src/main/java/io/sentry/IDebugManager.java @@ -0,0 +1,13 @@ +package io.sentry; + +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public interface IDebugManager { + + /** + * Returns whether a debugger is currently attached or the process is waiting for a debugger to + * attach. + */ + boolean isDebuggerConnected(); +} diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index f4a17e3ebe5..4a056c7609c 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -520,6 +520,8 @@ public class SentryOptions implements RateLimiterConfig { private @NotNull IThreadChecker threadChecker = NoOpThreadChecker.getInstance(); + private @Nullable IDebugManager debugManager; + // TODO [MAJOR] this should default to false on the next major /** Whether OPTIONS requests should be traced. */ private boolean traceOptionsRequests = true; @@ -2752,6 +2754,16 @@ public void setThreadChecker(final @NotNull IThreadChecker threadChecker) { this.threadChecker = threadChecker; } + @ApiStatus.Internal + public @Nullable IDebugManager getDebugManager() { + return debugManager; + } + + @ApiStatus.Internal + public void setDebugManager(final @Nullable IDebugManager debugManager) { + this.debugManager = debugManager; + } + /** * Gets the performance collector used to collect performance stats in a time period. * From 43a5c1484b474de8f2df7a490fada07ba75afc6b Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:00:52 +0100 Subject: [PATCH 26/40] ci(release): Bump craft to 3.9.0 and update config (#6456) * ci(release): Bump getsentry/craft to 3.9.0 3.9.0 includes getsentry/craft#956, which stops the release when release-blocker issues are open. * ci(release): Update .craft.yml for craft 3.9.0 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: BYK <126780+BYK@users.noreply.github.com> --- .craft.yml | 8 ++++++-- .github/workflows/release.yml | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.craft.yml b/.craft.yml index 5890b0037ea..d725b0e93db 100644 --- a/.craft.yml +++ b/.craft.yml @@ -1,5 +1,9 @@ -minVersion: 0.29.3 -changelogPolicy: auto +minVersion: 3.9.0 +changelog: + policy: auto +preReleaseCommand: bash scripts/bump-version.sh +versioning: + policy: manual targets: - name: maven includeNames: /^sentry.*$/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d43e018a33..0a3db95c974 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: # Needs to be set, otherwise git describe --tags will fail with: No names found, cannot describe anything fetch-depth: 0 - name: Prepare release - uses: getsentry/craft@90e8e203ad86c8ab8ee134e8d387def13e620e81 # v3 + uses: getsentry/craft@2e29b0819113d334c15da3d8cac2138cb9dc111a # 3.9.0 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From ff7e433e9a6a808d6c16813edc833baf214735ed Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Wed, 4 Nov 2026 12:57:47 +0100 Subject: [PATCH 27/40] docs: Add a concurrency section to AGENTS.md (#6009) Two production ANRs in the Android SDK came from holding a lock across a call that re-enters the SDK: finishing a span or transaction captures synchronously on the calling thread and runs every event processor, and those processors lock and call back into the component that is still holding its own lock. Write the rule down next to the exception-handling guidance, with the snapshot-then-call pattern, the second-lock escape hatch for compound operations that were only atomic because the lock spanned them, and the volatile and single-read rules. Co-authored-by: Claude Opus 5 --- AGENTS.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a736a15e389..f447fc31512 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,6 +154,54 @@ Apply that pattern only where a broad catch is genuinely unavoidable — an entr arbitrary user code or third-party callbacks. Everywhere else, name the exception types. Say in the PR description why the broad catch is necessary. +### Concurrency + +**Never hold a lock across a call that can re-enter the SDK.** Read what you need under the lock, +release it, then make the call. + +Finishing a span or transaction, capturing an event, and invoking a user-supplied callback all run +arbitrary SDK code on the calling thread. `SentryTracer.finish()` calls `scopes.captureTransaction(...)` +synchronously, which runs every registered `EventProcessor` — and those processors take their own +locks and call back into SDK components. A lock held across such a call is therefore acquired in the +opposite order from the processor's, which is a deadlock. On Android the blocked thread is usually +the main thread, so it surfaces to users as an ANR rather than a hang. + +```java +// Wrong: the lock is held while finish() captures the transaction and runs event processors, +// which take their own lock and then call back into this class. +try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final @Nullable ITransaction transaction = ownedTransaction; + if (transaction != null && !transaction.isFinished()) { + transaction.finish(SpanStatus.OK); + } +} + +// Right: read the field under the lock, call finish() outside it. +final @Nullable ITransaction transaction; +try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + transaction = ownedTransaction; +} +if (transaction != null && !transaction.isFinished()) { + transaction.finish(SpanStatus.OK); +} +``` + +The same applies to logging through `options.getLogger()`, invoking a listener, and any +`ISpan`/`IScopes` call reachable from a component that locks. + +Moving the call out can break a compound operation that was atomic only because the lock spanned +it. When that happens, do not widen the original lock again — serialize the callers with a second +lock that the re-entrant path never acquires, and document the order the two are taken in. + +Two more rules that catch most of the rest: + +- **Mark a field `volatile` when it is written on one thread and read on another without a lock.** + A plain field read is a data race, not merely a stale value. +- **Read mutable shared state once per operation.** Re-reading the same field for several + decisions in one pass lets it change mid-pass, so the results disagree with each other. + +Prefer `AutoClosableReentrantLock` over `synchronized`, matching the rest of the codebase. + ### Testing Requirements - Write comprehensive unit tests for new features - Android modules require both unit tests and instrumented tests where applicable From a21cd0935e81e572fdde0674057c26ae7be2f87a Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 4 Nov 2026 13:25:34 +0100 Subject: [PATCH 28/40] fix(android): Nav3 support for ViewPager2 (JAVA-785) (#6398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the multi-Activity and multi-Fragment support introduced by the #6301 PR stack play nicely with ViewPager2. BACKGROUND The approach in #6301 was smart about staging nav data (ie, screen name + nav context), but it didn't do the same for nav transactions. A new nav transaction could be started only if the lease was owned at the time of navigation, where "navigation" == initialization or modification of the nav back stack. Because the lease had to be owned, that meant that the Activity or Fragment hosting the SentryNavEffect requesting the lease had to be in a resumed state. That^^ assumption made sense for most use cases, because usually initialization of an Activity or Fragment's nav back stack coincides with the Activity or Fragment being visible and therefore being able to preempt the lease from the previous leaseholder. But the assumption didn't pan out for ViewPager2, because ViewPager2 preloads neighboring pages. That means it initializes the nav back stack for Fragments that aren't in a resumed state and therefore can't preempt the lease, causing them to drop their initial nav transaction and any spans created by the initial destination screen. Sigh. THE SOLUTION The solution this commit implements is to create a mechanism similar to the data staging from #6301, but for transactions. Doing so lets us separate back stack initialization observed during preloading vs attempted lease acquisition when a ViewPager2 page becomes visible. In particular, the new StagedNavTransaction class tracks requests to start nav transactions when a SentryNavEffect is created even if it doesn't currently have the lease. If the lease is later acquired and the transaction slot is available on the shared IScope, the staged transaction is started, which keeps ViewPager2 happy. REFACTORING INCLUDED Included in this commit is a refactor of code in the .navigation3.lifecycle package. The refactor lets us separate three responsibilities: - Lease coordination: NavLeaseCoordinator observes registered owners’ lifecycles and decides which SentryNavEffect may publish navigation state. - State publication: Each effect’s NavScopes publishes and withdraws its navigation data and transaction together when ownership changes. - Transaction management: Each effect gets a transaction handle that manages starting, finishing, binding, and restoring its transaction. The coordinator no longer retains transactions itself. --- CHANGELOG.md | 1 + .../compose/navigation3/BackStackConverter.kt | 2 +- .../navigation3/BackStackEntryMapper.kt | 2 +- .../compose/navigation3/BackStackObserver.kt | 77 +- .../CoordinatedBackStackObserver.kt | 3 +- .../lifecycle/NavLeaseCoordinator.kt | 647 +++++----- .../compose/navigation3/lifecycle/NavScope.kt | 156 --- .../navigation3/lifecycle/NavScopes.kt | 450 ++++--- .../navigation3/lifecycle/NavTransaction.kt | 149 +++ .../compose/navigation3/lifecycle/README.md | 24 +- .../navigation3/lifecycle/StagedNavData.kt | 16 +- .../lifecycle/StagedNavTransaction.kt | 176 +++ .../CoordinatedBackStackObserverTest.kt | 252 +++- ...SentryNavEffectLifecycleIntegrationTest.kt | 40 +- .../navigation3/SentryNavEffectTest.kt | 8 +- .../lifecycle/NavLeaseCoordinatorTest.kt | 1049 +++++++++-------- .../navigation3/lifecycle/NavScopeTest.kt | 501 +++++--- .../lifecycle/NavScopesIntegrationTest.kt | 82 ++ .../navigation3/lifecycle/NavScopesTest.kt | 708 +++++++++-- .../lifecycle/NavTransactionTest.kt | 348 ++++++ .../lifecycle/StagedNavTransactionTest.kt | 554 +++++++++ .../navigation/nav3/Nav3MultiActivity.kt | 1 + .../navigation/nav3/Nav3RouteScreens.kt | 14 + 23 files changed, 3780 insertions(+), 1480 deletions(-) delete mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavTransaction.kt create mode 100644 sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavTransaction.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesIntegrationTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavTransactionTest.kt create mode 100644 sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavTransactionTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c3c7416d0..c025af4eee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### Fixes +- Fix missing Navigation 3 transactions for preloaded ViewPager2 pages and destinations reached while another Activity or Fragment owns navigation tracking ([#6400](https://github.com/getsentry/sentry-java/pull/6400)) - Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291)) - Before, the start call was ignored and the profiler still stopped at the end of the chunk, even in `TRACE` mode while sampled spans were active. On API 34 and lower, every later profile also stopped after one chunk. - Refuse to send data to servers whose certificate was issued by one of the leaked eMudhra intermediates (CVE-2026-111943) ([#6336](https://github.com/getsentry/sentry-java/pull/6336)) 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 6100c706202..4bd0bd24e57 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 @@ -450,7 +450,7 @@ internal data class NormalizedSentryBackStackEntry( internal fun List.serialize(): List> = map(NormalizedSentryBackStackEntry::serialize) -/** Why host-provided arguments were unavailable in emitted navigation data. */ +/** Why host-provided arguments were unavailable in emitted nav state. */ internal enum class ArgumentDropReason( val serializedValue: String, val exhaustsUpdateBudget: Boolean = false, diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackEntryMapper.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackEntryMapper.kt index 7455cfda5ff..d26336d1066 100644 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackEntryMapper.kt +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/BackStackEntryMapper.kt @@ -78,7 +78,7 @@ public class SentryBackStackEntry( * * If [map] throws, returns `null`, or returns a blank [name][SentryBackStackEntry.name], Sentry * records the destination as "/unknown". Doing so signals that name extraction needs to be fixed - * while avoiding misleading gaps in navigation data. + * while avoiding misleading gaps in nav state. * * For instance, if a user navigates from `/home -> /detail -> /settings`, but the mapper for * `/detail` throws, the back stack record will be `/home -> /unknown -> /settings` rather than 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 8e2806686e5..2c4fd1fb14d 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,12 +2,9 @@ package io.sentry.compose.navigation3 import io.sentry.Breadcrumb import io.sentry.Hint -import io.sentry.ITransaction 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 import io.sentry.compose.navigation3.BackStackConverter.RetentionPolicy @@ -16,6 +13,7 @@ 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.INavTransaction import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY import io.sentry.protocol.App import io.sentry.protocol.TransactionNameSource @@ -59,7 +57,7 @@ internal class BackStackObserver( private val backStackConverter = BackStackConverter(entryMapper, scopes.options.logger) - private val navTransaction = NavTransaction(scopes) + private val navTransactions = NavTransactions(scopes) private val navBreadcrumbs = NavBreadcrumbs(scopes) private val navScreen = NavScreen() private val navContext = NavContext(scopes, options) @@ -83,7 +81,7 @@ internal class BackStackObserver( } /** - * Updates Sentry nav data based on the provided [backStack]. + * Updates Sentry nav state based on the provided [backStack]. * * **Data generated** * @@ -113,7 +111,7 @@ internal class BackStackObserver( } /** - * Clears the nav data this observer recorded. + * Clears the nav state this observer recorded. * * Call it when observation ends so that no stale screen, nav context, or nav transaction outlives * it. @@ -194,10 +192,10 @@ internal class BackStackObserver( navBreadcrumbs.emit(fromEntry = previousTop, toEntry = currentBackStack.topEntryNormalized) } - navTransaction.stop(scope) + navTransactions.stop(scope) if (areNavigationTransactionsEnabled) { - navTransaction + navTransactions .start( scope, currentTop.name, @@ -231,7 +229,7 @@ internal class BackStackObserver( } private fun tearDown(scope: INavScope) { - navTransaction.stop(scope) + navTransactions.stop(scope) navContext.clear(scope) navScreen.clear(scope) clearPreviousTop() @@ -277,32 +275,22 @@ private data class BackStackData( ) /** A helper class for managing nav transactions. */ -private class NavTransaction(private val scopes: INavScopes) { +private class NavTransactions(private val scopes: INavScopes) { private companion object { private const val TRANSACTION_ORIGIN = "auto.navigation.nav3" } - private var activeNavTransaction: ITransaction? = null - - /** Starts an idle navigation transaction, or no-ops if another transaction is already active. */ + /** + * Starts an idle navigation transaction, or no-ops if another transaction is already active. + * + * [Stop][stop] the current navigation transaction first. See [INavScope.startNavTransaction]. + */ fun start( scope: INavScope, name: String, arguments: Map, - ): ITransaction? { - scope.clearFinishedTransaction() - - if (scope.transaction != null) { - scopes.options.logger.log( - DEBUG, - "Nav3 transaction for route %s won't be created because another transaction is active.", - name, - ) - - return null - } - + ): INavTransaction? { val transactionOptions = TransactionOptions().also { it.isWaitForChildren = true @@ -314,36 +302,25 @@ private class NavTransaction(private val scopes: INavScopes) { } val transaction = - scopes.startTransaction( + scope.startNavTransaction( TransactionContext(name, TransactionNameSource.ROUTE, NAVIGATION_OP), transactionOptions, - ) + ) ?: return null - // Not an error: INavScopes.startTransaction may decline to start a transaction by design. - if (transaction.isNoOp) { - return null + if (arguments.isNotEmpty()) { + transaction.setData("arguments", arguments) } - activeNavTransaction = transaction - - transaction.apply { - if (arguments.isNotEmpty()) { - setData("arguments", arguments) - } - } - - scope.bindTransaction(transaction) - return transaction } - /** Finishes and unsets the active navigation transaction, if one exists. */ + /** + * Finishes and unsets the active navigation transaction, if one exists. + * + * See [INavScope.finishNavTransaction]. + */ fun stop(scope: INavScope) { - val transaction = activeNavTransaction ?: return - val status = transaction.status ?: SpanStatus.OK - transaction.finish(status) - scope.unbindTransaction(transaction) - activeNavTransaction = null + scope.finishNavTransaction() } } @@ -377,15 +354,15 @@ private class NavContext(private val scopes: INavScopes, private val options: Se * proper values to the transaction context instead. */ fun updateTransaction( - transaction: ITransaction, + transaction: INavTransaction, scope: INavScope, backStack: BackStackData, ) { if (scopes.options.isEnableScreenTracking) { - val appContext = transaction.contexts.app ?: scope.app ?: App() + val appContext = scope.app ?: App() appContext.viewNames = listOf(backStack.topEntryNormalized.name) - transaction.contexts.setApp(appContext) + transaction.setApp(appContext) } if (options.captureBackStack && backStack.capturedEntriesNormalized.isNotEmpty()) { 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 index 8cf9c6e1be3..a34a214decc 100644 --- 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 @@ -22,7 +22,8 @@ import io.sentry.compose.navigation3.lifecycle.NavScopes * **Thread safety** * * This class is ***not*** thread-safe. Invocations should be confined to the same thread as the - * [LifecycleEventObserver] callbacks implemented by [NavScopes]. + * [LifecycleEventObserver] callbacks through which the [NavLeaseCoordinator] observes the attached + * lifecycle. */ internal class CoordinatedBackStackObserver( scopes: IScopes, 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 index 87b0f8f75ba..15e3706ad4c 100644 --- 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 @@ -1,30 +1,158 @@ 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.IScope -import io.sentry.ITransaction -import io.sentry.SpanStatus +import io.sentry.IScopes /** * 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]. + * `NavScopes` instances register as [candidates][Candidate] for lease ownership via [register]. + * Each candidate observes its owner's lifecycle until it's disposed. * - * Note: "Owner" below refers to the `LifecycleOwner` whose lifecycle events the candidate's - * `NavScopes` relays. + * Note: "Owner" below refers to the `LifecycleOwner` whose lifecycle the candidate observes. * - * **Exception semantics** + * **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). + * + * **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][Lifecycle.Event.ON_STOP]. It gets the lease back when it + * [resumes][Lifecycle.Event.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 + * │ + * ▼ + * + * NavScopes A + Lifecycle A Shared state NavScopes B + Lifecycle 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 nav events │ + * Navigation lease: none │ + * ▼ + * + * Alternative at rollback point: B disposes before A stops + * Unbind txB ◄─────────────── dispose B ───┤ + * Navigation lease: A │ + * Republish A's staged data │ + * Restore txA if unfinished and scope free │ + * Finish txB if unfinished │ + * ``` + * + * **Nav transactions follow navigation, not the lease** + * + * A candidate gets a nav transaction for a destination it navigates to, never merely for acquiring + * the lease. A destination reached without the lease gets its transaction when the lease is next + * acquired, if it can start then; otherwise it gets none. On reacquiring the lease without having + * navigated in the meantime, a candidate only gets its unfinished transaction back, if any. + * + * Hence the asymmetry for, e.g., ViewPager2 pages: the first time the user swipes to a preloaded + * page, it gets the transaction for the destination it reached while offscreen, but swiping back to + * a page whose transaction has finished starts none. + * + * **Exception policy** * * 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. + * to the caller. Each method updates its own bookkeeping before calling into the scope or a + * candidate's [LeasedNavState], so the coordinator remains usable if the exception is caught. + * Candidates call these methods from [configureScope][IScopes.configureScope] callbacks, whose + * error handling catches the exceptions. * * † 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. + * including the host app – in an unguarded loop, or when starting a nav transaction triggers the + * host app's callbacks. * * **Thread safety** * @@ -36,195 +164,47 @@ 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 + /** Candidate whose nav state was most recently published to the shared scope. */ + private var publisher: 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. + * Registers a candidate for the nav lease that observes [lifecycle] until it's + * [disposed][NavLeaseRegistration.dispose] or `lifecycle` is destroyed. Returns `null` if + * `lifecycle` is already [destroyed][Lifecycle.State.DESTROYED]. */ - fun registerCandidate( - owner: Any, - lifecycleState: Lifecycle.State, - onPublishData: (IScope) -> Unit, - onClearPublishedData: (IScope) -> Unit, - ) { - if (findCandidate(owner) != null) { - return - } - + fun register( + lifecycle: Lifecycle, + scopes: IScopes, + navState: LeasedNavState, + ): NavLeaseRegistration? { + val lifecycleState = lifecycle.currentState if (lifecycleState == Lifecycle.State.DESTROYED) { - return + return null } val initialResumeOrder = if (lifecycleState == Lifecycle.State.RESUMED) ++resumeOrder else 0 - candidates += - Candidate(owner, onPublishData, onClearPublishedData, lifecycleState, initialResumeOrder) - } + val candidate = Candidate(lifecycle, scopes, navState, lifecycleState, initialResumeOrder) + candidates += candidate - /** Records that [owner] started. */ - fun startCandidate(owner: Any) { - findCandidate(owner)?.start() + lifecycle.addObserver(candidate) + return candidate } /** - * Records that [owner] resumed. + * Records that [candidate] is about to apply a back-stack update and acquires the nav lease if + * the owner's lifecycle permits it. * - * 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. + * Later calls for the same [candidate] do nothing. */ - 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 + private fun onBackStackUpdate(candidate: Candidate, scope: IScope) { if (candidate.hasAppliedInitialBackStackUpdate) { return } @@ -232,6 +212,10 @@ internal class NavLeaseCoordinator { candidate.hasAppliedInitialBackStackUpdate = true val holder = leaseHolder + + // 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 + // page). Instead, it acquires the lease when it resumes. val wouldDisplaceResumedHolder = holder != null && holder.state == Lifecycle.State.RESUMED && @@ -242,49 +226,66 @@ internal class NavLeaseCoordinator { } } - /** Whether [transaction] is a nav transaction retained for any registered owner. */ - fun isNavTransaction(transaction: ITransaction): Boolean = candidates.any { - it.transaction === transaction + /** + * Records that [candidate] resumed. + * + * Restores the lease to `candidate` if it already applied a back stack update and [scope] is + * non-null. + */ + private fun resumeCandidate(candidate: Candidate, scope: IScope?) { + // A null scope (e.g., the SDK is disabled) still records the lifecycle transition so that the + // candidate remains eligible for fallback once the scope becomes available. + candidate.resume(++resumeOrder) + + if (scope != null && candidate.hasAppliedInitialBackStackUpdate) { + transferLeaseTo(candidate, scope) + } } /** - * Records [transaction] as the nav transaction of [owner], which must hold the lease. Ignored - * otherwise, as only the leaseholder starts nav transactions. + * Records that [candidate] paused. * - * 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]. + * Hands the lease to another owner only if that owner is resumed (e.g., a Fragment whose dialog + * is being dismissed); otherwise keeps it. */ - fun retainTransaction(owner: Any, transaction: ITransaction) { - val candidate = leaseHolder ?: return - if (candidate.owner !== owner) { - return + private fun pauseCandidate(candidate: Candidate, scope: IScope?) { + candidate.pause() + + // A paused leaseholder may still be the topmost visible owner (e.g., beneath a translucent + // window or picture-in-picture mode), and any other paused owner is beneath it. A displaced + // owner will acquire the lease again when it resumes. + if (leaseHolder === candidate) { + mostRecentlyResumedCandidate(excluding = candidate, resumedOnly = true)?.let { next -> + handOffLease(next, scope) + } } - 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. + * Records that [candidate] stopped. * - * 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. + * 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. */ - fun releaseTransaction(owner: Any, transaction: ITransaction) { - val candidate = findCandidate(owner) ?: return - if (candidate.transaction === transaction) { - candidate.transaction = null + private fun stopCandidate(candidate: Candidate, scope: IScope?) { + candidate.stop() + + // 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 will acquire the lease when it resumes. + if (leaseHolder === candidate) { + mostRecentlyResumedCandidate(resumedOnly = true)?.let { next -> handOffLease(next, scope) } } } - 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. + * The eligible candidate most recently observed to be in a resumed state, other than [excluding]. + * + * If [resumedOnly] is set to `true`, only candidates that are resumed right now are considered; + * if `false`, started and paused candidates are also considered. */ private fun mostRecentlyResumedCandidate( excluding: Candidate? = null, @@ -312,94 +313,74 @@ internal class NavLeaseCoordinator { } } - /** - * 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. - */ + /** Hands the lease to [candidate], or releases it given `null`. */ 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 + val previousPublisher = publisher 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 + // nav state from the moment it takes the lease. If unpublishing the previous owner's state + // then throws, the next handoff still unpublishes the candidate's state rather than never + // unpublishing it. + publisher = candidate if (previousPublisher != null && previousPublisher !== candidate) { - previousPublisher.onClearPublishedData(scope) + previousPublisher.navState.unpublishFrom(scope) } - candidate.onPublishData(scope) + candidate.navState.publishTo(scope) } } /** - * Finishes [transaction] if it's unfinished, and unbinds it from [scope]. + * Stops tracking [candidate] (e.g., because it was destroyed). * - * Callers must forget the transaction first. + * Unpublishes `candidate`'s nav state from [scope] if it's the most recently published. If + * `candidate` 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. */ - private fun finishTransaction(transaction: ITransaction?, scope: IScope) { - if (transaction == null) { - return + private fun unregisterCandidate(candidate: Candidate, scope: IScope?) { + val wasPublisher = publisher === candidate + val wasLeaseHolder = leaseHolder === candidate + + // Remove candidate before any scope calls to ensure it's released even if cleanup or handoff + // throws. + candidates.remove(candidate) + + if (wasPublisher) { + publisher = null } - if (!transaction.isFinished) { - transaction.finish(transaction.status ?: SpanStatus.OK) + if (wasLeaseHolder) { + leaseHolder = null } - clearTransactionFromScope(transaction, scope) - } - private fun clearTransactionFromScope(transaction: ITransaction, scope: IScope) { - scope.withTransaction { current -> - if (current === transaction) { - scope.clearTransaction() - } + // A null scope (e.g., the SDK is disabled) still removes the candidate, but its published nav + // state is left in place and the lease is released rather than handed off. + if (scope == null) { + return + } + + if (wasPublisher) { + candidate.navState.unpublishFrom(scope) + } + if (wasLeaseHolder) { + transferLeaseTo(mostRecentlyResumedCandidate(), scope) } } - /** 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, + /** + * A [LifecycleOwner] registered as a candidate for lease ownership. Relays its owner's lifecycle + * events to the coordinator until it's [disposed][dispose]. + */ + private inner class Candidate( + private val lifecycle: Lifecycle, + private val scopes: IScopes, + val navState: LeasedNavState, initialState: Lifecycle.State, initialResumeOrder: Long, - ) { + ) : NavLeaseRegistration, LifecycleEventObserver { /** The candidate's lifecycle state, as last observed. */ var state: Lifecycle.State = initialState @@ -407,15 +388,13 @@ internal class NavLeaseCoordinator { /** * Whether the candidate has been observed STOPPED since - * [registration][NavLeaseCoordinator.registerCandidate]. + * [registration][NavLeaseCoordinator.register]. */ 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]. + * since [registration][NavLeaseCoordinator.register]. */ var resumeOrder: Long = initialResumeOrder private set @@ -424,14 +403,9 @@ internal class NavLeaseCoordinator { /** * 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]). + * [registration][NavLeaseCoordinator.register] to acquire the nav lease: it's resumed, or it + * has neither resumed nor stopped since registration (i.e., it's still coming up for the first + * time). */ val canAcquireLeaseOnFirstUpdate: Boolean get() = state == Lifecycle.State.RESUMED || (resumeOrder == 0L && !wasStopped) @@ -457,5 +431,112 @@ internal class NavLeaseCoordinator { state = Lifecycle.State.CREATED wasStopped = true } + + private var isDisposed = false + + override val hasLease: Boolean + get() = leaseHolder === this + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + ON_START -> start() + ON_RESUME -> withScopeOrNull { resumeCandidate(this, it) } + ON_PAUSE -> withScopeOrNull { pauseCandidate(this, it) } + ON_STOP -> withScopeOrNull { stopCandidate(this, it) } + ON_DESTROY -> dispose() + else -> Unit + } + } + + override fun onBackStackUpdate() { + if (isDisposed) { + return + } + scopes.configureScope { scope -> this@NavLeaseCoordinator.onBackStackUpdate(this, scope) } + } + + override fun dispose() { + if (isDisposed) { + return + } + isDisposed = true + lifecycle.removeObserver(this) + + withScopeOrNull { scope -> + try { + unregisterCandidate(this, scope) + } finally { + navState.onUnregistered(scope) + } + } + } + + /** + * 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 + scopes.configureScope { scope -> + callbackInvoked = true + block(scope) + } + + if (!callbackInvoked) { + block(null) + } + } } } + +/** + * A candidate's registration with a [NavLeaseCoordinator], starting from + * [NavLeaseCoordinator.register] and continuing until it's [disposed][dispose] or its lifecycle is + * destroyed. + */ +internal interface NavLeaseRegistration { + + /** Whether the candidate holds the nav lease. */ + val hasLease: Boolean + + /** + * Announces that the candidate is about to apply a back stack update, so it can acquire the nav + * lease if it's eligible to. Does nothing once the candidate is unregistered. + */ + fun onBackStackUpdate() + + /** + * Stops observing the candidate's lifecycle and unregisters it: releases the lease, unpublishes + * the candidate's nav state, and then calls [LeasedNavState.onUnregistered], even if + * unregistering throws. Does nothing once the candidate is unregistered. + */ + fun dispose() +} + +/** + * The nav state a [NavLeaseCoordinator] candidate publishes to the shared [IScope] while it holds + * the nav lease. + * + * Nav transactions and nav data each occupy a single, mutually-exclusive slot in the `IScope`, so + * only one candidate's nav state can be published at a time. Unpublishing nav state removes it from + * the scope but keeps it staged, so the candidate can publish it again if it regains the lease. + */ +internal interface LeasedNavState { + + /** Publishes staged nav state to [scope]. */ + fun publishTo(scope: IScope) + + /** Removes published nav state from [scope], keeping it staged so it can be republished. */ + fun unpublishFrom(scope: IScope) + + /** + * Called when the lease candidate is unregistered, after its nav state has been unpublished. + * + * The provided [scope] is the scope the candidate was unregistered against, or `null` if there + * was none (e.g., the SDK is disabled). + */ + fun onUnregistered(scope: IScope?) +} 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 deleted file mode 100644 index f67c0b9b32d..00000000000 --- a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt +++ /dev/null @@ -1,156 +0,0 @@ -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 index 92fdb80b52f..37a796a58c6 100644 --- 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 @@ -1,23 +1,17 @@ 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.PropagationContext import io.sentry.SentryLevel import io.sentry.SentryOptions import io.sentry.TransactionContext import io.sentry.TransactionOptions +import io.sentry.protocol.App +import io.sentry.protocol.Contexts /** 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" @@ -28,14 +22,14 @@ internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation" * `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: + * clobber or interleave one another's updates unless properly managed: * ``` * LifecycleOwner (e.g., Activity or Fragment) * ↓ owns * SentryNavEffect * ↓ * CoordinatedBackStackObserver (wrapper) - * ├── BackStackObserver — computes nav data and nav transactions + * ├── BackStackObserver — computes nav data and requests nav transactions * └── NavScopes — controls if nav data + txns reach the real scope * ↓ * NavLeaseCoordinator — arbitrates ownership across all observers @@ -43,151 +37,17 @@ internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation" * 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** + * **Only leaseholders start nav transactions or publish nav data** * * 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** + * and publish their navigation data to the shared `IScope`. (The lease policy is implemented by + * [NavLeaseCoordinator].) * - * 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 │ - * ``` + * `NavScopes` instances without a lease write to their own copy of [stagedNavTransaction] and + * [stagedNavData], which lets them preserve their latest updates without overwriting another + * instance's transaction or published data. When an instance acquires the lease, it writes its + * staged transaction and data to the shared `IScope` (subject to any publishing conditions, e.g., + * staged transactions are dropped if another transaction is already bound to the shared `IScope`). * * **Usage** * @@ -210,166 +70,264 @@ internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation" internal class NavScopes( private val delegate: IScopes, private val coordinator: NavLeaseCoordinator, -) : INavScopes, LifecycleEventObserver { +) : INavScopes { override val options: SentryOptions get() = delegate.options - private val owner: Any = Any() private var attachedLifecycle: Lifecycle? = null + + /** This instance's registration with the [coordinator], while it's attached. */ + private var registration: NavLeaseRegistration? = null + + private val stagedNavTransaction = StagedNavTransaction(delegate) private val stagedNavData = StagedNavData() + private val navStatePublisher = NavStatePublisher() + /** * 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. + * Attaching to a second, different lifecycle no-ops. */ 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) { + if (currentLifecycle != null) { + if (currentLifecycle !== lifecycle) { + delegate.options.logger.log( + SentryLevel.WARNING, + "NavScopes is already attached to a different lifecycle; ignoring the new lifecycle.", + ) + } + + // No-op if already attached to a lifecycle instance (whether the same or a different one). 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 - } + registration = coordinator.register(lifecycle, delegate, navStatePublisher) } /** * Announces that a back stack update is about to be applied, so this instance can acquire the nav - * lease if it's eligible to. + * lease if it's eligible. * - * 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. + * Must be called before each update. */ fun onBackStackUpdate() { - delegate.configureScope { scope -> coordinator.onBackStackUpdate(owner, scope) } + registration?.onBackStackUpdate() } override fun configureScope(callback: (INavScope) -> Unit) { - delegate.configureScope { scope -> - callback(NavScope(scope, stagedNavData, coordinator, owner)) - } + delegate.configureScope { scope -> callback(NavScope(scope)) } } - /** - * 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. + * Releases the lease (if held), unpublishes this instance's nav state, and finishes its nav + * transaction. */ fun dispose() { - attachedLifecycle?.removeObserver(this) + registration?.dispose() + stagedNavTransaction.forget() attachedLifecycle = null + registration = null + } - // Clear nav state, unbind the nav transaction, and avoid leaking the LifecycleOwner. - withScopeOrNull { coordinator.unregisterCandidate(owner, it) } + /** + * Publishes this instance's [stagedNavTransaction] and [stagedNavData] to the shared [IScope] + * while it holds the nav lease, as directed by the [coordinator]. + */ + private inner class NavStatePublisher : LeasedNavState { + + override fun publishTo(scope: IScope) { + // Publishing the data first ensures a staged transaction is sampled against the rotated + // propagation context when it starts. + stagedNavData.publishTo(scope) + stagedNavTransaction.publishTo(scope) + } + + override fun unpublishFrom(scope: IScope) { + stagedNavTransaction.unpublishFrom(scope) + stagedNavData.clearPublishedStateFrom(scope) + } + + override fun onUnregistered(scope: IScope?) { + // Unregistering on destroy detaches this instance, as dispose() does. + attachedLifecycle = null + registration = null + + if (scope != null) { + stagedNavTransaction.finish(scope) + } else { + // Without a scope (e.g., the SDK is disabled), the transaction can't be sent and will time + // out anyway. Not finishing it immediately lets us ensure the error-handling policy from + // IScopes.configureScope covers all SDK work. + stagedNavTransaction.forget() + } + } } /** - * 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. + * An [INavScope] that maintains a [stagedNavTransaction] and [stagedNavData], and only writes to + * or reads from the [delegate IScope][IScope] when this instance holds the nav lease. */ - private fun withScopeOrNull(block: (IScope?) -> Unit) { - var callbackInvoked = false - delegate.configureScope { scope -> - callbackInvoked = true - block(scope) + private inner class NavScope(private val delegate: IScope) : INavScope { + + override var screen: String? + get() = if (hasLease) delegate.screen else stagedNavData.screen + set(value) { + stagedNavData.stageScreen(value) + if (hasLease) { + stagedNavData.publishScreen(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`. + */ + override val app: App? + get() = Contexts(delegate.contexts).app + + private val hasLease: Boolean + get() = registration?.hasLease == true + + override fun setNavigationContext(value: Any?) { + stagedNavData.stageNavigationContext(value) + if (hasLease) { + stagedNavData.publishNavigationContext(delegate) + } + } + + override fun rotatePropagationContext() { + if (hasLease) { + delegate.withPropagationContext { delegate.propagationContext = PropagationContext() } + } else { + stagedNavData.deferPropagationContextRotation() + } } - if (!callbackInvoked) { - block(null) + /** + * Starts and binds a nav transaction with the nav lease, or defers it until the lease is + * acquired (see [StagedNavTransaction]). Callers must [finish][finishNavTransaction] the + * previous nav transaction first. + * + * **Deferred transactions may mis-parent spans generated before lease acquisition** + * + * A deferred transaction starts when the lease is acquired, which may be later than when its + * destination starts doing work (e.g., ViewPager2 can precompose offscreen pages, or an + * Activity that navigates from a result callback before it resumes). Spans created in the + * meantime attach to whichever transaction is bound to the scope at that time, usually the + * leaseholder's. + */ + override fun startNavTransaction( + transactionContext: TransactionContext, + transactionOptions: TransactionOptions, + ): INavTransaction? { + return if (hasLease) { + stagedNavTransaction.start(delegate, transactionContext, transactionOptions) + } else { + stagedNavData.deferPropagationContextRotation() + stagedNavTransaction.stage(transactionContext, transactionOptions) + } + } + + override fun finishNavTransaction() { + stagedNavTransaction.finish(delegate) } } } /** - * The subset of the [IScopes] interface used to record navigation data. + * The subset of the [IScopes] interface used to record nav state. * * 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 { +internal sealed interface INavScopes { val options: SentryOptions - /** Runs [callback] with the current [INavScope], if the SDK provides a scope. */ + /** + * Runs [callback] with the current [INavScope]. + * + * No-ops if the SDK doesn't provide a scope (e.g., the SDK is disabled). + */ fun configureScope(callback: (INavScope) -> Unit) + fun addBreadcrumb(breadcrumb: Breadcrumb, hint: Hint) +} + +/** + * The scope operations used to record nav data and nav transactions. + * + * 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. The exception is the nav + * transaction lifecycle ([startNavTransaction] and [finishNavTransaction]), which depends on the + * nav lease and so has no `IScope` equivalent. + */ +internal sealed 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. + */ + val app: App? + + /** + * 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() + /** - * 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]). + * Starts a nav transaction and binds it to the scope, unless another unfinished transaction is + * already bound to it. Returns `null` if no transaction was started. + * + * (Note: Callers must first [finish][finishNavTransaction] the nav transaction most recently + * requested, if any. Otherwise, with the nav lease, it blocks the new one while it's bound; + * without the lease, it's replaced without being finished.) + * + * The start may be deferred until the nav lease is acquired, in which case writes to the returned + * transaction are staged in the meantime, and whether another transaction blocks it is decided + * then (see [StagedNavTransaction.publishTo]). */ - fun startTransaction( + fun startNavTransaction( transactionContext: TransactionContext, transactionOptions: TransactionOptions, - ): ITransaction + ): INavTransaction? - fun addBreadcrumb(breadcrumb: Breadcrumb, hint: Hint) + /** + * Finishes the nav transaction most recently requested via [startNavTransaction] and unbinds it + * from the shared [IScope]. + * + * Alternatively, discards the most recently requested transaction if it's still deferred. + */ + fun finishNavTransaction() } diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavTransaction.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavTransaction.kt new file mode 100644 index 00000000000..c8c1464687c --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavTransaction.kt @@ -0,0 +1,149 @@ +package io.sentry.compose.navigation3.lifecycle + +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.App + +/** + * An [INavTransaction] handle backed by an [ITransaction] that's either started right away or, when + * the nav lease isn't held, [started later][start]. + * + * A deferred handle records what's set on it and replays it onto the transaction once started. + * [Finishing][finish] a deferred handle before it starts discards it, so its transaction never + * starts. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. Apply the thread safety policy from [NavScopes]. + */ +internal class NavTransaction +private constructor( + val name: String, + private var state: State, +) : INavTransaction { + + /** + * The backing transaction, once [started][State.Started]; `null` while [pending][State.Pending] + * or once [discarded][State.Discarded]. + */ + val transaction: ITransaction? + get() = (state as? State.Started)?.transaction + + override fun setData(key: String, value: Any) { + when (val state = state) { + is State.Pending -> state.data[key] = value + is State.Started -> state.transaction.setData(key, value) + State.Discarded -> Unit + } + } + + override fun setContext(key: String, value: Any) { + when (val state = state) { + is State.Pending -> state.contexts[key] = value + is State.Started -> state.transaction.setContext(key, value) + State.Discarded -> Unit + } + } + + override fun setApp(app: App) { + when (val state = state) { + is State.Pending -> state.app = app + is State.Started -> state.transaction.contexts.setApp(app) + State.Discarded -> Unit + } + } + + /** + * Starts a deferred handle's transaction via [scopes] and replays what was set on the handle onto + * it. + * + * Returns the started transaction, or `null` if the handle was started already or discarded, or + * [scopes] declined to start one. + */ + fun start(scopes: IScopes): ITransaction? { + val pending = state as? State.Pending ?: return null + val transaction = + scopes.startTransaction(pending.context, pending.options).takeUnless { it.isNoOp } + ?: return null + + state = State.Started(transaction) + pending.data.forEach { (key, value) -> transaction.setData(key, value) } + pending.contexts.forEach { (key, value) -> transaction.setContext(key, value) } + pending.app?.let { transaction.contexts.setApp(it) } + return transaction + } + + /** + * Finishes the backing transaction with its own status, or [OK][SpanStatus.OK] if it has none, + * and then passes it to [onFinished]. + * + * A deferred handle that hasn't started is discarded instead, so it never starts, and + * [onFinished] isn't called. + */ + fun finish(onFinished: (ITransaction) -> Unit) { + when (val state = state) { + is State.Pending -> this.state = State.Discarded + is State.Started -> { + if (!state.transaction.isFinished) { + state.transaction.finish(state.transaction.status ?: SpanStatus.OK) + } + onFinished(state.transaction) + } + State.Discarded -> Unit + } + } + + /** Lifecycle state for a [NavTransaction] handle. */ + private sealed interface State { + + /** + * Not started yet: the transaction will be started from [context] and [options], and what's set + * on the handle until then is recorded for replay. + */ + class Pending(val context: TransactionContext, val options: TransactionOptions) : State { + val data = linkedMapOf() + val contexts = linkedMapOf() + var app: App? = null + } + + /** The backing [transaction] has been started. A terminal [State]. */ + class Started(val transaction: ITransaction) : State + + /** The handle was finished before its backing transaction was started. A terminal [State]. */ + data object Discarded : State + } + + companion object { + /** A handle backed by the already-started [transaction]. */ + fun started(transaction: ITransaction): NavTransaction = + NavTransaction(transaction.name, State.Started(transaction)) + + /** A handle whose transaction is started later, via [start]. */ + fun deferred(context: TransactionContext, options: TransactionOptions): NavTransaction = + NavTransaction(context.name, State.Pending(context, options)) + } +} + +/** + * The subset of the [ITransaction] interface used to record nav data on a nav transaction. + * + * Protects against accidental misuse of what would otherwise be a partially-implemented + * `ITransaction` interface. New operations against the underlying `ITransaction` instance must be + * added here. Operations not directly delegable to `ITransaction` should ***not*** be added. + * + * Starting and finishing a nav transaction are lease-aware, so they go through [INavScope] instead. + */ +internal sealed interface INavTransaction { + + /** See [ITransaction.setData]. */ + fun setData(key: String, value: Any) + + /** See [ITransaction.setContext]. */ + fun setContext(key: String, value: Any) + + /** Sets the transaction's app context. See [ITransaction.getContexts]. */ + fun setApp(app: App) +} 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 index 7d697812ecf..e003af99061 100644 --- 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 @@ -3,10 +3,32 @@ 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. +See the `NavLeaseCoordinator` 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. + +## Limitations + +Support for multiple `SentryNavEffect`s is restricted to the scenarios documented in the +[Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups). + +`NavLeaseCoordinator` 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 `NavLeaseCoordinator` +manages as navigation animates, rather than as its Activity or Fragment comes and goes. 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 index a32cc74d506..affa1caff61 100644 --- 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 @@ -4,17 +4,22 @@ import io.sentry.IScope import io.sentry.PropagationContext /** - * Holds navigation state staged for publication. + * Holds nav data (i.e., the screen name and nav context) staged for publication to a shared + * [IScope]. * - * 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. + * Only state that occupies a single slot in the shared `IScope` is staged. Nav events (i.e., + * breadcrumbs) are excluded as they only append to the scope 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. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. Apply the thread safety policy from [NavScopes]. */ internal class StagedNavData { @@ -57,8 +62,7 @@ internal class StagedNavData { * 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]. + * screen name while screen tracking is off) survive. */ fun publishTo(realScope: IScope) { if (hasScreenUpdate) { diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavTransaction.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavTransaction.kt new file mode 100644 index 00000000000..2851a10481c --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavTransaction.kt @@ -0,0 +1,176 @@ +package io.sentry.compose.navigation3.lifecycle + +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.SentryLevel +import io.sentry.TransactionContext +import io.sentry.TransactionOptions + +/** + * Holds a nav transaction staged for publication to a shared [IScope]. + * + * A transaction requested while publishing isn't permitted is [staged][stage] rather than started, + * and starts when it's next [published][publishTo]. That publication is its only chance to start: + * if it can't start then, it's discarded. A started transaction that's [unpublished][unpublishFrom] + * is unbound but keeps running, so that it can be bound again if it's republished while unfinished. + * Once [finished][finish], a transaction is forgotten and never bound again. + * + * Starting or publishing a nav transaction never displaces an unfinished transaction bound to the + * scope by someone else (e.g., the host app). + * + * **Thread safety** + * + * This class is ***not*** thread-safe. Apply the thread safety policy from [NavScopes]. + */ +internal class StagedNavTransaction(private val scopes: IScopes) { + + /** + * The nav transaction most recently started or staged, unless it has been finished since then. + */ + private var navTransaction: NavTransaction? = null + + /** + * Starts a nav transaction from [context] and [options] and binds it to [scope], unless another + * unfinished transaction is already bound to `scope`, in which case none is started, as it + * couldn't be bound. Returns `null` if no transaction was started (including when [scopes] + * returned a no-op transaction, e.g., because the SDK is disabled or ignores the transaction's + * origin), in which case the instance's current nav transaction, if any, is kept. + * + * **Warning**: [Finish][finish] the current nav transaction before calling this method, as this + * method replaces the current transaction without finishing it. + */ + fun start( + scope: IScope, + context: TransactionContext, + options: TransactionOptions, + ): NavTransaction? { + if (!scope.isFreeFor(context.name, transaction = null)) { + return null + } + val transaction = + scopes.startTransaction(context, options).takeUnless { it.isNoOp } ?: return null + + // Record the transaction before binding it: binding notifies the host app's scope observers, + // which can throw, and an unrecorded transaction might never be finished. + val started = NavTransaction.started(transaction) + navTransaction = started + + bind(scope, transaction) + return started + } + + /** + * Stages a nav transaction that's started from [context] and [options] when it's + * [published][publishTo], and returns its handle, which records what's set on it in the meantime. + * + * **Warning**: [Finish][finish] the current nav transaction before calling this method, as this + * method replaces the current transaction without finishing it. + */ + fun stage(context: TransactionContext, options: TransactionOptions): NavTransaction = + NavTransaction.deferred(context, options).also { navTransaction = it } + + /** + * Binds the nav transaction to [scope], starting it first if it's staged. A started transaction + * that has finished in the meantime is forgotten and unbound instead. + * + * If another unfinished transaction is bound to [scope], a started transaction stays unbound, so + * it can be bound the next time it's published. A staged transaction is discarded instead, as it + * is if [scopes] return a no-op transaction for it (see [start]). + */ + fun publishTo(scope: IScope) { + val navTransaction = navTransaction ?: return + val started = navTransaction.transaction + when { + started?.isFinished == true -> { + this.navTransaction = null + unbind(scope, started) + } + started != null -> if (scope.isFreeFor(navTransaction.name, started)) bind(scope, started) + else -> { + val startedNow = + if (scope.isFreeFor(navTransaction.name, transaction = null)) { + navTransaction.start(scopes) + } else { + null + } + if (startedNow != null) { + bind(scope, startedNow) + } else { + this.navTransaction = null + navTransaction.finish(onFinished = {}) + } + } + } + } + + /** + * Unbinds the nav transaction from [scope], if it's bound, without finishing it, so it can be + * [published][publishTo] again. + */ + fun unpublishFrom(scope: IScope) { + navTransaction?.transaction?.let { unbind(scope, it) } + } + + /** + * Finishes the nav transaction and unbinds it from [scope], if it's bound. A staged transaction + * is discarded instead. + */ + fun finish(scope: IScope) { + val navTransaction = navTransaction ?: return + // Forget the transaction before unbinding it: unbinding notifies the host app's scope + // observers, which can throw, and a transaction left behind would be bound again later. + this.navTransaction = null + navTransaction.finish(onFinished = { unbind(scope, it) }) + } + + /** + * Forgets the nav transaction without finishing it, e.g., when there's no scope to unbind it + * from. If started, it will be left to time out. + */ + fun forget() { + navTransaction = null + } + + /** + * Reports whether the receiver's transaction slot can hold [transaction], or a new transaction + * given `null` (see [canHold]). + */ + private fun IScope.isFreeFor(name: String, transaction: ITransaction?): Boolean { + if (canHold(current = this.transaction, transaction)) { + return true + } + + scopes.options.logger.log( + SentryLevel.DEBUG, + "Nav3 transaction for route %s is blocked by another active transaction.", + name, + ) + return false + } + + /** Binds [transaction] to [scope] if the scope's transaction slot can hold it (see [canHold]). */ + private fun bind(scope: IScope, transaction: ITransaction) { + scope.withTransaction { current -> + if (current !== transaction && canHold(current, transaction)) { + scope.transaction = transaction + } + } + } + + /** + * Whether a transaction slot holding [current] can hold [transaction], or a new transaction given + * `null`: it's empty, holds a finished transaction, or already holds `transaction`. + */ + private fun canHold(current: ITransaction?, transaction: ITransaction?): Boolean = + current == null || current.isFinished || current === transaction + + /** Unbinds [transaction] from [scope] if it's the bound transaction. */ + private fun unbind(scope: IScope, transaction: ITransaction) { + scope.withTransaction { current -> + if (current === transaction) { + scope.clearTransaction() + } + } + } +} 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 index 08c2003fbbf..cf653c23e31 100644 --- 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 @@ -4,13 +4,18 @@ 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.IScope import io.sentry.IScopes +import io.sentry.ITransaction 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.LeasedNavState import io.sentry.compose.navigation3.lifecycle.NAVIGATION_CONTEXT_KEY import io.sentry.compose.navigation3.lifecycle.NavLeaseCoordinator import org.junit.Test @@ -51,8 +56,210 @@ class CoordinatedBackStackObserverTest { 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. + // "/Result" was reached without the nav lease, so its nav transaction starts when the owner + // regains the lease, rather than the one retired by navigating to it being rebound. + assertThat(fixture.scope.transaction).isNotSameInstanceAs(homeTransaction) + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/Result")) + } + + @Test + fun `a page swiped to starts its nav transaction when it resumes`() { + val fixture = Fixture() + val pageALifecycle = fixture.resumedLifecycle() + val pageBLifecycle = fixture.resumedLifecycle().apply { currentState = Lifecycle.State.STARTED } + val pageA = fixture.getSut(pageALifecycle) + val pageB = fixture.getSut(pageBLifecycle, arguments = { mapOf("page" to it) }) + pageA.onBackStackChanged(listOf("PageA")) + // Page B is preloaded while page A is resumed, so it doesn't get the nav lease. + pageB.onBackStackChanged(listOf("PageB")) + check(fixture.startedTransactions.map { it.name } == listOf("/PageA")) + + // The swipe settles: ViewPager2 resumes page B and pauses page A. + pageBLifecycle.currentState = Lifecycle.State.RESUMED + pageALifecycle.currentState = Lifecycle.State.STARTED + + val pageBTransaction = fixture.startedTransaction("/PageB") + assertThat(fixture.scope.transaction).isSameInstanceAs(pageBTransaction) + assertThat(pageBTransaction.getData("arguments")).isEqualTo(mapOf("page" to "PageB")) + assertThat(pageBTransaction.contexts.app?.viewNames).containsExactly("/PageB") + assertThat(pageBTransaction.contexts[NAVIGATION_CONTEXT_KEY]) + .isEqualTo( + mapOf( + "Back Stack" to + listOf(mapOf("entry" to "/PageB", "entry_arguments" to mapOf("page" to "PageB"))) + ) + ) + assertThat(fixture.scope.screen).isEqualTo("/PageB") + } + + @Test + fun `a page swiped to starts its nav transaction when the previous page pauses before it resumes`() { + val fixture = Fixture() + val pageALifecycle = fixture.resumedLifecycle() + val pageBLifecycle = fixture.resumedLifecycle().apply { currentState = Lifecycle.State.STARTED } + val pageA = fixture.getSut(pageALifecycle) + val pageB = fixture.getSut(pageBLifecycle) + pageA.onBackStackChanged(listOf("PageA")) + pageB.onBackStackChanged(listOf("PageB")) + + pageALifecycle.currentState = Lifecycle.State.STARTED + pageBLifecycle.currentState = Lifecycle.State.RESUMED + + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/PageB")) + assertThat(fixture.scope.screen).isEqualTo("/PageB") + } + + @Test + fun `a page swiped to records no breadcrumb beyond its first back stack update when it resumes`() { + val fixture = Fixture() + val pageALifecycle = fixture.resumedLifecycle() + val pageBLifecycle = fixture.resumedLifecycle().apply { currentState = Lifecycle.State.STARTED } + val pageA = fixture.getSut(pageALifecycle) + val pageB = fixture.getSut(pageBLifecycle) + pageA.onBackStackChanged(listOf("PageA")) + pageB.onBackStackChanged(listOf("PageB")) + + pageBLifecycle.currentState = Lifecycle.State.RESUMED + pageALifecycle.currentState = Lifecycle.State.STARTED + + assertThat(fixture.breadcrumbs.map { it.data["to"] }) + .containsExactly("/PageA", "/PageB") + .inOrder() + } + + @Test + fun `a page preloaded during a host transaction starts its nav transaction when swiped to after it finishes`() { + val fixture = Fixture() + val pageALifecycle = fixture.resumedLifecycle() + val pageBLifecycle = fixture.resumedLifecycle().apply { currentState = Lifecycle.State.STARTED } + val pageA = fixture.getSut(pageALifecycle) + val pageB = fixture.getSut(pageBLifecycle) + // E.g., the Activity's ui.load transaction, still running while the pager's pages load. + val hostTransaction = mock() + fixture.scope.transaction = hostTransaction + pageA.onBackStackChanged(listOf("PageA")) + pageB.onBackStackChanged(listOf("PageB")) + whenever(hostTransaction.isFinished).thenReturn(true) + + pageBLifecycle.currentState = Lifecycle.State.RESUMED + pageALifecycle.currentState = Lifecycle.State.STARTED + + assertThat(fixture.startedTransactions.map { it.name }).containsExactly("/PageB") + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/PageB")) + } + + @Test + fun `a page swiped to while a host transaction is bound starts no nav transaction when swiped to again`() { + val fixture = Fixture() + val pageALifecycle = fixture.resumedLifecycle() + val pageBLifecycle = fixture.resumedLifecycle().apply { currentState = Lifecycle.State.STARTED } + val pageA = fixture.getSut(pageALifecycle) + val pageB = fixture.getSut(pageBLifecycle) + val hostTransaction = mock() + fixture.scope.transaction = hostTransaction + pageA.onBackStackChanged(listOf("PageA")) + pageB.onBackStackChanged(listOf("PageB")) + pageBLifecycle.currentState = Lifecycle.State.RESUMED + pageALifecycle.currentState = Lifecycle.State.STARTED + whenever(hostTransaction.isFinished).thenReturn(true) + + // Swipe back to page A, then to page B again. + pageALifecycle.currentState = Lifecycle.State.RESUMED + pageBLifecycle.currentState = Lifecycle.State.STARTED + pageBLifecycle.currentState = Lifecycle.State.RESUMED + pageALifecycle.currentState = Lifecycle.State.STARTED + + // As with page A, which navigated while the host transaction was bound, page B's destination + // gets no nav transaction: it couldn't start one when it first got the nav lease. + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.screen).isEqualTo("/PageB") + } + + @Test + fun `a page that navigates while offscreen starts only its latest destination's nav transaction when swiped to`() { + val fixture = Fixture() + val pageALifecycle = fixture.resumedLifecycle() + val pageBLifecycle = fixture.resumedLifecycle().apply { currentState = Lifecycle.State.STARTED } + val pageA = fixture.getSut(pageALifecycle) + val pageB = fixture.getSut(pageBLifecycle) + pageA.onBackStackChanged(listOf("PageA")) + pageB.onBackStackChanged(listOf("PageB")) + pageB.onBackStackChanged(listOf("PageB", "Detail")) + + pageBLifecycle.currentState = Lifecycle.State.RESUMED + pageALifecycle.currentState = Lifecycle.State.STARTED + + assertThat(fixture.startedTransactions.map { it.name }) + .containsExactly("/PageA", "/Detail") + .inOrder() + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/Detail")) + } + + @Test + fun `swiping back to a page restores its unfinished nav transaction instead of starting another`() { + val fixture = Fixture() + val pageALifecycle = fixture.resumedLifecycle() + val pageBLifecycle = fixture.resumedLifecycle().apply { currentState = Lifecycle.State.STARTED } + val pageA = fixture.getSut(pageALifecycle) + val pageB = fixture.getSut(pageBLifecycle) + pageA.onBackStackChanged(listOf("PageA")) + val pageATransaction = fixture.startedTransaction("/PageA") + // A pending child span keeps the nav transaction, which waits for its children, unfinished. + pageATransaction.startChild("http.client") + pageB.onBackStackChanged(listOf("PageB")) + pageBLifecycle.currentState = Lifecycle.State.RESUMED + pageALifecycle.currentState = Lifecycle.State.STARTED + + pageALifecycle.currentState = Lifecycle.State.RESUMED + pageBLifecycle.currentState = Lifecycle.State.STARTED + + assertThat(fixture.scope.transaction).isSameInstanceAs(pageATransaction) + assertThat(fixture.startedTransactions.map { it.name }) + .containsExactly("/PageA", "/PageB") + .inOrder() + } + + @Test + fun `swiping back to a page whose nav transaction finished starts no nav transaction`() { + val fixture = Fixture() + val pageALifecycle = fixture.resumedLifecycle() + val pageBLifecycle = fixture.resumedLifecycle().apply { currentState = Lifecycle.State.STARTED } + val pageA = fixture.getSut(pageALifecycle) + val pageB = fixture.getSut(pageBLifecycle) + pageA.onBackStackChanged(listOf("PageA")) + val pageATransaction = fixture.startedTransaction("/PageA") + pageB.onBackStackChanged(listOf("PageB")) + pageBLifecycle.currentState = Lifecycle.State.RESUMED + pageALifecycle.currentState = Lifecycle.State.STARTED + pageATransaction.finish() + + pageALifecycle.currentState = Lifecycle.State.RESUMED + pageBLifecycle.currentState = Lifecycle.State.STARTED + + assertThat(fixture.startedTransactions.map { it.name }) + .containsExactly("/PageA", "/PageB") + .inOrder() assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isEqualTo("/PageA") + } + + @Test + fun `with nav transactions disabled, a page swiped to starts no nav transaction`() { + val fixture = Fixture() + val options = SentryNavOptions { enableNavigationTransactions = false } + val pageALifecycle = fixture.resumedLifecycle() + val pageBLifecycle = fixture.resumedLifecycle().apply { currentState = Lifecycle.State.STARTED } + val pageA = fixture.getSut(pageALifecycle, options) + val pageB = fixture.getSut(pageBLifecycle, options) + pageA.onBackStackChanged(listOf("PageA")) + pageB.onBackStackChanged(listOf("PageB")) + + pageBLifecycle.currentState = Lifecycle.State.RESUMED + pageALifecycle.currentState = Lifecycle.State.STARTED + + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isEqualTo("/PageB") } @Test @@ -120,9 +327,16 @@ class CoordinatedBackStackObserverTest { ) val coordinator = NavLeaseCoordinator() val startedTransactions = mutableListOf() + val breadcrumbs = mutableListOf() private val scopes = mock().also { scopes -> whenever(scopes.options).thenReturn(scope.options) + doAnswer { + breadcrumbs += it.arguments[0] as Breadcrumb + null + } + .whenever(scopes) + .addBreadcrumb(any(), any()) doAnswer { (it.arguments[0] as ScopeCallback).run(scope) null @@ -145,30 +359,50 @@ class CoordinatedBackStackObserverTest { /** 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)) + val otherLifecycle = resumedLifecycle() + val other = checkNotNull(coordinator.register(otherLifecycle, scopes, NoOpLeasedNavState)) + other.onBackStackUpdate() + otherLifecycle.currentState = Lifecycle.State.STARTED + check(other.hasLease) } - fun getSut(lifecycle: Lifecycle = resumedLifecycle()): CoordinatedBackStackObserver = + fun getSut( + lifecycle: Lifecycle = resumedLifecycle(), + options: SentryNavOptions = SentryNavOptions(), + arguments: (String) -> Map? = { null }, + ): CoordinatedBackStackObserver = CoordinatedBackStackObserver( scopes = scopes, - options = SentryNavOptions(), + options = options, entryMapper = ForwardingBackStackEntryMapper { - BackStackEntryMapper { SentryBackStackEntry(it) } + BackStackEntryMapper { SentryBackStackEntry(it, arguments(it)) } }, coordinator = coordinator, ) .apply { attach(lifecycle) } + /** + * Owners of the lifecycles created by [resumedLifecycle]. `LifecycleRegistry` holds its owner + * weakly and throws once it's collected, so the fixture keeps them alive for the test. + */ + private val lifecycleOwners = mutableListOf() + fun resumedLifecycle(): LifecycleRegistry = object : LifecycleOwner { override val lifecycle = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } } + .also(lifecycleOwners::add) .lifecycle } + + /** Nav state for a competing owner that has nothing to publish. */ + private object NoOpLeasedNavState : LeasedNavState { + override fun publishTo(scope: IScope) = Unit + + override fun unpublishFrom(scope: IScope) = Unit + + override fun onUnregistered(scope: IScope?) = Unit + } } 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 index 7034ba29f68..d5467b10b9a 100644 --- 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 @@ -407,19 +407,55 @@ class SentryNavEffectLifecycleIntegrationTest { } @Test - fun `switching to a preloaded view pager page publishes a new propagation context instead of a nav transaction`() { + fun `switching to a preloaded view pager page starts its nav transaction with a new propagation context`() { val fixture = Fixture.create() val activity = Robolectric.buildActivity(ViewPagerActivity::class.java).setup() fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) val propagationContext = fixture.scope.propagationContext activity.get().viewPager.setCurrentItem(1, false) fixture.idle() - assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.transaction?.name).isEqualTo("/Checkout") + assertThat(fixture.scope.transaction).isNotSameInstanceAs(homeTransaction) assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(propagationContext) } + @Test + fun `switching back to a view pager page restores its unfinished nav transaction`() { + val fixture = Fixture.create() + val activity = Robolectric.buildActivity(ViewPagerActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + activity.get().viewPager.setCurrentItem(1, false) + fixture.idle() + check(!homeTransaction.isFinished) + + activity.get().viewPager.setCurrentItem(0, false) + fixture.idle() + + assertThat(fixture.scope.transaction).isSameInstanceAs(homeTransaction) + assertThat(fixture.scope.screen).isEqualTo("/Home") + } + + @Test + fun `switching back to a view pager page whose nav transaction finished starts no nav transaction`() { + val fixture = Fixture.create() + val activity = Robolectric.buildActivity(ViewPagerActivity::class.java).setup() + fixture.idle() + val homeTransaction = requireNotNull(fixture.scope.transaction) + activity.get().viewPager.setCurrentItem(1, false) + fixture.idle() + homeTransaction.finish() + + activity.get().viewPager.setCurrentItem(0, false) + fixture.idle() + + assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.screen).isEqualTo("/Home") + } + // Dialogs @Test fun `a dialog fragment shown over a fragment publishes its own nav data while the fragment stays resumed`() { 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 32536d28ccf..0ce82b832a6 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 @@ -629,7 +629,7 @@ class SentryNavEffectTest { } @Test - fun `navigating without the lease records breadcrumbs, then publishes with a fresh trace on resume`() { + fun `navigating without the lease records breadcrumbs, then starts a nav transaction with a fresh trace on resume`() { val fixture = NavTestFixture() val ownerA = NavTestLifecycle(Lifecycle.State.RESUMED) val ownerB = NavTestLifecycle() @@ -674,10 +674,10 @@ class SentryNavEffectTest { 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. + // A's nav transaction for /Result starts once it regains the lease: one started without the + // lease would have collected no spans of its own. composeRule.runOnIdle { - assertThat(fixture.scope.transaction).isNull() + assertThat(fixture.scope.transaction?.name).isEqualTo("/Result") assertThat(fixture.scope.screen).isEqualTo("/Result") assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(contextBeforeResume) } 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 index 44986848b5b..9a521312f68 100644 --- 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 @@ -1,868 +1,931 @@ package io.sentry.compose.navigation3.lifecycle import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.Lifecycle.State.STARTED +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry import com.google.common.truth.Truth.assertThat import io.sentry.IScope -import io.sentry.ITransaction +import io.sentry.IScopes import io.sentry.Scope +import io.sentry.ScopeCallback 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.doAnswer 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() + // region registration @Test - fun `a registered created owner acquires the nav lease on its first back stack update`() { + fun `register() - returns no registration for a destroyed lifecycle and doesn't observe it`() { val fixture = Fixture.create() val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.register(owner, Lifecycle.State.CREATED) + val owner = TestOwner(DESTROYED) - navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + val registration = + navLeaseCoordinator.register(owner.lifecycle, fixture.scopes, fixture.navState("owner")) - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(fixture.published).containsExactly(owner) + assertThat(registration).isNull() + assertThat(owner.lifecycle.observerCount).isEqualTo(0) } @Test - fun `registering the same owner twice preserves its first publication callback`() { + fun `separate registrations for the same lifecycle don't share the nav lease`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.register(owner, Lifecycle.State.CREATED) + val owner = TestOwner(RESUMED) + val first = fixture.acquireLease("first", owner) - navLeaseCoordinator.registerCandidate( - owner, - Lifecycle.State.RESUMED, - { fixture.published += "duplicate" }, - { fixture.cleared += "duplicate" }, - ) - navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + val second = fixture.acquireLease("second", owner) - assertThat(fixture.published).containsExactly(owner) + assertThat(first.hasLease).isFalse() + assertThat(second.hasLease).isTrue() } - @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) + // endregion - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() - assertThat(fixture.published).isEmpty() - } + // region acquiring the nav lease on the first back stack update @Test - fun `equal but distinct owners do not share the nav lease`() { + fun `a created owner acquires the nav lease on its first back stack update`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - val registeredOwner = String(charArrayOf('a')) - val equalOwner = String(charArrayOf('a')) - fixture.acquireLease(registeredOwner) + val registration = fixture.register("owner", TestOwner(CREATED)) - val result = navLeaseCoordinator.hasLease(equalOwner) + registration.onBackStackUpdate() - assertThat(result).isFalse() - assertThat(navLeaseCoordinator.hasLease(registeredOwner)).isTrue() + assertThat(registration.hasLease).isTrue() + assertThat(fixture.published).containsExactly("owner") } @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) + val owner = TestOwner(CREATED) + val registration = fixture.register("owner", owner) - navLeaseCoordinator.startCandidate(owner) - navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + owner.lifecycle.currentState = STARTED + registration.onBackStackUpdate() - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(registration.hasLease).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) + val owner = TestOwner(STARTED) + val registration = fixture.register("owner", owner) + owner.lifecycle.currentState = CREATED + val incumbentOwner = TestOwner(RESUMED) + val incumbent = fixture.acquireLease("incumbent", incumbentOwner) + // The incumbent keeps the lease, but no longer blocks a first update from taking it. + incumbentOwner.lifecycle.currentState = STARTED - navLeaseCoordinator.startCandidate(owner) - navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + owner.lifecycle.currentState = STARTED + registration.onBackStackUpdate() - assertThat(navLeaseCoordinator.hasLease(incumbent)).isTrue() - assertThat(fixture.published).containsExactly(incumbent) + assertThat(incumbent.hasLease).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) + val owner = TestOwner(CREATED) + val registration = fixture.register("owner", owner) - navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + owner.lifecycle.currentState = RESUMED - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(registration.hasLease).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) + fixture.acquireLease("incumbent", TestOwner(RESUMED)) + val owner = TestOwner(STARTED) + val registration = fixture.register("owner", owner) + owner.lifecycle.currentState = CREATED + registration.onBackStackUpdate() - navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + owner.lifecycle.currentState = RESUMED - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(fixture.published).containsExactly(incumbent, owner).inOrder() + assertThat(registration.hasLease).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) + val owner = TestOwner(RESUMED) + val registration = fixture.register("owner", owner) + owner.lifecycle.currentState = STARTED - navLeaseCoordinator.onBackStackUpdate(owner, fixture.scope) + registration.onBackStackUpdate() - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(registration.hasLease).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) + val incumbent = fixture.acquireLease("incumbent", TestOwner(RESUMED)) - fixture.acquireLease(owner, state = Lifecycle.State.STARTED) + fixture.acquireLease("owner", TestOwner(STARTED)) - assertThat(navLeaseCoordinator.hasLease(incumbent)).isTrue() - assertThat(fixture.published).containsExactly(incumbent) + assertThat(incumbent.hasLease).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) + fixture.acquireLease("incumbent", TestOwner(RESUMED)) + val owner = TestOwner(STARTED) + val registration = fixture.acquireLease("owner", owner) + + owner.lifecycle.currentState = RESUMED + + assertThat(registration.hasLease).isTrue() + assertThat(fixture.published).containsExactly("incumbent", "owner").inOrder() + } + + /** + * `NavScopes` relies on this: it starts a nav transaction deferred without the lease when the + * lease is acquired, which must not coincide with a back stack update that starts a transaction + * of its own. + */ + @Test + fun `an owner kept from the nav lease on its first back stack update doesn't take it on a later one`() { + val fixture = Fixture.create() + val incumbentOwner = TestOwner(RESUMED) + val incumbent = fixture.acquireLease("incumbent", incumbentOwner) + val registration = fixture.acquireLease("owner", TestOwner(STARTED)) + // The incumbent keeps the lease, but no longer blocks a first update from taking it. + incumbentOwner.lifecycle.currentState = STARTED - navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + registration.onBackStackUpdate() - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(fixture.published).containsExactly(incumbent, owner).inOrder() + assertThat(incumbent.hasLease).isTrue() + assertThat(fixture.published).containsExactly("incumbent") } @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) + val incumbentOwner = TestOwner(RESUMED) + fixture.acquireLease("incumbent", incumbentOwner) + incumbentOwner.lifecycle.currentState = STARTED - fixture.acquireLease(owner, state = Lifecycle.State.CREATED) + val registration = fixture.acquireLease("owner", TestOwner(CREATED)) - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(registration.hasLease).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) + val incumbentOwner = TestOwner(RESUMED) + fixture.acquireLease("incumbent", incumbentOwner) + incumbentOwner.lifecycle.currentState = CREATED - fixture.acquireLease(owner, state = Lifecycle.State.CREATED) + val registration = fixture.acquireLease("owner", TestOwner(CREATED)) - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(fixture.published).containsExactly(incumbent, owner).inOrder() + assertThat(registration.hasLease).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("incumbent", TestOwner(RESUMED)) - fixture.acquireLease(owner) + val registration = fixture.acquireLease("owner", TestOwner(RESUMED)) - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(registration.hasLease).isTrue() } @Test - fun `acquiring the nav lease clears the previous owner's published nav data before publishing`() { + fun `acquiring the nav lease unpublishes the previous publisher's nav state before publishing its own`() { 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" }, + fixture.acquireLease( + "previous", + TestOwner(RESUMED), + navState = + fixture.navState( + "previous", + onPublishTo = { calls += "publish previous" }, + onUnpublishFrom = { calls += "unpublish previous" }, + ), ) + val incoming = + fixture.register( + "incoming", + TestOwner(RESUMED), + fixture.navState( + "incoming", + onPublishTo = { calls += "publish incoming" }, + onUnpublishFrom = { calls += "unpublish incoming" }, + ), + ) - navLeaseCoordinator.onBackStackUpdate(incoming, fixture.scope) + incoming.onBackStackUpdate() assertThat(calls) - .containsExactly("publish previous", "clear previous", "publish incoming") + .containsExactly("publish previous", "unpublish previous", "publish incoming") .inOrder() } @Test - fun `regaining the nav lease doesn't clear the owner's own published nav data`() { + fun `regaining the nav lease doesn't unpublish the owner's own published nav state`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(previous) - fixture.acquireLease(owner) - navLeaseCoordinator.pauseCandidate(owner, null) - fixture.cleared.clear() + fixture.acquireLease("previous", TestOwner(RESUMED)) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) + fixture.withoutScope { owner.lifecycle.currentState = STARTED } + fixture.unpublished.clear() - navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + owner.lifecycle.currentState = RESUMED - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(fixture.published).containsExactly(previous, owner, owner).inOrder() - assertThat(fixture.cleared).isEmpty() + assertThat(registration.hasLease).isTrue() + assertThat(fixture.published).containsExactly("previous", "owner", "owner").inOrder() + assertThat(fixture.unpublished).isEmpty() } @Test - fun `releasing the nav lease leaves the owner's published nav data in place`() { + fun `releasing the nav lease leaves the owner's published nav state in place`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(previous) - fixture.acquireLease(owner) - fixture.cleared.clear() + fixture.acquireLease("previous", TestOwner(RESUMED)) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) + fixture.unpublished.clear() - navLeaseCoordinator.pauseCandidate(owner, null) + fixture.withoutScope { owner.lifecycle.currentState = STARTED } - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() - assertThat(fixture.cleared).isEmpty() + assertThat(registration.hasLease).isFalse() + assertThat(fixture.unpublished).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. + * The coordinator records the incoming owner as the publisher before calling into the scope, so + * unpublishing that throws mid-handoff doesn't strand the incoming owner's nav state: the next + * handoff still unpublishes it. */ @Test - fun `if a clear attempt throws during a handoff, the next handoff clears the new leaseholder's nav data`() { + fun `if unpublishing throws during a handoff, the next handoff unpublishes the new leaseholder's nav state`() { 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 + val failure = IllegalStateException("unpublish failed") fixture.acquireLease( - firstOwner, - onClearPublishedData = { - if (failClear) { - failClear = false - throw failure - } - fixture.cleared += firstOwner - }, + "first", + TestOwner(RESUMED), + navState = fixture.navState("first", onUnpublishFrom = { throw failure }), ) - fixture.register(secondOwner, Lifecycle.State.RESUMED) + val second = fixture.register("second", TestOwner(RESUMED)) - val thrown = - assertThrows(IllegalStateException::class.java) { - navLeaseCoordinator.onBackStackUpdate(secondOwner, fixture.scope) - } + val thrown = assertThrows(IllegalStateException::class.java) { second.onBackStackUpdate() } assertThat(thrown).isSameInstanceAs(failure) - assertThat(navLeaseCoordinator.hasLease(secondOwner)).isTrue() + assertThat(second.hasLease).isTrue() - fixture.acquireLease(thirdOwner) + fixture.acquireLease("third", TestOwner(RESUMED)) - assertThat(fixture.cleared).containsExactly(secondOwner) + assertThat(fixture.unpublished).containsExactly("second") } @Test - fun `later back stack updates do not republish nav data or reclaim the nav lease`() { + fun `later back stack updates do not republish nav state 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) + val first = fixture.acquireLease("first", TestOwner(RESUMED)) + val second = fixture.acquireLease("second", TestOwner(RESUMED)) + + first.onBackStackUpdate() + + assertThat(second.hasLease).isTrue() + assertThat(fixture.published).containsExactly("first", "second").inOrder() + } + + @Test + fun `a back stack update skipped by configureScope doesn't acquire the nav lease`() { + val fixture = Fixture.create() + val registration = fixture.register("owner", TestOwner(CREATED)) + + fixture.withoutScope { registration.onBackStackUpdate() } + + assertThat(registration.hasLease).isFalse() + assertThat(fixture.published).isEmpty() + } + + @Test + fun `a back stack update skipped by configureScope doesn't count as the first back stack update`() { + val fixture = Fixture.create() + val registration = fixture.register("owner", TestOwner(CREATED)) + fixture.withoutScope { registration.onBackStackUpdate() } - navLeaseCoordinator.onBackStackUpdate(first, fixture.scope) + registration.onBackStackUpdate() - assertThat(navLeaseCoordinator.hasLease(second)).isTrue() - assertThat(fixture.published).containsExactly(first, second).inOrder() + assertThat(registration.hasLease).isTrue() + assertThat(fixture.published).containsExactly("owner") } + // endregion + + // region pausing + @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) + fixture.acquireLease("older", TestOwner(RESUMED)) + val recent = fixture.acquireLease("recent", TestOwner(RESUMED)) + val incomingOwner = TestOwner(RESUMED) + fixture.acquireLease("incoming", incomingOwner) - navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + incomingOwner.lifecycle.currentState = STARTED - assertThat(navLeaseCoordinator.hasLease(recent)).isTrue() - assertThat(fixture.published.last()).isSameInstanceAs(recent) + assertThat(recent.hasLease).isTrue() + assertThat(fixture.published.last()).isEqualTo("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) + val owner = TestOwner(RESUMED) + fixture.acquireLease("owner", owner) + owner.lifecycle.currentState = STARTED + val incomingOwner = TestOwner(RESUMED) + val incoming = fixture.acquireLease("incoming", incomingOwner) - navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + incomingOwner.lifecycle.currentState = STARTED - assertThat(navLeaseCoordinator.hasLease(incoming)).isTrue() - assertThat(fixture.published).containsExactly(owner, incoming).inOrder() + assertThat(incoming.hasLease).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) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) + owner.lifecycle.currentState = STARTED + val incomingOwner = TestOwner(RESUMED) + fixture.acquireLease("incoming", incomingOwner) + incomingOwner.lifecycle.currentState = STARTED - navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + owner.lifecycle.currentState = RESUMED - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(fixture.published).containsExactly(owner, incoming, owner).inOrder() + assertThat(registration.hasLease).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) + val eligible = fixture.acquireLease("eligible", TestOwner(RESUMED)) + val uninitialized = fixture.register("uninitialized", TestOwner(RESUMED)) + val incomingOwner = TestOwner(RESUMED) + fixture.acquireLease("incoming", incomingOwner) - navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + incomingOwner.lifecycle.currentState = STARTED - assertThat(navLeaseCoordinator.hasLease(eligible)).isTrue() - assertThat(navLeaseCoordinator.hasLease(uninitialized)).isFalse() + assertThat(eligible.hasLease).isTrue() + assertThat(uninitialized.hasLease).isFalse() } @Test fun `pausing the only eligible owner keeps the nav lease`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(owner) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) - navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + owner.lifecycle.currentState = STARTED - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(fixture.published).containsExactly(owner) + assertThat(registration.hasLease).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) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) - navLeaseCoordinator.pauseCandidate(owner, null) + fixture.withoutScope { owner.lifecycle.currentState = STARTED } - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(registration.hasLease).isTrue() } @Test - fun `resuming a paused leaseholder does not republish its nav data`() { + fun `resuming a paused leaseholder does not republish its nav state`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(owner) - navLeaseCoordinator.pauseCandidate(owner, fixture.scope) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) + owner.lifecycle.currentState = STARTED - navLeaseCoordinator.resumeCandidate(owner, fixture.scope) + owner.lifecycle.currentState = RESUMED - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(fixture.published).containsExactly(owner) + assertThat(registration.hasLease).isTrue() + assertThat(fixture.published).containsExactly("owner") } + // endregion + + // region stopping + + /** + * A real lifecycle pauses before it stops, so the leaseholder here is an owner that acquired the + * lease without ever resuming, and the other owner resumes without a scope, which records the + * resume without restoring its lease. + */ @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) + val previousOwner = TestOwner(RESUMED) + val previous = fixture.acquireLease("previous", previousOwner) + previousOwner.lifecycle.currentState = STARTED + val owner = TestOwner(STARTED) + fixture.acquireLease("owner", owner) + fixture.withoutScope { previousOwner.lifecycle.currentState = RESUMED } - navLeaseCoordinator.stopCandidate(owner, fixture.scope) + owner.lifecycle.currentState = CREATED - assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() - assertThat(fixture.published.last()).isSameInstanceAs(previous) + assertThat(previous.hasLease).isTrue() + assertThat(fixture.published.last()).isEqualTo("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) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) + owner.lifecycle.currentState = STARTED - navLeaseCoordinator.stopCandidate(owner, fixture.scope) + owner.lifecycle.currentState = CREATED - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(fixture.published).containsExactly(owner) + assertThat(registration.hasLease).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) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) + fixture.withoutScope { owner.lifecycle.currentState = STARTED } - navLeaseCoordinator.stopCandidate(owner, null) + fixture.withoutScope { owner.lifecycle.currentState = CREATED } - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(registration.hasLease).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) + val previousOwner = TestOwner(RESUMED) + val previous = fixture.acquireLease("previous", previousOwner) + previousOwner.lifecycle.currentState = STARTED + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) + owner.lifecycle.currentState = STARTED - navLeaseCoordinator.stopCandidate(owner, fixture.scope) + owner.lifecycle.currentState = CREATED - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() - assertThat(fixture.published).containsExactly(previous, owner).inOrder() - assertThat(fixture.cleared).containsExactly(previous) + assertThat(registration.hasLease).isTrue() + assertThat(previous.hasLease).isFalse() + assertThat(fixture.published).containsExactly("previous", "owner").inOrder() + assertThat(fixture.unpublished).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) + val previousOwner = TestOwner(RESUMED) + val previous = fixture.acquireLease("previous", previousOwner) + previousOwner.lifecycle.currentState = STARTED + val owner = TestOwner(RESUMED) + fixture.acquireLease("owner", owner) + owner.lifecycle.currentState = CREATED - navLeaseCoordinator.resumeCandidate(previous, fixture.scope) + previousOwner.lifecycle.currentState = RESUMED - assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() - assertThat(fixture.published).containsExactly(previous, owner, previous).inOrder() + assertThat(previous.hasLease).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) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) + owner.lifecycle.currentState = CREATED - navLeaseCoordinator.startCandidate(owner) + owner.lifecycle.currentState = STARTED - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(fixture.published).containsExactly(owner) + assertThat(registration.hasLease).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) + val previousOwner = TestOwner(RESUMED) + val previous = fixture.acquireLease("previous", previousOwner) + val registration = fixture.acquireLease("owner", TestOwner(RESUMED)) + previousOwner.lifecycle.currentState = CREATED - navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + registration.dispose() - assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() - assertThat(fixture.published).containsExactly(previous, owner).inOrder() + assertThat(previous.hasLease).isFalse() + assertThat(fixture.published).containsExactly("previous", "owner").inOrder() } + // endregion + + // region lifecycle events without a scope + @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) + val owner = TestOwner(CREATED) + val registration = fixture.acquireLease("owner", owner) + val incomingOwner = TestOwner(RESUMED) + fixture.acquireLease("incoming", incomingOwner) + fixture.withoutScope { owner.lifecycle.currentState = RESUMED } - navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + incomingOwner.lifecycle.currentState = STARTED - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() + assertThat(registration.hasLease).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) + val incumbent = fixture.acquireLease("incumbent", TestOwner(RESUMED)) + val owner = TestOwner(STARTED) + val registration = fixture.register("owner", owner) + owner.lifecycle.currentState = CREATED + registration.onBackStackUpdate() - navLeaseCoordinator.resumeCandidate(owner, null) + fixture.withoutScope { owner.lifecycle.currentState = RESUMED } - assertThat(navLeaseCoordinator.hasLease(incumbent)).isTrue() - assertThat(fixture.published).containsExactly(incumbent) + assertThat(incumbent.hasLease).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) + val previous = fixture.acquireLease("previous", TestOwner(RESUMED)) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) - navLeaseCoordinator.pauseCandidate(owner, null) + fixture.withoutScope { owner.lifecycle.currentState = STARTED } - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() - assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() - assertThat(fixture.published).containsExactly(previous, owner).inOrder() + assertThat(registration.hasLease).isFalse() + assertThat(previous.hasLease).isFalse() + assertThat(fixture.published).containsExactly("previous", "owner").inOrder() } + /** + * A real lifecycle pauses before it stops, so the leaseholder here is an owner that acquired the + * lease without ever resuming, and the other owner resumes without a scope, which records the + * resume without restoring its lease. + */ @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) + val previousOwner = TestOwner(RESUMED) + val previous = fixture.acquireLease("previous", previousOwner) + previousOwner.lifecycle.currentState = STARTED + val owner = TestOwner(STARTED) + val registration = fixture.acquireLease("owner", owner) + fixture.withoutScope { previousOwner.lifecycle.currentState = RESUMED } - navLeaseCoordinator.stopCandidate(owner, null) + fixture.withoutScope { owner.lifecycle.currentState = CREATED } - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() - assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() - assertThat(fixture.published).containsExactly(previous, owner).inOrder() + assertThat(registration.hasLease).isFalse() + assertThat(previous.hasLease).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) + // endregion - verifyNoInteractions(transaction) - } + // region unregistering @Test - fun `a nav transaction recorded without the lease is ignored`() { + fun `disposing the leaseholder unpublishes its published nav state and releases the nav lease`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(owner) - fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + val registration = fixture.acquireLease("owner", TestOwner(RESUMED)) - navLeaseCoordinator.retainTransaction(owner, transaction) - navLeaseCoordinator.unregisterCandidate(incoming, fixture.scope) + registration.dispose() - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() - assertThat(fixture.scope.transaction).isNull() - - navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) - - verifyNoInteractions(transaction) + assertThat(fixture.unpublished).containsExactly("owner") + assertThat(registration.hasLease).isFalse() } @Test - fun `an unfinished nav transaction is restored when its owner regains the lease`() { + fun `destroying an owner's lifecycle unregisters its candidate`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(owner, transaction = transaction) - fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) - assertThat(fixture.scope.transaction).isNull() - navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + owner.lifecycle.currentState = DESTROYED - assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - verify(transaction, never()).finish(any()) + assertThat(registration.hasLease).isFalse() + assertThat(fixture.unpublished).containsExactly("owner") + assertThat(fixture.unregistered).containsExactly("owner" to fixture.scope) } @Test - fun `restoring a nav transaction preserves an unrelated scope transaction`() { + fun `disposing a former leaseholder preserves the current owner's published nav state`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(owner, transaction = transaction) - fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) - fixture.scope.transaction = unrelatedTransaction + val previous = fixture.acquireLease("previous", TestOwner(RESUMED)) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) + owner.lifecycle.currentState = STARTED + fixture.unpublished.clear() - navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + registration.dispose() - assertThat(fixture.scope.transaction).isSameInstanceAs(unrelatedTransaction) + assertThat(previous.hasLease).isTrue() + assertThat(fixture.unpublished).isEmpty() } @Test - fun `a finished nav transaction is discarded when its owner regains the lease`() { + fun `disposing an owner whose nav state is still published without the nav lease unpublishes it`() { 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) + val previous = fixture.acquireLease("previous", TestOwner(RESUMED)) + val owner = TestOwner(RESUMED) + val registration = fixture.acquireLease("owner", owner) + fixture.withoutScope { owner.lifecycle.currentState = STARTED } + fixture.published.clear() + fixture.unpublished.clear() - navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + registration.dispose() - assertThat(fixture.scope.transaction).isNull() - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + assertThat(fixture.unpublished).containsExactly("owner") + assertThat(fixture.published).isEmpty() + assertThat(previous.hasLease).isFalse() } @Test - fun `a released unfinished nav transaction is not restored when its owner regains the lease`() { + fun `disposing the leaseholder hands the nav lease to the most recently resumed eligible owner`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(owner, transaction = transaction) - fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + val olderOwner = TestOwner(RESUMED) + fixture.acquireLease("older", olderOwner) + olderOwner.lifecycle.currentState = STARTED + val previousOwner = TestOwner(RESUMED) + val previous = fixture.acquireLease("previous", previousOwner) + previousOwner.lifecycle.currentState = STARTED + val registration = fixture.acquireLease("owner", TestOwner(RESUMED)) - navLeaseCoordinator.releaseTransaction(owner, transaction) - navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + registration.dispose() - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(fixture.scope.transaction).isNull() - assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + assertThat(previous.hasLease).isTrue() + assertThat(fixture.published.last()).isEqualTo("previous") + assertThat(fixture.unpublished).containsExactly("older", "previous", "owner").inOrder() } @Test - fun `releasing a nav transaction other than the retained one keeps the retained one restorable`() { + fun `disposing the leaseholder without a scope releases the nav lease`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(owner, transaction = transaction) - fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + val previous = fixture.acquireLease("previous", TestOwner(RESUMED)) + val registration = fixture.acquireLease("owner", TestOwner(RESUMED)) + fixture.unpublished.clear() - navLeaseCoordinator.releaseTransaction(owner, unrelatedTransaction) - navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + fixture.withoutScope { registration.dispose() } - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(fixture.scope.transaction).isSameInstanceAs(transaction) + assertThat(registration.hasLease).isFalse() + assertThat(previous.hasLease).isFalse() + assertThat(fixture.published).containsExactly("previous", "owner").inOrder() + assertThat(fixture.unpublished).isEmpty() } @Test - fun `a displaced owner's retained nav transaction remains identifiable until unregistering`() { + fun `disposing the leaseholder forgets the owner when unpublishing its nav state throws`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(owner, transaction = transaction) - fixture.acquireLease(incoming, state = Lifecycle.State.RESUMED) + val failure = IllegalStateException("unpublish failed") + val owner = TestOwner(RESUMED) + val registration = + fixture.acquireLease( + "owner", + owner, + navState = fixture.navState("owner", onUnpublishFrom = { throw failure }), + ) - assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isTrue() - assertThat(navLeaseCoordinator.isNavTransaction(unrelatedTransaction)).isFalse() + val thrown = assertThrows(IllegalStateException::class.java) { registration.dispose() } - navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + assertThat(thrown).isSameInstanceAs(failure) + assertThat(registration.hasLease).isFalse() + + fixture.published.clear() + owner.lifecycle.currentState = STARTED + owner.lifecycle.currentState = RESUMED + fixture.acquireLease("incoming", TestOwner(RESUMED)) - assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + assertThat(fixture.published).containsExactly("incoming") + assertThat(fixture.unpublished).isEmpty() } @Test - fun `acquiring the nav lease binds its retained transaction before publishing nav data`() { + fun `disposing an owner before its first back stack update keeps it from acquiring the nav lease`() { 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) + val registration = fixture.register("owner", TestOwner(RESUMED)) + registration.dispose() - navLeaseCoordinator.pauseCandidate(incoming, fixture.scope) + registration.onBackStackUpdate() - assertThat(navLeaseCoordinator.hasLease(owner)).isTrue() - assertThat(transactionAtPublication).containsExactly(null, transaction).inOrder() + assertThat(registration.hasLease).isFalse() + assertThat(fixture.published).isEmpty() } @Test - fun `unregistering the leaseholder finishes its transaction and clears its published nav data`() { + fun `dispose() - notifies the nav state that it was unregistered after unpublishing it`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(owner, transaction = transaction) + val calls = mutableListOf() + val registration = + fixture.acquireLease( + "owner", + TestOwner(RESUMED), + navState = + fixture.navState( + "owner", + onUnpublishFrom = { calls += "unpublish" }, + onUnregistered = { calls += "unregistered" }, + ), + ) - navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + registration.dispose() - verify(transaction).finish(SpanStatus.OK) - assertThat(fixture.scope.transaction).isNull() - assertThat(fixture.cleared).containsExactly(owner) - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() + assertThat(calls).containsExactly("unpublish", "unregistered").inOrder() } @Test - fun `unregistering a former leaseholder preserves the current owner's published nav data`() { + fun `dispose() - passes the scope to the nav state's onUnregistered`() { 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() + val registration = fixture.register("owner", TestOwner(CREATED)) - navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + registration.dispose() - assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() - assertThat(fixture.cleared).isEmpty() + assertThat(fixture.unregistered).containsExactly("owner" to fixture.scope) } @Test - fun `unregistering the leaseholder hands the nav lease to the most recently resumed eligible owner`() { + fun `dispose() - passes no scope to the nav state's onUnregistered when configureScope skips it`() { 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) + val registration = fixture.register("owner", TestOwner(CREATED)) - navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + fixture.withoutScope { registration.dispose() } - assertThat(navLeaseCoordinator.hasLease(previous)).isTrue() - assertThat(fixture.published.last()).isSameInstanceAs(previous) - assertThat(fixture.cleared).containsExactly(older, previous, owner).inOrder() + assertThat(fixture.unregistered).containsExactly("owner" to null) } @Test - fun `unregistering the leaseholder without a scope releases the nav lease`() { + fun `dispose() - notifies the nav state once with the scope when unpublishing it throws`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(previous) - fixture.acquireLease(owner) - fixture.cleared.clear() + val failure = IllegalStateException("unpublish failed") + val registration = + fixture.acquireLease( + "owner", + TestOwner(RESUMED), + navState = fixture.navState("owner", onUnpublishFrom = { throw failure }), + ) - navLeaseCoordinator.unregisterCandidate(owner, null) + val thrown = assertThrows(IllegalStateException::class.java) { registration.dispose() } - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() - assertThat(navLeaseCoordinator.hasLease(previous)).isFalse() - assertThat(fixture.published).containsExactly(previous, owner).inOrder() - assertThat(fixture.cleared).isEmpty() + assertThat(thrown).isSameInstanceAs(failure) + assertThat(fixture.unregistered).containsExactly("owner" to fixture.scope) } @Test - fun `unregistering the leaseholder without a scope doesn't finish its nav transaction`() { + fun `dispose() - notifies the nav state only once when called again`() { val fixture = Fixture.create() - val navLeaseCoordinator = fixture.navLeaseCoordinator - fixture.acquireLease(owner, transaction = transaction) + val registration = fixture.register("owner", TestOwner(CREATED)) + registration.dispose() - navLeaseCoordinator.unregisterCandidate(owner, null) + registration.dispose() - verify(transaction, never()).finish(any()) + assertThat(fixture.unregistered).hasSize(1) } @Test - fun `unregistering a former leaseholder finishes its retained transaction without clearing an unrelated one`() { + fun `dispose() - notifies the nav state only once when its lifecycle is destroyed afterward`() { 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() + val owner = TestOwner(RESUMED) + val registration = fixture.register("owner", owner) + registration.dispose() - navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) + owner.lifecycle.currentState = DESTROYED - verify(transaction).finish(SpanStatus.CANCELLED) - assertThat(fixture.scope.transaction).isSameInstanceAs(unrelatedTransaction) - assertThat(fixture.cleared).isEmpty() + assertThat(fixture.unregistered).hasSize(1) } @Test - fun `unregistering the leaseholder forgets the owner when finishing its transaction throws`() { + fun `dispose() - stops observing the lifecycle`() { 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 owner = TestOwner(RESUMED) + val registration = fixture.register("owner", owner) - val thrown = - assertThrows(IllegalStateException::class.java) { - navLeaseCoordinator.unregisterCandidate(owner, fixture.scope) - } + registration.dispose() - assertThat(thrown).isSameInstanceAs(failure) - assertThat(navLeaseCoordinator.hasLease(owner)).isFalse() - assertThat(navLeaseCoordinator.isNavTransaction(transaction)).isFalse() + assertThat(owner.lifecycle.observerCount).isEqualTo(0) + } - fixture.published.clear() - navLeaseCoordinator.resumeCandidate(owner, fixture.scope) - fixture.acquireLease(incoming) + // endregion - assertThat(fixture.published).containsExactly(incoming) - assertThat(fixture.cleared).isEmpty() + /** + * A [LifecycleOwner] whose lifecycle starts in [initialState] and can be moved through states by + * setting [LifecycleRegistry.currentState], which dispatches the events in between. + */ + private class TestOwner(initialState: Lifecycle.State) : LifecycleOwner { + override val lifecycle: LifecycleRegistry = + LifecycleRegistry.createUnsafe(this).apply { + // A lifecycle must be created before it can be destroyed. + currentState = CREATED + currentState = initialState + } } - private class Fixture { + private class Fixture private constructor() { val navLeaseCoordinator = NavLeaseCoordinator() val scope = Scope(SentryOptions()) - val published = mutableListOf() - val cleared = mutableListOf() + /** Whether [scopes] runs `configureScope` callbacks; when false, it skips them. */ + private var providesScope = true + + val scopes = + mock().also { scopes -> + doAnswer { + if (providesScope) { + (it.arguments[0] as ScopeCallback).run(scope) + } + null + } + .whenever(scopes) + .configureScope(any()) + } + + /** Names of the nav states published, in publication order. */ + val published = mutableListOf() + + /** Names of the nav states unpublished, in the order they were unpublished. */ + val unpublished = mutableListOf() + + /** Names of the nav states notified of unregistration, with the scope they were given. */ + val unregistered = mutableListOf>() + + /** + * `LifecycleRegistry` holds its owner weakly and throws once it's collected, so the fixture + * keeps registered owners alive for the test. + */ + private val owners = mutableListOf() + + /** + * Nav state named [name] that, by default, records its publication in [published], its + * unpublishing in [unpublished], and its unregistration in [unregistered]. + */ + fun navState( + name: String, + onPublishTo: (IScope) -> Unit = { published += name }, + onUnpublishFrom: (IScope) -> Unit = { unpublished += name }, + onUnregistered: (IScope?) -> Unit = { unregistered += name to it }, + ): LeasedNavState = FakeLeasedNavState(onPublishTo, onUnpublishFrom, onUnregistered) + + /** Registers a candidate for [owner]'s lifecycle with the nav state named [name]. */ fun register( - owner: Any, - state: Lifecycle.State, - onClearPublishedData: (IScope) -> Unit = { cleared += owner }, - ) { - navLeaseCoordinator.registerCandidate( - owner, - state, - onPublishData = { published += owner }, - onClearPublishedData = onClearPublishedData, - ) + name: String, + owner: TestOwner, + navState: LeasedNavState = navState(name), + ): NavLeaseRegistration { + owners += owner + return checkNotNull(navLeaseCoordinator.register(owner.lifecycle, scopes, navState)) } + /** + * Registers a candidate for [owner]'s lifecycle and applies its first back stack update, which + * acquires the nav lease if the owner's lifecycle permits it. + */ 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 + name: String, + owner: TestOwner, + navState: LeasedNavState = navState(name), + ): NavLeaseRegistration = register(name, owner, navState).also { it.onBackStackUpdate() } + + /** Runs [block] while `configureScope` skips its callbacks, e.g., as if the SDK is disabled. */ + fun withoutScope(block: () -> Unit) { + providesScope = false + try { + block() + } finally { + providesScope = true } } @@ -870,4 +933,16 @@ class NavLeaseCoordinatorTest { fun create(): Fixture = Fixture() } } + + private class FakeLeasedNavState( + private val onPublishTo: (IScope) -> Unit, + private val onUnpublishFrom: (IScope) -> Unit, + private val onUnregistered: (IScope?) -> Unit, + ) : LeasedNavState { + override fun publishTo(scope: IScope) = onPublishTo(scope) + + override fun unpublishFrom(scope: IScope) = onUnpublishFrom(scope) + + override fun onUnregistered(scope: IScope?) = onUnregistered.invoke(scope) + } } 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 index be66fb5b165..57be6d68d5d 100644 --- 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 @@ -1,26 +1,42 @@ 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.IScopes import io.sentry.ITransaction import io.sentry.Scope +import io.sentry.ScopeCallback import io.sentry.ScopeObserverAdapter import io.sentry.SentryOptions +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.App import org.junit.Assert.assertThrows import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +/** + * Tests for the [INavScope] that [NavScopes.configureScope] hands out, which is only reachable + * through it. + */ 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 navScopes = fixture.navScopes + navScopes.withNavScope { it.screen = "Staged" } + fixture.scope.screen = "Host" - val screen = navScope.screen + val screen = navScopes.withNavScope { it.screen } assertThat(screen).isEqualTo("Host") } @@ -28,11 +44,11 @@ class NavScopeTest { @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 navScopes = fixture.navScopes + fixture.scope.screen = "Host" + navScopes.withNavScope { it.screen = "Staged" } - val screen = navScope.screen + val screen = navScopes.withNavScope { it.screen } assertThat(screen).isEqualTo("Staged") } @@ -40,282 +56,437 @@ class NavScopeTest { @Test fun `setScreen() - publishes the screen with the nav lease`() { val fixture = Fixture.create(ownsLease = true) - val navScope = fixture.navScope + val navScopes = fixture.navScopes - navScope.screen = "Profile" + navScopes.withNavScope { it.screen = "Profile" } - assertThat(fixture.realScope.screen).isEqualTo("Profile") + assertThat(fixture.scope.screen).isEqualTo("Profile") } @Test - fun `setScreen() - stages the screen without the nav lease`() { + fun `setScreen() - stages the screen without the nav lease until the lease is acquired`() { val fixture = Fixture.create() - val navScope = fixture.navScope - fixture.realScope.screen = "Host" + val navScopes = fixture.navScopes + fixture.scope.screen = "Host" - navScope.screen = "Profile" + navScopes.withNavScope { it.screen = "Profile" } + assertThat(fixture.scope.screen).isEqualTo("Host") - assertThat(fixture.realScope.screen).isEqualTo("Host") - assertThat(navScope.screen).isEqualTo("Profile") + fixture.acquireLease() + assertThat(fixture.scope.screen).isEqualTo("Profile") + } + + @Test + fun `getApp() - returns a copy of the scope's app context`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes + val hostApp = App().apply { appName = "Host" } + fixture.scope.contexts.setApp(hostApp) + + val app = navScopes.withNavScope { it.app } + + assertThat(app?.appName).isEqualTo("Host") + assertThat(app).isNotSameInstanceAs(hostApp) + } + + @Test + fun `getApp() - returns null when the scope has no app context`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes + + val app = navScopes.withNavScope { it.app } + + assertThat(app).isNull() } @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") + val navScopes = fixture.navScopes - navScope.setNavigationContext(navigationContext) + navScopes.withNavScope { it.setNavigationContext(navigationContext) } - assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]) - .isSameInstanceAs(navigationContext) + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) } @Test - fun `setNavigationContext() - stages the navigation context without the nav lease`() { + fun `setNavigationContext() - clears the published navigation context given null with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScopes = fixture.navScopes + navScopes.withNavScope { it.setNavigationContext(navigationContext) } + + navScopes.withNavScope { it.setNavigationContext(null) } + + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `setNavigationContext() - stages the navigation context without the nav lease until the lease is acquired`() { val fixture = Fixture.create() - val navScope = fixture.navScope + val navScopes = fixture.navScopes val hostContext = mapOf("route" to "Host") - val navigationContext = mapOf("route" to "Profile") - fixture.realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + fixture.scope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) - navScope.setNavigationContext(navigationContext) + navScopes.withNavScope { it.setNavigationContext(navigationContext) } + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) - assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) - fixture.stagedNavData.publishTo(fixture.realScope) - assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]) - .isSameInstanceAs(navigationContext) + fixture.acquireLease() + assertThat(fixture.scope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) } @Test - fun `rotatePropagationContext() - replaces the real context with the nav lease`() { + fun `rotatePropagationContext() - replaces the real propagation context with the nav lease`() { val fixture = Fixture.create(ownsLease = true) - val realContext = fixture.realScope.propagationContext + val navScopes = fixture.navScopes + val realContext = fixture.scope.propagationContext - fixture.navScope.rotatePropagationContext() + navScopes.withNavScope { it.rotatePropagationContext() } - assertThat(fixture.realScope.propagationContext).isNotSameInstanceAs(realContext) + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(realContext) } @Test - fun `rotatePropagationContext() - defers rotating the real context without the nav lease`() { + fun `rotatePropagationContext() - defers the rotation without the nav lease until the lease is acquired`() { val fixture = Fixture.create() - val realContext = fixture.realScope.propagationContext + val navScopes = fixture.navScopes + val realContext = fixture.scope.propagationContext + + navScopes.withNavScope { it.rotatePropagationContext() } + assertThat(fixture.scope.propagationContext).isSameInstanceAs(realContext) + + fixture.acquireLease() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(realContext) + } + + @Test + fun `startNavTransaction() - binds the nav transaction to a free scope with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScopes = fixture.navScopes - fixture.navScope.rotatePropagationContext() + val navTransaction = navScopes.withNavScope { it.startNavTransaction("/Profile") } - assertThat(fixture.realScope.propagationContext).isSameInstanceAs(realContext) - fixture.stagedNavData.publishTo(fixture.realScope) - assertThat(fixture.realScope.propagationContext).isNotSameInstanceAs(realContext) + assertThat(navTransaction).isNotNull() + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.single()) } @Test - fun `bindTransaction() - binds to a free scope with the nav lease`() { + fun `startNavTransaction() - starts none and keeps a bound host transaction with the nav lease`() { val fixture = Fixture.create(ownsLease = true) - val transaction = mock() + val navScopes = fixture.navScopes + val hostTransaction = mock() + fixture.scope.transaction = hostTransaction - fixture.navScope.bindTransaction(transaction) + val navTransaction = navScopes.withNavScope { it.startNavTransaction("/Profile") } - assertThat(fixture.realScope.transaction).isSameInstanceAs(transaction) + assertThat(navTransaction).isNull() + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) } @Test - fun `bindTransaction() - preserves an already bound transaction`() { + fun `startNavTransaction() - replaces a finished bound transaction with the nav lease`() { val fixture = Fixture.create(ownsLease = true) - val bound = mock() - fixture.realScope.transaction = bound + val navScopes = fixture.navScopes + fixture.scope.transaction = + mock().also { whenever(it.isFinished).thenReturn(true) } - fixture.navScope.bindTransaction(mock()) + val navTransaction = navScopes.withNavScope { it.startNavTransaction("/Profile") } - assertThat(fixture.realScope.transaction).isSameInstanceAs(bound) + assertThat(navTransaction).isNotNull() + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.single()) } @Test - fun `bindTransaction() - does nothing without the nav lease`() { + fun `startNavTransaction() - defers the start despite a bound host transaction without the nav lease`() { val fixture = Fixture.create() + val navScopes = fixture.navScopes + val hostTransaction = mock() + fixture.scope.transaction = hostTransaction - fixture.navScope.bindTransaction(mock()) + val navTransaction = navScopes.withNavScope { it.startNavTransaction("/Profile") } - assertThat(fixture.realScope.transaction).isNull() + assertThat(navTransaction).isNotNull() + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) } @Test - fun `unbindTransaction() - unbinds the given transaction without the nav lease`() { + fun `startNavTransaction() - starts a deferred transaction once the host's is unbound and the lease acquired`() { val fixture = Fixture.create() - val transaction = mock() - fixture.realScope.transaction = transaction + val navScopes = fixture.navScopes + fixture.scope.transaction = mock() + navScopes.withNavScope { it.startNavTransaction("/Profile") } + fixture.scope.clearTransaction() - fixture.navScope.unbindTransaction(transaction) + fixture.acquireLease() - assertThat(fixture.realScope.transaction).isNull() + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.single()) } @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) + fun `startNavTransaction() - defers the start despite another owner's bound nav transaction without the nav lease`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes + val otherTransaction = fixture.startAnotherOwnersNavTransaction() - fixture.navScope.unbindTransaction(transaction) + val navTransaction = navScopes.withNavScope { it.startNavTransaction("/Profile") } - assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + assertThat(navTransaction).isNotNull() + assertThat(fixture.startedTransactions).containsExactly(otherTransaction) + assertThat(fixture.scope.transaction).isSameInstanceAs(otherTransaction) } @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() + fun `startNavTransaction() - defers the start and keeps a finished bound transaction without the nav lease`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes + val finished = mock().also { whenever(it.isFinished).thenReturn(true) } + fixture.scope.transaction = finished - fixture.navScope.unbindTransaction(transaction) + val navTransaction = navScopes.withNavScope { it.startNavTransaction("/Profile") } - assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + assertThat(navTransaction).isNotNull() + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isSameInstanceAs(finished) } @Test - fun `unbindTransaction() - releases the retained nav transaction when a scope observer throws`() { + fun `startNavTransaction() - starts none while its unfinished previous nav transaction is bound`() { 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() - } - } - ) + val navScopes = fixture.navScopes + navScopes.withNavScope { it.startNavTransaction("/Profile") } + val previous = fixture.startedTransactions.single() - assertThrows(ThrowingScopeObserverException::class.java) { - fixture.navScope.unbindTransaction(transaction) - } + val navTransaction = navScopes.withNavScope { it.startNavTransaction("/Settings") } - assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + assertThat(navTransaction).isNull() + verify(previous, never()).finish(any()) + assertThat(fixture.startedTransactions).containsExactly(previous) + assertThat(fixture.scope.transaction).isSameInstanceAs(previous) } @Test - fun `unbindTransaction() - preserves a different bound transaction`() { + fun `startNavTransaction() - binds the new nav transaction once the previous one is finished with the nav lease`() { val fixture = Fixture.create(ownsLease = true) - val bound = mock() - fixture.realScope.transaction = bound + val navScopes = fixture.navScopes + navScopes.withNavScope { it.startNavTransaction("/Profile") } + navScopes.withNavScope { it.finishNavTransaction() } + + val navTransaction = navScopes.withNavScope { it.startNavTransaction("/Settings") } + + assertThat(navTransaction).isNotNull() + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.last()) + } + + @Test + fun `startNavTransaction() - replaces the previous deferred transaction without the nav lease`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes + val previous = navScopes.withNavScope { it.startNavTransaction("/Profile") } as NavTransaction - fixture.navScope.unbindTransaction(mock()) + val navTransaction = navScopes.withNavScope { it.startNavTransaction("/Settings") } + fixture.acquireLease() - assertThat(fixture.realScope.transaction).isSameInstanceAs(bound) + assertThat(navTransaction).isNotNull() + assertThat(previous.transaction).isNull() + assertThat(fixture.startedTransactions.map { it.name }).containsExactly("/Settings") + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.single()) } @Test - fun `clearFinishedTransaction() - unbinds a finished transaction with the nav lease`() { + fun `startNavTransaction() - starts none while a host transaction is bound after the previous one finishes`() { val fixture = Fixture.create(ownsLease = true) - fixture.realScope.transaction = - mock().also { whenever(it.isFinished).thenReturn(true) } + val navScopes = fixture.navScopes + navScopes.withNavScope { it.startNavTransaction("/Profile") } + val previous = fixture.startedTransactions.single() + val hostTransaction = mock() + fixture.scope.transaction = hostTransaction + navScopes.withNavScope { it.finishNavTransaction() } - fixture.navScope.clearFinishedTransaction() + val navTransaction = navScopes.withNavScope { it.startNavTransaction("/Settings") } - assertThat(fixture.realScope.transaction).isNull() + assertThat(navTransaction).isNull() + verify(previous).finish(SpanStatus.OK) + assertThat(fixture.startedTransactions).containsExactly(previous) + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) } @Test - fun `clearFinishedTransaction() - does nothing without the nav lease`() { + fun `startNavTransaction() - replaces the previous deferred transaction while a host transaction is bound`() { val fixture = Fixture.create() - val finished = mock().also { whenever(it.isFinished).thenReturn(true) } - fixture.realScope.transaction = finished + val navScopes = fixture.navScopes + val previous = navScopes.withNavScope { it.startNavTransaction("/Profile") } as NavTransaction + fixture.scope.transaction = mock() + + val navTransaction = navScopes.withNavScope { it.startNavTransaction("/Settings") } + fixture.scope.clearTransaction() + fixture.acquireLease() + + assertThat(navTransaction).isNotNull() + assertThat(previous.transaction).isNull() + assertThat(fixture.startedTransactions.map { it.name }).containsExactly("/Settings") + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransactions.single()) + } - fixture.navScope.clearFinishedTransaction() + @Test + fun `finishNavTransaction() - finishes and unbinds the nav transaction`() { + val fixture = Fixture.create(ownsLease = true) + val navScopes = fixture.navScopes + navScopes.withNavScope { it.startNavTransaction("/Profile") } + val started = fixture.startedTransactions.single() + + navScopes.withNavScope { it.finishNavTransaction() } - assertThat(fixture.realScope.transaction).isSameInstanceAs(finished) + verify(started).finish(SpanStatus.OK) + assertThat(fixture.scope.transaction).isNull() } @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 + fun `finishNavTransaction() - finishes it without the nav lease and keeps the leaseholder's bound`() { + val fixture = Fixture.create(ownsLease = true) + val navScopes = fixture.navScopes + navScopes.withNavScope { it.startNavTransaction("/Profile") } + val started = fixture.startedTransactions.single() + val otherTransaction = fixture.startAnotherOwnersNavTransaction() - val transaction = fixture.navScope.transaction + navScopes.withNavScope { it.finishNavTransaction() } - assertThat(transaction).isNull() + verify(started).finish(SpanStatus.OK) + assertThat(fixture.scope.transaction).isSameInstanceAs(otherTransaction) } @Test - fun `transaction - reports a non-nav transaction without the nav lease`() { - val fixture = Fixture.create() - val hostTransaction = mock() - fixture.realScope.transaction = hostTransaction + fun `finishNavTransaction() - forgets the nav transaction if unbinding throws`() { + val fixture = Fixture.create(ownsLease = true) + val navScopes = fixture.navScopes + navScopes.withNavScope { it.startNavTransaction("/Profile") } + val started = fixture.startedTransactions.single() + // Registered after the start, so that only unbinding the transaction reaches it. + fixture.scope.options.addScopeObserver( + object : ScopeObserverAdapter() { + override fun setTransaction(transaction: String?) { + throw ThrowingScopeObserverException() + } + } + ) - val transaction = fixture.navScope.transaction + assertThrows(ThrowingScopeObserverException::class.java) { + navScopes.withNavScope { it.finishNavTransaction() } + } + navScopes.withNavScope { it.finishNavTransaction() } - assertThat(transaction).isSameInstanceAs(hostTransaction) + // Finished once: the second call has no nav transaction left to finish. + verify(started).finish(SpanStatus.OK) } @Test - fun `transaction - reports the bound transaction with the nav lease`() { + fun `finishNavTransaction() - keeps a different bound transaction`() { val fixture = Fixture.create(ownsLease = true) - val boundTransaction = mock() - fixture.realScope.transaction = boundTransaction + val navScopes = fixture.navScopes + navScopes.withNavScope { it.startNavTransaction("/Profile") } + val hostTransaction = mock() + fixture.scope.transaction = hostTransaction - val transaction = fixture.navScope.transaction + navScopes.withNavScope { it.finishNavTransaction() } - assertThat(transaction).isSameInstanceAs(boundTransaction) + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) } + @Test + fun `finishNavTransaction() - keeps the bound transaction when the nav transaction has not started`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes + val otherTransaction = fixture.startAnotherOwnersNavTransaction() + navScopes.withNavScope { it.startNavTransaction("/Profile") } + + navScopes.withNavScope { it.finishNavTransaction() } + + assertThat(fixture.scope.transaction).isSameInstanceAs(otherTransaction) + verify(otherTransaction, never()).finish(any()) + } + + private val navigationContext = mapOf("route" to "Profile") + + /** Runs [block] with the [INavScope] handed out by [NavScopes.configureScope]. */ + private fun NavScopes.withNavScope(block: (INavScope) -> T): T { + val results = mutableListOf() + configureScope { results += block(it) } + return results.single() + } + + private fun INavScope.startNavTransaction(name: String): INavTransaction? = + startNavTransaction(TransactionContext(name, "navigation"), TransactionOptions()) + /** 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)) + private class TestLifecycleOwner : LifecycleOwner { + override val lifecycle: LifecycleRegistry = + LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + } + + private class Fixture private constructor() { + val scope = Scope(SentryOptions()) + val coordinator = NavLeaseCoordinator() + val startedTransactions = mutableListOf() + + /** + * `LifecycleRegistry` holds its owner weakly and throws once it's collected, so the fixture + * keeps the owners it creates alive for the test. + */ + private val lifecycleOwners = mutableListOf() + + private val scopes = + mock().also { scopes -> + whenever(scopes.options).thenReturn(SentryOptions()) + doAnswer { + (it.arguments[0] as ScopeCallback).run(scope) + null + } + .whenever(scopes) + .configureScope(any()) + whenever(scopes.startTransaction(any(), any())) + .thenAnswer { + val name = (it.arguments[0] as TransactionContext).name + mock() + .also { transaction -> whenever(transaction.name).thenReturn(name) } + .also(startedTransactions::add) + } + } + + /** Attached to a resumed owner, but without the nav lease until [acquireLease]. */ + val navScopes = resumedNavScopes() + + /** Lets [navScopes] acquire the nav lease by applying its first back stack update. */ + fun acquireLease() { + navScopes.onBackStackUpdate() } - /** 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)) + /** + * Lets another resumed owner acquire the nav lease and start a nav transaction, which is + * returned. + */ + fun startAnotherOwnersNavTransaction(): ITransaction { + val other = resumedNavScopes() + other.onBackStackUpdate() + other.configureScope { + it.startNavTransaction(TransactionContext("/Other", "navigation"), TransactionOptions()) + } + return startedTransactions.single { it.name == "/Other" } } - companion object { + private fun resumedNavScopes(): NavScopes = + NavScopes(scopes, coordinator).also { + it.attach(TestLifecycleOwner().also(lifecycleOwners::add).lifecycle) + } - 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) + companion object { + fun create(ownsLease: Boolean = false): Fixture = + Fixture().also { + if (ownsLease) { + it.acquireLease() + } } - - 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/NavScopesIntegrationTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesIntegrationTest.kt new file mode 100644 index 00000000000..4f01197a0f7 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesIntegrationTest.kt @@ -0,0 +1,82 @@ +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.ISentryClient +import io.sentry.PropagationContext +import io.sentry.Scope +import io.sentry.ScopeType +import io.sentry.Scopes +import io.sentry.SentryOptions +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** Exercises navigation lease publication together with real SDK transaction sampling. */ +class NavScopesIntegrationTest { + + @Test + fun `a deferred nav transaction is sampled after the propagation context rotates`() { + val fixture = Fixture.create() + val incumbentOwner = TestLifecycleOwner(Lifecycle.State.RESUMED) + val incumbent = fixture.navScopes() + incumbent.attach(incumbentOwner.lifecycle) + incumbent.onBackStackUpdate() + val incomingOwner = TestLifecycleOwner(Lifecycle.State.STARTED) + val incoming = fixture.navScopes() + incoming.attach(incomingOwner.lifecycle) + incoming.onBackStackUpdate() + val contextBeforeResume = fixture.scope.propagationContext + val contextsAtSampling = mutableListOf() + fixture.options.setTracesSampler { + contextsAtSampling += fixture.scope.propagationContext + 1.0 + } + incoming.configureScope { + it.startNavTransaction(TransactionContext("/Checkout", "navigation"), TransactionOptions()) + } + assertThat(contextsAtSampling).isEmpty() + assertThat(fixture.scope.propagationContext).isSameInstanceAs(contextBeforeResume) + + incomingOwner.lifecycle.currentState = Lifecycle.State.RESUMED + + assertThat(contextsAtSampling).hasSize(1) + assertThat(contextsAtSampling.single()).isNotSameInstanceAs(contextBeforeResume) + assertThat(contextsAtSampling.single()).isSameInstanceAs(fixture.scope.propagationContext) + assertThat(fixture.scope.transaction?.name).isEqualTo("/Checkout") + incoming.dispose() + incumbent.dispose() + } + + private class TestLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner { + override val lifecycle: LifecycleRegistry = + LifecycleRegistry.createUnsafe(this).apply { currentState = initialState } + } + + private class Fixture private constructor() { + val options = + SentryOptions().apply { + dsn = "http://key@localhost/1" + defaultScopeType = ScopeType.CURRENT + } + val scope = Scope(options) + private val scopes = Scopes(scope, Scope(options), Scope(options), "navigation sampling test") + private val coordinator = NavLeaseCoordinator() + + init { + val client = mock() + whenever(client.isEnabled).thenReturn(true) + scopes.bindClient(client) + } + + fun navScopes(): NavScopes = NavScopes(scopes, coordinator) + + companion object { + fun create(): Fixture = Fixture() + } + } +} 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 index 4470072b354..c865e8e91e6 100644 --- 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 @@ -6,25 +6,58 @@ import androidx.lifecycle.LifecycleRegistry import com.google.common.truth.Truth.assertThat import io.sentry.Breadcrumb import io.sentry.Hint +import io.sentry.ILogger import io.sentry.IScopes import io.sentry.ITransaction +import io.sentry.NoOpTransaction import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.ScopeObserverAdapter +import io.sentry.SentryLevel import io.sentry.SentryOptions +import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.TransactionOptions +import io.sentry.protocol.Contexts +import org.junit.Assert.assertThrows import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class NavScopesTest { + @Test + fun `attach() - lets the first back stack update acquire the nav lease`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + navScopes.attach(fixture.lifecycle()) + + navScopes.applyFirstBackStackUpdate() + + assertThat(fixture.hasLease(navScopes)).isTrue() + } + + @Test + fun `attach() - does nothing for the lifecycle that's already attached`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + val lifecycle = fixture.lifecycle() + navScopes.attach(lifecycle) + + navScopes.attach(lifecycle) + + assertThat(lifecycle.observerCount).isEqualTo(1) + } + @Test fun `attach() - ignores a second, different lifecycle`() { - val fixture = Fixture() + val fixture = Fixture.create() val navScopes = fixture.navScopes() val first = TestLifecycleOwner() val second = TestLifecycleOwner() @@ -37,45 +70,79 @@ class NavScopesTest { } @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() + fun `attach() - ignores a destroyed lifecycle`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + val lifecycle = fixture.lifecycle(Lifecycle.State.DESTROYED) - incoming.dispose() + navScopes.attach(lifecycle) + navScopes.applyFirstBackStackUpdate() - assertThat(fixture.hasLease(outgoing)).isTrue() + assertThat(fixture.hasLease(navScopes)).isFalse() } @Test - fun `onBackStackUpdate() - an update skipped by the scope does not count as the first update`() { - val fixture = Fixture() + fun `attach() - accepts a new lifecycle after the attached one is destroyed`() { + val fixture = Fixture.create() val navScopes = fixture.navScopes() - val owner = TestLifecycleOwner() - navScopes.attach(owner.lifecycle) - fixture.isEnabled = false + val first = fixture.lifecycle(Lifecycle.State.RESUMED) + navScopes.attach(first) navScopes.applyFirstBackStackUpdate() - fixture.isEnabled = true + first.currentState = Lifecycle.State.DESTROYED + val second = fixture.lifecycle(Lifecycle.State.RESUMED) + navScopes.attach(second) navScopes.applyFirstBackStackUpdate() + assertThat(second.observerCount).isEqualTo(1) assertThat(fixture.hasLease(navScopes)).isTrue() } + @Test + fun `onBackStackUpdate() - doesn't acquire the nav lease before attach`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + + navScopes.applyFirstBackStackUpdate() + + assertThat(fixture.hasLease(navScopes)).isFalse() + } + + @Test + fun `destroying the attached lifecycle finishes the nav transaction`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + val lifecycle = fixture.lifecycle(Lifecycle.State.RESUMED) + navScopes.attach(lifecycle) + navScopes.applyFirstBackStackUpdate() + navScopes.startNavTransaction("/Home") + val started = fixture.startedTransaction("/Home") + + lifecycle.currentState = Lifecycle.State.DESTROYED + + verify(started).finish(SpanStatus.OK) + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `destroying the attached lifecycle without a scope doesn't finish the nav transaction`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + val lifecycle = fixture.lifecycle(Lifecycle.State.RESUMED) + navScopes.attach(lifecycle) + navScopes.applyFirstBackStackUpdate() + navScopes.startNavTransaction("/Home") + val started = fixture.startedTransaction("/Home") + fixture.isEnabled = false + + lifecycle.currentState = Lifecycle.State.DESTROYED + + verify(started, never()).finish(any()) + } + @Test fun `addBreadcrumb() - records breadcrumbs immediately without the nav lease`() { - val fixture = Fixture() + val fixture = Fixture.create() val leaseholder = fixture.navScopes() leaseholder.attach(TestLifecycleOwner().lifecycle) leaseholder.applyFirstBackStackUpdate() @@ -91,77 +158,513 @@ class NavScopesTest { } @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() + fun `dispose() - finishes a displaced owner's nav transaction and keeps the leaseholder's bound`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes(startedRoute = "/Home") + fixture.incoming.startNavTransaction("/Incoming") + val coveredTransaction = fixture.startedTransaction("/Home") + val incomingTransaction = fixture.startedTransaction("/Incoming") + + covered.dispose() - val transaction = - covered.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + verify(coveredTransaction).finish(SpanStatus.OK) + assertThat(fixture.scope.transaction).isSameInstanceAs(incomingTransaction) + } + + @Test + fun `dispose() - finishes the nav transaction when handing off the nav lease throws`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + covered.configureScope { it.screen = "/Result" } + fixture.incoming.startNavTransaction("/Incoming") + val incomingTransaction = fixture.startedTransaction("/Incoming") + val failure = IllegalStateException("observer failure") + fixture.scope.options.addScopeObserver( + object : ScopeObserverAdapter() { + override fun setContexts(contexts: Contexts) { + throw failure + } + } + ) + + val thrown = assertThrows(IllegalStateException::class.java) { fixture.incoming.dispose() } + + assertThat(thrown).isSameInstanceAs(failure) + verify(incomingTransaction).finish(SpanStatus.OK) + } + + @Test + fun `dispose() - doesn't finish the nav transaction without a scope`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + navScopes.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + navScopes.applyFirstBackStackUpdate() + navScopes.startNavTransaction("/Home") + val started = fixture.startedTransaction("/Home") + fixture.isEnabled = false + + navScopes.dispose() + + verify(started, never()).finish(any()) + } + + @Test + fun `dispose() - forgets a nav transaction staged while never attached`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + navScopes.startNavTransaction("/Home") + + navScopes.dispose() + navScopes.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + navScopes.applyFirstBackStackUpdate() + + assertThat(fixture.hasLease(navScopes)).isTrue() + assertThat(fixture.startedTransactions).isEmpty() + } + + @Test + fun `startNavTransaction() - starts and binds a nav transaction with the nav lease`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + navScopes.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + navScopes.applyFirstBackStackUpdate() + + val transaction = navScopes.startNavTransaction("/Home") + + val started = fixture.startedTransaction("/Home") + assertThat(transaction).isNotNull() + assertThat(fixture.scope.transaction).isSameInstanceAs(started) + } + + @Test + fun `startNavTransaction() - returns null when the delegate declines with the nav lease`() { + val fixture = Fixture.create(declinesTransactions = true) + val navScopes = fixture.navScopes() + navScopes.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + navScopes.applyFirstBackStackUpdate() + + val transaction = navScopes.startNavTransaction("/Home") + + assertThat(transaction).isNull() + } + + @Test + fun `startNavTransaction() - logs the route when another transaction blocks the start`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + navScopes.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + navScopes.applyFirstBackStackUpdate() + fixture.scope.transaction = mock() + + navScopes.startNavTransaction("/Home") + + verify(fixture.logger).log(eq(SentryLevel.DEBUG), any(), eq("/Home")) + } + + @Test + fun `startNavTransaction() - defers the nav transaction start without the nav lease`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + + val transaction = covered.startNavTransaction("/Result") assertThat(fixture.hasLease(covered)).isFalse() - assertThat(transaction.isNoOp).isTrue() + assertThat(transaction).isNotNull() + assertThat(fixture.startedTransactions).isEmpty() 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() + fun `startNavTransaction() - defers the propagation context rotation without the nav lease`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() val incomingContext = fixture.scope.propagationContext - covered.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + covered.startNavTransaction("/Result") assertThat(fixture.scope.propagationContext).isSameInstanceAs(incomingContext) - incoming.dispose() + fixture.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() + fun `startNavTransaction() - starts no nav transaction after dispose`() { + val fixture = Fixture.create() val navScopes = fixture.navScopes() - navScopes.attach(TestLifecycleOwner().lifecycle) + navScopes.attach(fixture.lifecycle()) navScopes.applyFirstBackStackUpdate() navScopes.dispose() - val transaction = - navScopes.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + navScopes.startNavTransaction("/Home") - assertThat(transaction.isNoOp).isTrue() + assertThat(fixture.startedTransactions).isEmpty() } @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 + fun `finishNavTransaction() - finishes the started nav transaction`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + navScopes.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + navScopes.applyFirstBackStackUpdate() + navScopes.startNavTransaction("/Home") + val started = fixture.startedTransaction("/Home") - covered.configureScope { it.rotatePropagationContext() } - assertThat(fixture.scope.propagationContext).isSameInstanceAs(incomingContext) + navScopes.finishNavTransaction() + + verify(started).finish(SpanStatus.OK) + } + + @Test + fun `finishNavTransaction() - finishes the started nav transaction only once`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + navScopes.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + navScopes.applyFirstBackStackUpdate() + navScopes.startNavTransaction("/Home") + val started = fixture.startedTransaction("/Home") + navScopes.finishNavTransaction() + + navScopes.finishNavTransaction() + + verify(started, times(1)).finish(any()) + } + + @Test + fun `finishNavTransaction() - does nothing without a nav transaction`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + navScopes.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + navScopes.applyFirstBackStackUpdate() + val hostTransaction = mock() + fixture.scope.transaction = hostTransaction + + navScopes.finishNavTransaction() + + verify(hostTransaction, never()).finish(any()) + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `a nav transaction starts and is bound after the previous one is finished`() { + val fixture = Fixture.create() + val navScopes = fixture.navScopes() + navScopes.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + navScopes.applyFirstBackStackUpdate() + navScopes.startNavTransaction("/Home") + navScopes.finishNavTransaction() - incoming.dispose() + val transaction = navScopes.startNavTransaction("/Profile") + + assertThat(transaction).isNotNull() + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/Profile")) + } + + @Test + fun `losing the nav lease unbinds the nav transaction without finishing it`() { + val fixture = Fixture.create() + + fixture.coveredNavScopes(startedRoute = "/Home") + + val started = fixture.startedTransaction("/Home") + assertThat(fixture.scope.transaction).isNull() + verify(started, never()).finish(any()) + } + + @Test + fun `regaining the nav lease rebinds the unfinished nav transaction`() { + val fixture = Fixture.create() + fixture.coveredNavScopes(startedRoute = "/Home") + + fixture.incoming.dispose() + + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/Home")) + } + + @Test + fun `regaining the nav lease republishes the nav data before rebinding the nav transaction`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes(startedRoute = "/Home") + covered.configureScope { it.screen = "Home" } + val screensAtBinding = mutableListOf() + fixture.scope.options.addScopeObserver( + object : ScopeObserverAdapter() { + override fun setTransaction(transaction: String?) { + if (transaction == "/Home") screensAtBinding += fixture.scope.screen + } + } + ) + + fixture.incoming.dispose() + + assertThat(screensAtBinding).containsExactly("Home") + } + + @Test + fun `a finished nav transaction is discarded when the nav lease is regained`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes(startedRoute = "/Home") + val started = fixture.startedTransaction("/Home") + whenever(started.isFinished).thenReturn(true) + + fixture.incoming.dispose() + + assertThat(fixture.hasLease(covered)).isTrue() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `a nav transaction finished without the nav lease is not bound when the nav lease is regained`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes(startedRoute = "/Home") + // The mock keeps reporting itself unfinished, like a transaction waiting for its child spans. + covered.finishNavTransaction() + + fixture.incoming.dispose() + + assertThat(fixture.hasLease(covered)).isTrue() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `a deferred transaction starts and is bound when the nav lease is acquired`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + covered.startNavTransaction("/Result") + + fixture.incoming.dispose() + + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/Result")) + } + + @Test + fun `a deferred transaction receives the data recorded on its handle once it starts`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + val transaction = covered.startNavTransaction("/Result") + transaction?.setData("arguments", "id=1") + + fixture.incoming.dispose() + + verify(fixture.startedTransaction("/Result")).setData("arguments", "id=1") + } + + @Test + fun `a preloaded owner's deferred transaction starts when the owner resumes`() { + val fixture = Fixture.create() + val current = fixture.navScopes() + current.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + current.applyFirstBackStackUpdate() + val preloaded = fixture.navScopes() + val preloadedLifecycle = fixture.lifecycle(Lifecycle.State.STARTED) + preloaded.attach(preloadedLifecycle) + preloaded.applyFirstBackStackUpdate() + preloaded.startNavTransaction("/PageB") + check(!fixture.hasLease(preloaded)) + + preloadedLifecycle.currentState = Lifecycle.State.RESUMED + + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/PageB")) + } + + @Test + fun `a started deferred transaction is restored when the nav lease is regained`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + covered.startNavTransaction("/Result") + fixture.incoming.dispose() + val started = fixture.startedTransaction("/Result") + val nextIncoming = fixture.navScopes() + nextIncoming.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + nextIncoming.applyFirstBackStackUpdate() + check(fixture.scope.transaction == null) + + nextIncoming.dispose() + + assertThat(fixture.scope.transaction).isSameInstanceAs(started) + assertThat(fixture.startedTransactions).containsExactly(started) + } + + @Test + fun `regaining the nav lease with a started nav transaction logs no blocked nav transaction start`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + covered.startNavTransaction("/Result") + fixture.incoming.dispose() + val nextIncoming = fixture.navScopes() + nextIncoming.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + nextIncoming.applyFirstBackStackUpdate() + + nextIncoming.dispose() + + verify(fixture.logger, never()).log(eq(SentryLevel.DEBUG), any(), eq("/Result")) + } + + @Test + fun `a started deferred transaction is finished on dispose`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + covered.startNavTransaction("/Result") + fixture.incoming.dispose() + val started = fixture.startedTransaction("/Result") + + covered.dispose() + + verify(started).finish(any()) + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `a deferred transaction does not start while a host transaction is bound`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + covered.startNavTransaction("/Result") + val hostTransaction = mock() + fixture.scope.transaction = hostTransaction + + fixture.incoming.dispose() + + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `a deferred transaction blocked by a host transaction logs its route`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + covered.startNavTransaction("/Result") + fixture.scope.transaction = mock() + + fixture.incoming.dispose() + + verify(fixture.logger).log(eq(SentryLevel.DEBUG), any(), eq("/Result")) + } + + @Test + fun `a deferred transaction starts in place of a finished bound transaction`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + covered.startNavTransaction("/Result") + fixture.scope.transaction = + mock().also { whenever(it.isFinished).thenReturn(true) } + + fixture.incoming.dispose() + + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/Result")) + } + + @Test + fun `a deferred transaction requested while a finished transaction is bound starts in its place`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + fixture.scope.transaction = + mock().also { whenever(it.isFinished).thenReturn(true) } + covered.startNavTransaction("/Result") + + fixture.incoming.dispose() + + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/Result")) + } + + @Test + fun `a finished deferred transaction does not start when the nav lease is acquired`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + covered.startNavTransaction("/Result") + covered.finishNavTransaction() + + fixture.incoming.dispose() + + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `only the most recently deferred transaction starts when the nav lease is acquired`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + covered.startNavTransaction("/Result") + covered.finishNavTransaction() + covered.startNavTransaction("/Receipt") + + fixture.incoming.dispose() + + assertThat(fixture.startedTransactions.map { it.name }).containsExactly("/Receipt") + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/Receipt")) + } + + @Test + fun `a deferred transaction interrupted by a failed nav lease acquisition starts on the next one`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + covered.configureScope { it.screen = "/Result" } + covered.startNavTransaction("/Result") + val failure = IllegalStateException("observer failure") + var observerThrows = true + fixture.scope.options.addScopeObserver( + object : ScopeObserverAdapter() { + override fun setContexts(contexts: Contexts) { + if (observerThrows) throw failure + } + } + ) + val thrown = assertThrows(IllegalStateException::class.java) { fixture.incoming.dispose() } + assertThat(thrown).isSameInstanceAs(failure) + check(fixture.startedTransactions.isEmpty()) + observerThrows = false + val nextIncoming = fixture.navScopes() + nextIncoming.attach(fixture.lifecycle(Lifecycle.State.RESUMED)) + nextIncoming.applyFirstBackStackUpdate() + check(!fixture.hasLease(covered)) + + nextIncoming.dispose() assertThat(fixture.hasLease(covered)).isTrue() + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/Result")) + } + + @Test + fun `a deferred transaction does not start after dispose`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + covered.startNavTransaction("/Result") + + covered.dispose() + fixture.incoming.dispose() + + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `a declined deferred start binds no transaction but still rotates the propagation context`() { + val fixture = Fixture.create() + val covered = fixture.coveredNavScopes() + val incomingContext = fixture.scope.propagationContext + covered.startNavTransaction("/Result") + fixture.declinesTransactions = true + + fixture.incoming.dispose() + + assertThat(fixture.scope.transaction).isNull() assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(incomingContext) } + /** Starts a nav transaction for [name] through the [INavScope] handed out by [NavScopes]. */ + private fun NavScopes.startNavTransaction(name: String): INavTransaction? { + var transaction: INavTransaction? = null + configureScope { + transaction = it.startNavTransaction(TransactionContext(name, "op"), TransactionOptions()) + } + return transaction + } + + /** Finishes the nav transaction through the [INavScope] handed out by [NavScopes]. */ + private fun NavScopes.finishNavTransaction() { + configureScope { it.finishNavTransaction() } + } + private fun NavScopes.applyFirstBackStackUpdate() { onBackStackUpdate() } @@ -169,16 +672,27 @@ class NavScopesTest { private class TestLifecycleOwner(initialState: Lifecycle.State = Lifecycle.State.CREATED) : LifecycleOwner { override val lifecycle: LifecycleRegistry = - LifecycleRegistry.createUnsafe(this).apply { currentState = initialState } + LifecycleRegistry.createUnsafe(this).apply { + // A lifecycle must be created before it can be destroyed. + currentState = Lifecycle.State.CREATED + currentState = initialState + } } - private class Fixture { + private class Fixture private constructor() { var isEnabled = true val scope = Scope(SentryOptions()) val coordinator = NavLeaseCoordinator() + val logger = mock() val scopes = mock().also { scopes -> - whenever(scopes.options).thenReturn(SentryOptions()) + whenever(scopes.options) + .thenReturn( + SentryOptions().apply { + setLogger(this@Fixture.logger) + isDebug = true + } + ) doAnswer { if (isEnabled) { (it.arguments[0] as ScopeCallback).run(scope) @@ -188,11 +702,61 @@ class NavScopesTest { .whenever(scopes) .configureScope(any()) whenever(scopes.startTransaction(any(), any())) - .thenAnswer { mock() } + .thenAnswer { + if (declinesTransactions) { + NoOpTransaction.getInstance() + } else { + val name = (it.arguments[0] as TransactionContext).name + mock() + .also { transaction -> whenever(transaction.name).thenReturn(name) } + .also(startedTransactions::add) + } + } } + /** Whether [scopes] declines to start transactions, returning a no-op transaction instead. */ + var declinesTransactions = false + val startedTransactions = mutableListOf() + + /** The resumed leaseholder that covers the [NavScopes] returned by [coveredNavScopes]. */ + lateinit var incoming: NavScopes + + /** + * `LifecycleRegistry` holds its owner weakly and throws once it's collected, so the fixture + * keeps the owners created by [lifecycle] alive for the test. + */ + private val lifecycleOwners = mutableListOf() + fun navScopes(): NavScopes = NavScopes(scopes, coordinator) + fun lifecycle(state: Lifecycle.State = Lifecycle.State.CREATED): LifecycleRegistry = + TestLifecycleOwner(state).also(lifecycleOwners::add).lifecycle + + fun startedTransaction(name: String): ITransaction = startedTransactions.single { + it.name == name + } + + /** + * Returns a resumed [NavScopes] that applied its first back stack update, started a nav + * transaction for [startedRoute] if given, and then lost the nav lease to the resumed + * [incoming] one. It regains the lease when [incoming] is disposed. + */ + fun coveredNavScopes(startedRoute: String? = null): NavScopes { + val covered = navScopes() + covered.attach(lifecycle(Lifecycle.State.RESUMED)) + covered.onBackStackUpdate() + if (startedRoute != null) { + covered.configureScope { + it.startNavTransaction(TransactionContext(startedRoute, "op"), TransactionOptions()) + } + } + incoming = navScopes() + incoming.attach(lifecycle(Lifecycle.State.RESUMED)) + incoming.onBackStackUpdate() + check(!hasLease(covered)) + return covered + } + private var leaseProbeCount = 0 /** @@ -204,5 +768,13 @@ class NavScopesTest { navScopes.configureScope { it.screen = probe } return scope.screen == probe } + + companion object { + /** + * [declinesTransactions] can also be changed later, e.g., once a transaction was deferred. + */ + fun create(declinesTransactions: Boolean = false): Fixture = + Fixture().also { it.declinesTransactions = declinesTransactions } + } } } diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavTransactionTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavTransactionTest.kt new file mode 100644 index 00000000000..1eca057f395 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavTransactionTest.kt @@ -0,0 +1,348 @@ +package io.sentry.compose.navigation3.lifecycle + +import com.google.common.truth.Truth.assertThat +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.NoOpTransaction +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.App +import io.sentry.protocol.Contexts +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder +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 + +class NavTransactionTest { + + private val transactionContext = TransactionContext("/Checkout", "navigation") + private val transactionOptions = TransactionOptions() + + private val app = App().apply { viewNames = listOf("/Checkout") } + private val navigationContext = mapOf("Back Stack" to listOf(mapOf("entry" to "/Checkout"))) + + @Test + fun `started() - is named after the transaction`() { + val fixture = Fixture.create() + + val handle = NavTransaction.started(fixture.transaction) + + assertThat(handle.name).isEqualTo("/Checkout") + } + + @Test + fun `deferred() - is named after the transaction context`() { + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + + assertThat(handle.name).isEqualTo("/Checkout") + } + + @Test + fun `transaction - is null for a deferred handle`() { + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + + assertThat(handle.transaction).isNull() + } + + @Test + fun `transaction - is the backing transaction for a started handle`() { + val fixture = Fixture.create() + + val handle = NavTransaction.started(fixture.transaction) + + assertThat(handle.transaction).isSameInstanceAs(fixture.transaction) + } + + @Test + fun `transaction - is the started transaction once a deferred handle has started`() { + val fixture = Fixture.create() + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + + handle.start(fixture.scopes) + + assertThat(handle.transaction).isSameInstanceAs(fixture.transaction) + } + + @Test + fun `transaction - stays null when the scopes decline to start a deferred handle's transaction`() { + val fixture = Fixture.create(declinesTransactions = true) + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + + handle.start(fixture.scopes) + + assertThat(handle.transaction).isNull() + } + + @Test + fun `transaction - is null once a deferred handle is finished before it starts`() { + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + + handle.finish(onFinished = {}) + + assertThat(handle.transaction).isNull() + } + + @Test + fun `a started handle sets data, contexts, and the app context on the transaction`() { + val fixture = Fixture.create() + val handle = NavTransaction.started(fixture.transaction) + + handle.setData("arguments", "id=1") + handle.setContext(NAVIGATION_CONTEXT_KEY, navigationContext) + handle.setApp(app) + + verify(fixture.transaction).setData("arguments", "id=1") + verify(fixture.transaction).setContext(NAVIGATION_CONTEXT_KEY, navigationContext) + assertThat(fixture.transaction.contexts.app).isSameInstanceAs(app) + } + + @Test + fun `a deferred handle sets data, contexts, and the app context on the transaction once it has started`() { + val fixture = Fixture.create() + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + handle.start(fixture.scopes) + + handle.setData("arguments", "id=1") + handle.setContext(NAVIGATION_CONTEXT_KEY, navigationContext) + handle.setApp(app) + + verify(fixture.transaction).setData("arguments", "id=1") + verify(fixture.transaction).setContext(NAVIGATION_CONTEXT_KEY, navigationContext) + assertThat(fixture.transaction.contexts.app).isSameInstanceAs(app) + } + + @Test + fun `start() - starts and returns the transaction with the deferred context and options`() { + val fixture = Fixture.create() + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + + val started = handle.start(fixture.scopes) + + verify(fixture.scopes).startTransaction(transactionContext, transactionOptions) + assertThat(started).isSameInstanceAs(fixture.transaction) + } + + @Test + fun `start() - replays data, contexts, and the app context set before the start`() { + val fixture = Fixture.create() + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + handle.setData("arguments", "id=1") + handle.setContext(NAVIGATION_CONTEXT_KEY, navigationContext) + handle.setApp(app) + + handle.start(fixture.scopes) + + verify(fixture.transaction).setData("arguments", "id=1") + verify(fixture.transaction).setContext(NAVIGATION_CONTEXT_KEY, navigationContext) + assertThat(fixture.transaction.contexts.app).isSameInstanceAs(app) + } + + @Test + fun `start() - replays only the latest value set for each key`() { + val fixture = Fixture.create() + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + val latestNavigationContext = mapOf("Back Stack" to listOf(mapOf("entry" to "/Receipt"))) + val latestApp = App().apply { viewNames = listOf("/Receipt") } + handle.setData("arguments", "id=1") + handle.setData("arguments", "id=2") + handle.setContext(NAVIGATION_CONTEXT_KEY, navigationContext) + handle.setContext(NAVIGATION_CONTEXT_KEY, latestNavigationContext) + handle.setApp(app) + handle.setApp(latestApp) + + handle.start(fixture.scopes) + + verify(fixture.transaction, never()).setData("arguments", "id=1") + verify(fixture.transaction).setData("arguments", "id=2") + verify(fixture.transaction, never()).setContext(NAVIGATION_CONTEXT_KEY, navigationContext) + verify(fixture.transaction).setContext(NAVIGATION_CONTEXT_KEY, latestNavigationContext) + assertThat(fixture.transaction.contexts.app).isSameInstanceAs(latestApp) + } + + @Test + fun `start() - returns null when the scopes decline to start a transaction`() { + val fixture = Fixture.create(declinesTransactions = true) + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + + val started = handle.start(fixture.scopes) + + assertThat(started).isNull() + } + + @Test + fun `start() - starts the transaction on a retry after the scopes declined to start one`() { + val fixture = Fixture.create(declinesTransactions = true) + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + handle.setData("arguments", "id=1") + handle.start(fixture.scopes) + fixture.declinesTransactions = false + + val started = handle.start(fixture.scopes) + + assertThat(started).isSameInstanceAs(fixture.transaction) + verify(fixture.transaction).setData("arguments", "id=1") + } + + @Test + fun `start() - starts no transaction a second time`() { + val fixture = Fixture.create() + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + handle.start(fixture.scopes) + + val started = handle.start(fixture.scopes) + + assertThat(started).isNull() + verify(fixture.scopes).startTransaction(any(), any()) + } + + @Test + fun `start() - starts no transaction for a started handle`() { + val fixture = Fixture.create() + val handle = NavTransaction.started(fixture.transaction) + + val started = handle.start(fixture.scopes) + + assertThat(started).isNull() + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `finish() - finishes a started handle's transaction with the transaction's status`() { + val fixture = Fixture.create() + whenever(fixture.transaction.status).thenReturn(SpanStatus.CANCELLED) + val handle = NavTransaction.started(fixture.transaction) + + handle.finish(onFinished = {}) + + verify(fixture.transaction).finish(SpanStatus.CANCELLED) + } + + @Test + fun `finish() - finishes a started handle's transaction with OK when the transaction has no status`() { + val fixture = Fixture.create() + val handle = NavTransaction.started(fixture.transaction) + + handle.finish(onFinished = {}) + + verify(fixture.transaction).finish(SpanStatus.OK) + } + + @Test + fun `finish() - passes a started handle's transaction to the callback after finishing it`() { + val fixture = Fixture.create() + val onFinished = mock<(ITransaction) -> Unit>() + val handle = NavTransaction.started(fixture.transaction) + + handle.finish(onFinished) + + inOrder(fixture.transaction, onFinished) { + verify(fixture.transaction).finish(SpanStatus.OK) + verify(onFinished).invoke(fixture.transaction) + } + } + + @Test + fun `finish() - doesn't finish a started handle's transaction that has already finished`() { + val fixture = Fixture.create() + whenever(fixture.transaction.isFinished).thenReturn(true) + val handle = NavTransaction.started(fixture.transaction) + + handle.finish(onFinished = {}) + + verify(fixture.transaction, never()).finish(any()) + } + + @Test + fun `finish() - passes a started handle's already finished transaction to the callback`() { + val fixture = Fixture.create() + val onFinished = mock<(ITransaction) -> Unit>() + whenever(fixture.transaction.isFinished).thenReturn(true) + val handle = NavTransaction.started(fixture.transaction) + + handle.finish(onFinished) + + verify(onFinished).invoke(fixture.transaction) + } + + @Test + fun `finish() - finishes a deferred handle's transaction once it has started`() { + val fixture = Fixture.create() + val onFinished = mock<(ITransaction) -> Unit>() + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + handle.start(fixture.scopes) + + handle.finish(onFinished) + + verify(fixture.transaction).finish(SpanStatus.OK) + verify(onFinished).invoke(fixture.transaction) + } + + @Test + fun `finish() - discards a pending handle so that it never starts a transaction`() { + val fixture = Fixture.create() + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + + handle.finish(onFinished = {}) + val started = handle.start(fixture.scopes) + + assertThat(started).isNull() + verify(fixture.scopes, never()).startTransaction(any(), any()) + } + + @Test + fun `finish() - does not call back for a pending handle`() { + val onFinished = mock<(ITransaction) -> Unit>() + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + + handle.finish(onFinished) + + verifyNoInteractions(onFinished) + } + + @Test + fun `a discarded handle ignores later writes and finishes`() { + val fixture = Fixture.create() + val onFinished = mock<(ITransaction) -> Unit>() + val handle = NavTransaction.deferred(transactionContext, transactionOptions) + handle.finish(onFinished = {}) + + handle.setData("arguments", "id=1") + handle.setContext(NAVIGATION_CONTEXT_KEY, navigationContext) + handle.setApp(app) + handle.finish(onFinished) + val started = handle.start(fixture.scopes) + + assertThat(started).isNull() + verifyNoInteractions(fixture.transaction, onFinished) + } + + private class Fixture private constructor(val scopes: IScopes, val transaction: ITransaction) { + + /** Whether [scopes] declines to start transactions, returning a no-op transaction instead. */ + var declinesTransactions = false + + companion object { + fun create(declinesTransactions: Boolean = false): Fixture { + val contexts = Contexts() + val transaction = + mock().also { + whenever(it.name).thenReturn("/Checkout") + whenever(it.contexts).thenReturn(contexts) + } + val scopes = mock() + val fixture = Fixture(scopes, transaction) + fixture.declinesTransactions = declinesTransactions + whenever(scopes.startTransaction(any(), any())) + .thenAnswer { + if (fixture.declinesTransactions) NoOpTransaction.getInstance() else transaction + } + return fixture + } + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavTransactionTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavTransactionTest.kt new file mode 100644 index 00000000000..e732db751e9 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavTransactionTest.kt @@ -0,0 +1,554 @@ +package io.sentry.compose.navigation3.lifecycle + +import com.google.common.truth.Truth.assertThat +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.NoOpTransaction +import io.sentry.Scope +import io.sentry.ScopeObserverAdapter +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import org.junit.Assert.assertThrows +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@Suppress("LargeClass") +class StagedNavTransactionTest { + + private val transactionContext = TransactionContext("/Profile", "navigation") + private val transactionOptions = TransactionOptions() + + private val finishedTransaction = + mock().also { whenever(it.isFinished).thenReturn(true) } + private val hostTransaction = mock() + + @Test + fun `start() - starts a nav transaction and binds it to a free scope`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + + val navTransaction = + stagedNavTransaction.start(fixture.scope, transactionContext, transactionOptions) + + val started = fixture.startedTransaction("/Profile") + verify(fixture.scopes).startTransaction(transactionContext, transactionOptions) + assertThat(navTransaction?.transaction).isSameInstanceAs(started) + assertThat(fixture.scope.transaction).isSameInstanceAs(started) + } + + @Test + fun `start() - starts no nav transaction while another transaction is bound`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + fixture.scope.transaction = hostTransaction + + val navTransaction = + stagedNavTransaction.start(fixture.scope, transactionContext, transactionOptions) + + assertThat(navTransaction).isNull() + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `start() - logs the route when another transaction blocks the start`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + fixture.scope.transaction = hostTransaction + + stagedNavTransaction.start(fixture.scope, transactionContext, transactionOptions) + + verify(fixture.logger).log(eq(SentryLevel.DEBUG), any(), eq("/Profile")) + } + + @Test + fun `start() - binds the nav transaction in place of a finished bound transaction`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + fixture.scope.transaction = finishedTransaction + + val navTransaction = + stagedNavTransaction.start(fixture.scope, transactionContext, transactionOptions) + + val started = fixture.startedTransaction("/Profile") + assertThat(navTransaction?.transaction).isSameInstanceAs(started) + assertThat(fixture.scope.transaction).isSameInstanceAs(started) + } + + @Test + fun `start() - returns null and keeps the current nav transaction when the start is declined`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val current = fixture.start("/Home") + fixture.declinesTransactions = true + + val navTransaction = + stagedNavTransaction.start(fixture.scope, transactionContext, transactionOptions) + + assertThat(navTransaction).isNull() + assertThat(fixture.scope.transaction).isSameInstanceAs(current) + stagedNavTransaction.finish(fixture.scope) + verify(current).finish(SpanStatus.OK) + } + + @Test + fun `start() - replaces the current nav transaction without finishing it`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val current = fixture.start("/Home") + // Unbound, so that it doesn't block the start. + stagedNavTransaction.unpublishFrom(fixture.scope) + + stagedNavTransaction.start(fixture.scope, transactionContext, transactionOptions) + verify(current, never()).finish(any()) + + stagedNavTransaction.finish(fixture.scope) + verify(fixture.startedTransaction("/Profile")).finish(SpanStatus.OK) + verify(current, never()).finish(any()) + } + + @Test + fun `start() - keeps the started nav transaction when binding it throws`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + fixture.transactionObserverThrows = true + + assertThrows(ThrowingScopeObserverException::class.java) { + stagedNavTransaction.start(fixture.scope, transactionContext, transactionOptions) + } + fixture.transactionObserverThrows = false + val started = fixture.startedTransaction("/Profile") + stagedNavTransaction.finish(fixture.scope) + + verify(started).finish(SpanStatus.OK) + } + + @Test + fun `stage() - returns a pending nav transaction without starting a transaction`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + + val navTransaction = stagedNavTransaction.stage(transactionContext, transactionOptions) + + assertThat(navTransaction.transaction).isNull() + assertThat(navTransaction.name).isEqualTo("/Profile") + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `stage() - replaces the current nav transaction without finishing it`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val current = fixture.start("/Home") + + stagedNavTransaction.stage(transactionContext, transactionOptions) + stagedNavTransaction.finish(fixture.scope) + + verify(current, never()).finish(any()) + } + + @Test + fun `publishTo() - starts a staged nav transaction and binds it to a free scope`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val navTransaction = stagedNavTransaction.stage(transactionContext, transactionOptions) + + stagedNavTransaction.publishTo(fixture.scope) + + val started = fixture.startedTransaction("/Profile") + assertThat(navTransaction.transaction).isSameInstanceAs(started) + assertThat(fixture.scope.transaction).isSameInstanceAs(started) + } + + @Test + fun `publishTo() - starts a staged nav transaction in place of a finished bound transaction`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + stagedNavTransaction.stage(transactionContext, transactionOptions) + fixture.scope.transaction = finishedTransaction + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(fixture.startedTransaction("/Profile")) + } + + @Test + fun `publishTo() - doesn't start a staged nav transaction while another transaction is bound`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + stagedNavTransaction.stage(transactionContext, transactionOptions) + fixture.scope.transaction = hostTransaction + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `publishTo() - logs the route when another transaction blocks the nav transaction`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + stagedNavTransaction.stage(transactionContext, transactionOptions) + fixture.scope.transaction = hostTransaction + + stagedNavTransaction.publishTo(fixture.scope) + + verify(fixture.logger).log(eq(SentryLevel.DEBUG), any(), eq("/Profile")) + } + + @Test + fun `publishTo() - discards a staged nav transaction blocked by another transaction`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val navTransaction = stagedNavTransaction.stage(transactionContext, transactionOptions) + fixture.scope.transaction = hostTransaction + stagedNavTransaction.publishTo(fixture.scope) + fixture.scope.clearTransaction() + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(navTransaction.start(fixture.scopes)).isNull() + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `publishTo() - binds nothing when a staged nav transaction's start is declined`() { + val fixture = Fixture.create(declinesTransactions = true) + val stagedNavTransaction = fixture.stagedNavTransaction + stagedNavTransaction.stage(transactionContext, transactionOptions) + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `publishTo() - discards a staged nav transaction whose start was declined`() { + val fixture = Fixture.create(declinesTransactions = true) + val stagedNavTransaction = fixture.stagedNavTransaction + stagedNavTransaction.stage(transactionContext, transactionOptions) + stagedNavTransaction.publishTo(fixture.scope) + fixture.declinesTransactions = false + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `publishTo() - binds an unpublished unfinished nav transaction again`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val started = fixture.start() + stagedNavTransaction.unpublishFrom(fixture.scope) + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(started) + assertThat(fixture.startedTransactions).containsExactly(started) + } + + @Test + fun `publishTo() - keeps the bound nav transaction bound`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val started = fixture.start() + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(started) + verify(fixture.logger, never()).log(eq(SentryLevel.DEBUG), any(), any()) + } + + @Test + fun `publishTo() - binds an unpublished nav transaction in place of a finished bound transaction`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val started = fixture.start() + stagedNavTransaction.unpublishFrom(fixture.scope) + fixture.scope.transaction = finishedTransaction + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(started) + } + + @Test + fun `publishTo() - doesn't bind an unpublished nav transaction while another transaction is bound`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val started = fixture.start() + stagedNavTransaction.unpublishFrom(fixture.scope) + fixture.scope.transaction = hostTransaction + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + stagedNavTransaction.finish(fixture.scope) + verify(started).finish(SpanStatus.OK) + } + + @Test + fun `publishTo() - forgets and unbinds a bound nav transaction that has finished`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val started = fixture.start() + whenever(started.isFinished).thenReturn(true) + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `publishTo() - forgets an unpublished nav transaction that has finished and keeps another transaction`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val started = fixture.start() + stagedNavTransaction.unpublishFrom(fixture.scope) + whenever(started.isFinished).thenReturn(true) + fixture.scope.transaction = hostTransaction + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `publishTo() - leaves the scope untouched without a nav transaction`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + fixture.scope.transaction = finishedTransaction + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(finishedTransaction) + } + + @Test + fun `unpublishFrom() - unbinds the started nav transaction without finishing it`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val started = fixture.start() + + stagedNavTransaction.unpublishFrom(fixture.scope) + + assertThat(fixture.scope.transaction).isNull() + verify(started, never()).finish(any()) + } + + @Test + fun `unpublishFrom() - keeps another bound transaction`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + fixture.start() + stagedNavTransaction.unpublishFrom(fixture.scope) + fixture.scope.transaction = hostTransaction + + stagedNavTransaction.unpublishFrom(fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `unpublishFrom() - keeps the bound transaction while the nav transaction is staged`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val navTransaction = stagedNavTransaction.stage(transactionContext, transactionOptions) + fixture.scope.transaction = hostTransaction + + stagedNavTransaction.unpublishFrom(fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + assertThat(navTransaction.transaction).isNull() + } + + @Test + fun `finish() - finishes the started nav transaction and unbinds it`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val started = fixture.start() + + stagedNavTransaction.finish(fixture.scope) + + verify(started).finish(SpanStatus.OK) + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `finish() - finishes an unpublished nav transaction and keeps another bound transaction`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val started = fixture.start() + stagedNavTransaction.unpublishFrom(fixture.scope) + fixture.scope.transaction = hostTransaction + + stagedNavTransaction.finish(fixture.scope) + + verify(started).finish(SpanStatus.OK) + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `finish() - forgets the nav transaction so that publishing never binds it again`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + fixture.start() + stagedNavTransaction.finish(fixture.scope) + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `finish() - discards a staged nav transaction so that publishing never starts it`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val navTransaction = stagedNavTransaction.stage(transactionContext, transactionOptions) + stagedNavTransaction.finish(fixture.scope) + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(navTransaction.start(fixture.scopes)).isNull() + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `finish() - forgets the nav transaction when unbinding it throws`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val started = fixture.start() + fixture.transactionObserverThrows = true + + assertThrows(ThrowingScopeObserverException::class.java) { + stagedNavTransaction.finish(fixture.scope) + } + stagedNavTransaction.finish(fixture.scope) + + // Finished once: the second call has no nav transaction left to finish or unbind. + verify(started).finish(SpanStatus.OK) + } + + @Test + fun `finish() - leaves the scope untouched without a nav transaction`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + fixture.scope.transaction = hostTransaction + + stagedNavTransaction.finish(fixture.scope) + + verify(hostTransaction, never()).finish(any()) + assertThat(fixture.scope.transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `forget() - forgets the started nav transaction without finishing or unbinding it`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + val started = fixture.start() + + stagedNavTransaction.forget() + stagedNavTransaction.finish(fixture.scope) + + assertThat(fixture.scope.transaction).isSameInstanceAs(started) + verify(started, never()).finish(any()) + } + + @Test + fun `forget() - forgets a staged nav transaction so that publishing never starts it`() { + val fixture = Fixture.create() + val stagedNavTransaction = fixture.stagedNavTransaction + stagedNavTransaction.stage(transactionContext, transactionOptions) + stagedNavTransaction.forget() + + stagedNavTransaction.publishTo(fixture.scope) + + assertThat(fixture.startedTransactions).isEmpty() + assertThat(fixture.scope.transaction).isNull() + } + + /** Thrown by a scope observer, to stand in for one the host app registered. */ + private class ThrowingScopeObserverException : RuntimeException() + + private class Fixture private constructor() { + val scope = Scope(SentryOptions()) + val logger = mock() + + /** Whether [scopes] declines to start transactions, returning a no-op transaction instead. */ + var declinesTransactions = false + val startedTransactions = mutableListOf() + + /** Whether a scope observer throws when the scope's transaction is bound or unbound. */ + var transactionObserverThrows = false + + val scopes = + mock().also { scopes -> + whenever(scopes.options) + .thenReturn( + SentryOptions().apply { + setLogger(this@Fixture.logger) + isDebug = true + } + ) + whenever(scopes.startTransaction(any(), any())) + .thenAnswer { + if (declinesTransactions) { + NoOpTransaction.getInstance() + } else { + val name = (it.arguments[0] as TransactionContext).name + mock() + .also { transaction -> whenever(transaction.name).thenReturn(name) } + .also(startedTransactions::add) + } + } + } + + val stagedNavTransaction = StagedNavTransaction(scopes) + + init { + scope.options.addScopeObserver( + object : ScopeObserverAdapter() { + override fun setTransaction(transaction: String?) { + if (transactionObserverThrows) { + throw ThrowingScopeObserverException() + } + } + } + ) + } + + fun startedTransaction(name: String): ITransaction = startedTransactions.single { + it.name == name + } + + /** Starts a nav transaction for [name], binding it if the scope is free, and returns it. */ + fun start(name: String = "/Profile"): ITransaction { + stagedNavTransaction.start( + scope, + TransactionContext(name, "navigation"), + TransactionOptions(), + ) + return startedTransaction(name) + } + + companion object { + fun create(declinesTransactions: Boolean = false): Fixture = + Fixture().also { it.declinesTransactions = declinesTransactions } + } + } +} 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 index e3c92bf8d7b..789bce20015 100644 --- 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 @@ -297,6 +297,7 @@ internal fun MultiRootNavDisplay( maxCapturedBackStackEntries = configuration.maxCapturedBackStackEntries } } + // Keep this before the NavDisplay below: TracedNav3Route's resume tagging relies on it. SentryNavEffect( backStack = backStack, backStackEntryMapper = { route -> 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 64e3ee41f39..a67ed6d9d4e 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 @@ -46,6 +46,7 @@ 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 androidx.lifecycle.compose.LifecycleResumeEffect import io.sentry.ITransaction import io.sentry.Sentry import io.sentry.compose.SentryModifier.sentryTag @@ -80,6 +81,19 @@ internal fun TracedNav3Route( val hostName = LocalNav3SampleHost.current ?: activityName // Tag after composition is applied so abandoned compositions don't tag anything. SideEffect { tagCurrentNav3Scenario(route, scenario, hostName, activityName) } + // Tag again on resume: a root can get its nav transaction without composing (e.g., a ViewPager2 + // page composes while it's dragged into view, but only gets the nav lease and a transaction once + // it resumes). That transaction would otherwise go untagged and be missing from the history. + // + // This relies on the transaction starting before this effect runs. It does, as long as the root's + // SentryNavEffect starts observing the host's lifecycle before its NavDisplay does, i.e., it's + // composed first (see MultiRootNavDisplay) and isn't recreated afterwards (e.g., by new + // SentryNavOptions). The NavDisplay's scene and entry lifecycle owners are children of the host's + // lifecycle, so they only resume once its earlier observers have handled the resume. + LifecycleResumeEffect(route, scenario, hostName, activityName) { + tagCurrentNav3Scenario(route, scenario, hostName, activityName) + onPauseOrDispose {} + } SentryTraced( tag = "Nav3 /${route.routeName}", enableUserInteractionTracing = false, From d845ddb04d3d63a393f6397519e9e40dd43bba26 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Wed, 4 Nov 2026 14:56:05 +0100 Subject: [PATCH 29/40] fix(tracing): Stop idle spans from keeping idle transactions alive until their deadline (JAVA-795) (#6396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An idle span (SpanOptions.isIdle) is meant to be finished by its parent transaction when the transaction finishes. Before this commit, SentryTracer treated an unfinished idle span as blocking. Creating one canceled the idle timer; SentryTracer.hasAllChildrenFinished() counted it as unfinished; and the timer never restarted. As a consequence, any idle transaction that i) waited for its children (i.e., all Android idle transactions, including ui.load and nav transactions) and ii) had an unfinished idle span was kept alive until its deadline (30 s by default), causing it to be reported as DEADLINE_EXCEEDED even though in most cases no actual work was keeping the transaction alive. **Current producers of idle spans** SentryTraced is the SDK's only producer of idle spans, so the effects mentioned above are limited to idle transactions that, prior to this commit, contained a span from SentryTraced. (The issue has been with SentryTraced from the beginning – see #2507). --- CHANGELOG.md | 6 +++ .../io/sentry/compose/SentryTracedTest.kt | 38 +++++++++++++-- .../src/main/java/io/sentry/SentryTracer.java | 18 ++++++- .../src/main/java/io/sentry/SpanOptions.java | 7 ++- .../test/java/io/sentry/SentryTracerTest.kt | 48 +++++++++++++++++++ 5 files changed, 110 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c025af4eee6..8b03d80294f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ - Speed up Session Replay and screenshot masking for large `maskViewClasses`/`unmaskViewClasses` lists by using constant-time class lookups ([#6410](https://github.com/getsentry/sentry-java/pull/6410)) - Skip ANR profiling while a debugger is connected ([#6389](https://github.com/getsentry/sentry-java/pull/6389)) +### Behavioral Changes + +- Idle spans, including those produced by `SentryTraced`, now let any idle parent transaction finish after the idle timeout (3s by default) with status `ok`, rather than waiting for the deadline (30s by default) and reporting `deadline_exceeded` ([#6396](https://github.com/getsentry/sentry-java/pull/6396)) + - If you use `SentryTraced`, expect shorter durations and lower failure rates for affected `ui.load` and navigation transactions, plus changes to metrics measured over the transaction's lifetime, such as slow and frozen frames. + - Cold and warm app start, TTID, and TTFD remain unchanged. + ### Fixes - Fix missing Navigation 3 transactions for preloaded ViewPager2 pages and destinations reached while another Activity or Fragment owns navigation tracking ([#6400](https://github.com/getsentry/sentry-java/pull/6400)) diff --git a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt index 431ce297c46..935fb6933d5 100644 --- a/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt +++ b/sentry-compose/src/androidUnitTest/kotlin/io/sentry/compose/SentryTracedTest.kt @@ -31,6 +31,7 @@ import io.sentry.ITransaction import io.sentry.NoOpSpan import io.sentry.Sentry import io.sentry.SentryOptions +import io.sentry.SpanStatus import io.sentry.TransactionOptions import kotlin.test.assertFailsWith import org.junit.After @@ -446,8 +447,30 @@ class SentryTracedTest { assertThat(renderingTx.countSpans(OP_RENDER)).isEqualTo(0) } + @Test + fun `an idle transaction hosting a traced composable finishes after the idle timeout`() { + val tx = + initSentryAndStartTransaction( + "tx", + configureTransactionOptions = { transactionOptions -> + transactionOptions.idleTimeout = 1_000 + transactionOptions.isWaitForChildren = true + }, + ) + + rule.setContent { + ProvideSentrySpan(tx) { SentryTraced(tag = "product_info") { Box(Modifier.size(1.dp)) } } + } + tx.waitForSpanCount(OP_COMPOSE, 1) + + rule.waitUntil(timeoutMillis = 5_000) { tx.isFinished } + assertThat(tx.status).isEqualTo(SpanStatus.OK) + assertThat(tx.singleSpan(OP_PARENT_COMPOSITION).isFinished).isTrue() + } + private fun initSentryAndStartTransaction( name: String, + configureTransactionOptions: (TransactionOptions) -> Unit = {}, configureOptions: (SentryOptions) -> Unit = {}, ): ITransaction { lateinit var tx: ITransaction @@ -460,7 +483,7 @@ class SentryTracedTest { }, true, ) - tx = startBoundTransaction(name) + tx = startBoundTransaction(name, configureTransactionOptions) var boundTransaction: ITransaction? = null Sentry.configureScope { boundTransaction = it.transaction } assertThat(boundTransaction).isSameInstanceAs(tx) @@ -468,9 +491,16 @@ class SentryTracedTest { return tx } - private fun startBoundTransaction(name: String): ITransaction { - val transaction = - Sentry.startTransaction(name, "test", TransactionOptions().apply { isBindToScope = true }) + private fun startBoundTransaction( + name: String, + configureTransactionOptions: (TransactionOptions) -> Unit = {}, + ): ITransaction { + val transactionOptions = + TransactionOptions().apply { + isBindToScope = true + configureTransactionOptions(this) + } + val transaction = Sentry.startTransaction(name, "test", transactionOptions) Sentry.configureScope { it.setTransaction(transaction) } return transaction } diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 475729f3b6a..cc1ce8e22ac 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -469,7 +469,13 @@ private ISpan createChild( if (children.size() < scopes.getOptions().getMaxSpans()) { Objects.requireNonNull(parentSpanId, "parentSpanId is required"); Objects.requireNonNull(operation, "operation is required"); - cancelIdleTimer(); + + // When this transaction finishes, we want its idle spans to finish as well. We don't let idle + // spans cancel the idle timer b/c it would keep the transaction alive until its deadline. + if (!spanOptions.isIdle()) { + cancelIdleTimer(); + } + final Span span = new Span( this, @@ -768,10 +774,20 @@ private void updateBaggageValues(final @NotNull Baggage baggage) { return null; } + /** + * Returns true if no child span should keep this transaction from finishing. + * + *

Ignores idle spans. Including them would mean an idle transaction would never restart its + * idle timer once it had an unfinished idle span, causing the transaction to always live until + * its deadline. + */ private boolean hasAllChildrenFinished() { @NotNull final ListIterator iterator = this.children.listIterator(); while (iterator.hasNext()) { @NotNull final Span span = iterator.next(); + if (span.getOptions().isIdle()) { + continue; + } // This is used in the spanFinishCallback, when the span isn't finished, but has a finish // date if (!span.isFinished() && span.getFinishDate() == null) { diff --git a/sentry/src/main/java/io/sentry/SpanOptions.java b/sentry/src/main/java/io/sentry/SpanOptions.java index 69f93e00a9e..effe3449a53 100644 --- a/sentry/src/main/java/io/sentry/SpanOptions.java +++ b/sentry/src/main/java/io/sentry/SpanOptions.java @@ -48,8 +48,11 @@ public void setStartTimestamp(@Nullable SentryDate startTimestamp) { private boolean trimEnd = false; /** - * true if the span is considered idle and should be automatically finished when it's parent gets - * finished. + * `true` if the span is idle, in which case its transaction finishes it when the transaction + * finishes, with the transaction's status and end time. + * + *

Note: An unfinished idle span doesn't keep a transaction that waits for its children from + * finishing, but non-idle spans created under it do. */ private boolean isIdle = false; diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index de23f5be2af..20232f56b4d 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -1224,6 +1224,54 @@ class SentryTracerTest { assertNull(transaction.idleTimeoutFuture) } + @Test + fun `when idle transaction has only an unfinished idle span, finishes the transaction and the idle span after the idle timeout`() { + val transaction = fixture.getSut(waitForChildren = true, idleTimeout = 50) + + val idleSpan = transaction.startChild("ui.load", null, SpanOptions().apply { isIdle = true }) + + await.untilAsserted { + verify(fixture.scopes).captureTransaction(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + } + assertThat(transaction.status).isEqualTo(SpanStatus.OK) + assertThat(idleSpan.isFinished).isTrue() + assertThat(idleSpan.status).isEqualTo(SpanStatus.OK) + } + + @Test + fun `when idle transaction has an idle span with a finished child, finishes the transaction after the idle timeout`() { + val transaction = fixture.getSut(waitForChildren = true, idleTimeout = 50) + val idleSpan = transaction.startChild("ui.load", null, SpanOptions().apply { isIdle = true }) + + idleSpan.startChild("ui.render").finish() + + await.untilAsserted { + verify(fixture.scopes).captureTransaction(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + } + assertThat(transaction.status).isEqualTo(SpanStatus.OK) + } + + @Test + fun `when idle transaction has an unfinished span under an idle span, does not finish the transaction until that span finishes`() { + val transaction = fixture.getSut(waitForChildren = true, idleTimeout = 50) + val idleSpan = transaction.startChild("ui.load", null, SpanOptions().apply { isIdle = true }) + val spanUnderIdleSpan = idleSpan.startChild("ui.render") + + transaction.startChild("op").finish() + Thread.sleep(200) + + assertThat(transaction.isFinished).isFalse() + verify(fixture.scopes, never()) + .captureTransaction(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + + spanUnderIdleSpan.finish() + + await.untilAsserted { + verify(fixture.scopes).captureTransaction(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + } + assertThat(transaction.status).isEqualTo(SpanStatus.OK) + } + @Test fun `when trimEnd, trims idle transaction time to the latest child timestamp`() { val transaction = From b05d9546386973497d8559a20ecd27797babfef9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:29:27 +0100 Subject: [PATCH 30/40] chore(deps): bump io.coil-kt:coil-compose from 2.6.0 to 2.7.0 (#6451) Bumps [io.coil-kt:coil-compose](https://github.com/coil-kt/coil) from 2.6.0 to 2.7.0. - [Release notes](https://github.com/coil-kt/coil/releases) - [Changelog](https://github.com/coil-kt/coil/blob/main/CHANGELOG.md) - [Commits](https://github.com/coil-kt/coil/compare/2.6.0...2.7.0) --- updated-dependencies: - dependency-name: io.coil-kt:coil-compose dependency-version: 2.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ff6e1e20dc1..94577c903d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -132,7 +132,7 @@ async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyn async-profiler-jfr-converter = { module = "tools.profiler:jfr-converter", version.ref = "asyncProfiler" } caffeine = { module = "com.github.ben-manes.caffeine:caffeine" } caffeine-jcache = { module = "com.github.ben-manes.caffeine:jcache", version = "3.2.0" } -coil-compose = { module = "io.coil-kt:coil-compose", version = "2.6.0" } +coil-compose = { module = "io.coil-kt:coil-compose", version = "2.7.0" } commons-compress = {module = "org.apache.commons:commons-compress", version = "1.28.0"} context-propagation = { module = "io.micrometer:context-propagation", version = "1.1.0" } errorprone-core = { module = "com.google.errorprone:error_prone_core", version = "2.50.0" } From 174d531639cbc352f3b73f9668d7af9d444ca575 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:22:52 +0100 Subject: [PATCH 31/40] chore: update scripts/update-sentry-native-ndk.sh to 0.17.6 (#6460) Co-authored-by: GitHub --- CHANGELOG.md | 6 +++--- gradle/libs.versions.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b03d80294f..b05ad9d9f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,9 +33,9 @@ - Bump epitaph (Android tombstone parsing) from 0.1.1 to 0.2.0 ([#6402](https://github.com/getsentry/sentry-java/pull/6402)) - Reject malformed protobuf tags, oversized lengths, invalid varints, and invalid reader bounds instead of silently accepting partial/corrupt tombstone data. -- Bump Native SDK from v0.17.4 to v0.17.5 ([#6409](https://github.com/getsentry/sentry-java/pull/6409)) - - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0175) - - [diff](https://github.com/getsentry/sentry-native/compare/0.17.4...0.17.5) +- Bump Native SDK from v0.17.4 to v0.17.6 ([#6409](https://github.com/getsentry/sentry-java/pull/6409), [#6460](https://github.com/getsentry/sentry-java/pull/6460)) + - [changelog](https://github.com/getsentry/sentry-native/blob/master/CHANGELOG.md#0176) + - [diff](https://github.com/getsentry/sentry-native/compare/0.17.4...0.17.6) ## 8.62.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 94577c903d9..65f51bc19a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -187,7 +187,7 @@ quartz = { module = "org.quartz-scheduler:quartz", version = "2.5.2" } reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } -sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.17.5" } +sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.17.6" } servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" } servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } From a29ab326c813a48df925d3ead69d43ef8c46cd5c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 5 Nov 2026 10:50:50 +0100 Subject: [PATCH 32/40] build: Assemble dist zips from a local Maven repo layout (#6467) * build: Assemble dist zips from a local Maven repo layout Craft expects Maven file names in each dist dir, e.g. --javadoc.jar. We used to copy build outputs and rename them to match, so the rules broke whenever a plugin changed its output names. The vanniktech maven-publish 0.30.0 -> 0.37.0 bump (#6412) added the version to the KMP javadoc jars, so sentry-compose--javadoc.jar was missing and the 8.63.0 publish failed. Now every module also publishes to build/distMaven and the dist zips copy from its Maven layout, where file names are fixed by Maven itself. Only the craft-specific names are renamed (pom-default.xml, -release.aar). The zip contents are unchanged. Co-Authored-By: Claude Opus 5.5 * ref(build): Make distMavenDir an extension property Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- build.gradle.kts | 6 ++ buildSrc/src/main/java/Publication.kt | 126 ++++++++------------------ 2 files changed, 42 insertions(+), 90 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a2f7bb27df8..6a42fe07a42 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -99,6 +99,7 @@ subprojects { tasks.named("distZip").configure { this.dependsOn("publishToMavenLocal") + this.dependsOn("publishAllPublicationsTo${DIST_MAVEN_REPO.replaceFirstChar { it.uppercase() }}Repository") val file = this.project.layout.buildDirectory.file("distributions${sep}${this.project.name}-${this.project.version}.zip").get().asFile this.doLast { if (!file.exists()) throw IllegalStateException("Distribution file: ${file.absolutePath} does not exist") @@ -129,6 +130,11 @@ subprojects { // this is needed for sentry-unity to consume our artifacts locally as proper maven publication configure { repositories { + // the dist zips craft publishes are assembled from this, see Publication.kt + maven { + name = DIST_MAVEN_REPO + url = distMavenDir.get().asFile.toURI() + } maven { name = "unityMaven" url = rootProject.layout.buildDirectory.file("unityMaven").get().asFile.toURI() diff --git a/buildSrc/src/main/java/Publication.kt b/buildSrc/src/main/java/Publication.kt index d545e6e32dc..c9251fe25c0 100644 --- a/buildSrc/src/main/java/Publication.kt +++ b/buildSrc/src/main/java/Publication.kt @@ -1,110 +1,56 @@ import org.gradle.api.Project import org.gradle.api.distribution.DistributionContainer import org.gradle.api.file.CopySpec -import java.io.File +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider -private object Consts { - val taskRegex = Regex("(.*)DistZip") -} +/** + * Name of the per-project Maven repository the dist zips are assembled from. + * `distZip` depends on `publishAllPublicationsToDistMavenRepository`. + */ +const val DIST_MAVEN_REPO = "distMaven" + +val Project.distMavenDir: Provider get() = layout.buildDirectory.dir(DIST_MAVEN_REPO) private fun Project.versionName(): String = providers.gradleProperty("versionName").get() // configure distZip tasks for multiplatform fun DistributionContainer.configureForMultiplatform(project: Project) { - val sep = File.separator - val version = project.versionName() - val name = project.name - - this.maybeCreate("android").contents { - from("build${sep}publications${sep}androidRelease") { - renameModule(name, "android", version = version) - } - from("build${sep}outputs${sep}aar") { - include("*-release*") - rename { - it.replace("-release", "-android-release") - } - } - from("build${sep}libs") { - include("*android*") - include("*androidRelease-javadoc*") - rename { - it.replace("androidRelease-javadoc", "android") - } - } - } - this.getByName("main").contents { - from("build${sep}publications${sep}kotlinMultiplatform") { - renameModule(name, version = version) - } - from("build${sep}kotlinToolingMetadata") - from("build${sep}libs") { - include("*compose-kotlin*") - include("*compose-metadata*") - rename { - it.replace("-kotlin", "") - .replace("-metadata", "") - .replace("Multiplatform-javadoc", "") - } - } - } - this.maybeCreate("desktop").contents { - // kotlin multiplatform modules - from("build${sep}publications${sep}desktop") { - renameModule(name, "desktop", version = version) - } - from("build${sep}libs") { - include("*desktop*") - include("*desktop-javadoc*") - rename { - it.replace("desktop-javadoc", "desktop") - } - } - } + getByName("main").contents { fromDistMaven(project, project.name) } + maybeCreate("android").contents { fromDistMaven(project, "${project.name}-android") } + maybeCreate("desktop").contents { fromDistMaven(project, "${project.name}-desktop") } // make other distZip tasks run together with the main distZip - val platformDists = project.tasks.filter { task -> - task.name.matches(Consts.taskRegex) - }.toTypedArray() + val platformDists = project.tasks.filter { it.name.matches(Regex("(.*)DistZip")) }.toTypedArray() project.tasks.getByName("distZip").finalizedBy(*platformDists) } fun DistributionContainer.configureForJvm(project: Project) { - val sep = File.separator - val version = project.versionName() - val name = project.name - - this.getByName("main").contents { - // non android modules - from("build${sep}libs") - from("build${sep}publications${sep}maven") { - renameModule(name, version = version) - } - // android modules - from("build${sep}outputs${sep}aar") { - include("*-release*") - } - from("build${sep}publications${sep}release") { - renameModule(name, version = version) - } - from("build${sep}intermediates${sep}java_doc_jar${sep}release") { - include("*javadoc*") - rename { it.replace("release", "$name-$version") } - } - from("build${sep}intermediates${sep}source_jar${sep}release") { - include("*sources*") - rename { it.replace("release", "$name-$version") } - } - } + getByName("main").contents { fromDistMaven(project, project.name) } } -private fun CopySpec.renameModule(projectName: String, renameTo: String = "", version: String) { - var target = "" - if (renameTo.isNotEmpty()) { - target = "-$renameTo" - } - rename { - it.replace("module.json", "$projectName$target-$version.module") +/** + * Copies one publication out of the project's Maven repository layout. Maven guarantees file names + * there (`-[-].`) independently of how the publish plugin + * names its tasks or intermediate outputs, which is exactly what craft expects. Only the few + * craft-specific names below need renaming. + */ +private fun CopySpec.fromDistMaven(project: Project, artifactId: String) { + val version = project.versionName() + val groupPath = project.group.toString().replace('.', '/') + from(project.distMavenDir.map { it.dir("$groupPath/$artifactId/$version") }) { + exclude("*.md5", "*.sha1", "*.sha256", "*.sha512", "*.asc") + rename { + when (it) { + // craft reads the POM from a fixed name + "$artifactId-$version.pom" -> "pom-default.xml" + // craft's android config maps

- to -release.aar + "$artifactId-$version.aar" -> "$artifactId-release.aar" + // kept for parity with the old layout + "$artifactId-$version-kotlin-tooling-metadata.json" -> "kotlin-tooling-metadata.json" + else -> it + } + } } } From 19813ee6c4ef0ad8934123d9c54d0e4aeffa842c Mon Sep 17 00:00:00 2001 From: romtsn <4999776+romtsn@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:35:14 +0000 Subject: [PATCH 33/40] release: 8.63.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b05ad9d9f22..2660e6dcff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.63.0 ### Features diff --git a/gradle.properties b/gradle.properties index a7df990ebf3..eeefd38c92d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,7 +19,7 @@ android.experimental.lint.version=9.4.0 android.disableAgpUpgradePrompt=true # Release information -versionName=8.62.0 +versionName=8.63.0 # Override the SDK name on native crashes on Android sentryAndroidSdkName=sentry.native.android From fff7ea544a127749010923cc7bcf1feaf41a280c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 5 Nov 2026 17:29:09 +0100 Subject: [PATCH 34/40] docs: Announce Java 11 and minSdk 26 requirements for SDK 9 (#6471) * docs: Announce Java 11 and minSdk 26 requirements for SDK 9 Add a warning at the top of the changelog that SDK 9.0.0 drops Java 8 and raises the Android minSdk from 21 to 26, linking to the feedback issues so users can raise concerns before the major release. Co-Authored-By: Claude Opus 5.5 * docs: Drop the stay-on-8.x line from the SDK 9 warning Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2660e6dcff2..5da066cad5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +> [!WARNING] +> SDK 9.0.0 will raise the minimum supported Java and Android versions: +> - Java 8 support will be dropped. SDK 9.0.0 requires Java 11+. See [#6469](https://github.com/getsentry/sentry-java/issues/6469). +> - The Android `minSdk` will be raised from 21 to 26. See [#5508](https://github.com/getsentry/sentry-java/issues/5508). + ## 8.63.0 ### Features From 9b8318e44eeb9537a402fa25bbb60801f5fe1bdd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 13:38:04 -0500 Subject: [PATCH 35/40] ref(core): Add MonitorConfigUtils to convert schedules to monitor configs (#6217) * ref(core): Add MonitorConfigUtils to convert schedules to monitor configs Framework-free conversion of a 6 field cron expression, fixed rate or fixed delay into a MonitorConfig, plus period parsing, so the Spring and Quartz integrations can share it. * fix(core): Only send monitor configs Sentry reads the same way Return no config when day of month and day of week are both set, for cron syntax Sentry rejects, and for zones that are not IANA region IDs. Whole-hour fixed offsets map to Etc/GMT zones. Intervals use hours or days when they divide evenly. * ref(core): Add MonitorConfigUtils.fromSpringScheduled Move the Spring day-of-week rewrite and the JVM default zone rule into core so the three Spring modules share them. * fix(core): Return no config for stepped single value ranges Spring and Quartz run 10-10/2 only at 10, cronsim steps to the field max. * fix(core): Read Spring crons per parser and skip fixed delays Spring before 5.3 steps day of month */n from 0 and can run at midnight when both day fields are set; read its crons that way. Spring 5.3 runs #5 in months without a fifth weekday, so return null for it. A fixed delay drifts from any interval, so it gets no config. * fix(core): Keep Sunday-only day of week ranges SUN-SUN was rewritten to 0-7, which crontab reads as every day. Co-Authored-By: Claude * fix(core): Use a 2 minute check-in margin for crons with fixed seconds Dropping a non-zero seconds field makes check-ins arrive up to 59s after the expected minute, which a 1 minute margin barely covers. --------- Co-authored-by: Claude --- sentry/api/sentry.api | 6 + .../io/sentry/util/MonitorConfigUtils.java | 507 ++++++++++++++++++ .../io/sentry/util/MonitorConfigUtilsTest.kt | 497 +++++++++++++++++ 3 files changed, 1010 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java create mode 100644 sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 117a04565a0..bb869c6a1f9 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -8148,6 +8148,12 @@ public final class io/sentry/util/MapObjectWriter : io/sentry/ObjectWriter { public fun value (Z)Lio/sentry/util/MapObjectWriter; } +public final class io/sentry/util/MonitorConfigUtils { + public static fun fromSchedule (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;)Lio/sentry/MonitorConfig; + public static fun fromSpringScheduled (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Z)Lio/sentry/MonitorConfig; + public static fun parsePeriodMillis (Ljava/lang/String;Ljava/util/concurrent/TimeUnit;)Ljava/lang/Long; +} + public final class io/sentry/util/Objects { public static fun equals (Ljava/lang/Object;Ljava/lang/Object;)Z public static fun hash ([Ljava/lang/Object;)I diff --git a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java new file mode 100644 index 00000000000..4058529b12f --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java @@ -0,0 +1,507 @@ +package io.sentry.util; + +import io.sentry.MonitorConfig; +import io.sentry.MonitorSchedule; +import io.sentry.MonitorScheduleUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Converts scheduler definitions into a {@link MonitorConfig}. */ +@ApiStatus.Internal +public final class MonitorConfigUtils { + private static final @NotNull String CRON_DISABLED = "-"; + private static final long MINUTE_MILLIS = TimeUnit.MINUTES.toMillis(1); + private static final long MIN_CHECKIN_MARGIN_WITH_SECONDS = 2; + private static final @NotNull Map CRON_MACROS; + private static final @NotNull Pattern SIMPLE_DURATION = + Pattern.compile("^([+-]?\\d+)([a-zA-Z]{0,2})$"); + private static final @NotNull Pattern FIXED_OFFSET = + Pattern.compile("^(?:GMT|UTC)?([+-])(\\d{1,2})(?::?(\\d{2}))?$"); + private static final @NotNull Pattern DIGITS = Pattern.compile("\\d+"); + + private static final int DAY_OF_MONTH = 2; + private static final int MONTH = 3; + private static final int DAY_OF_WEEK = 4; + private static final int[] FIELD_MIN = {0, 0, 1, 1, 0}; + private static final int[] FIELD_MAX = {59, 23, 31, 12, 7}; + private static final int LAST = -1; + private static final int[] DAYS_IN_MONTH = {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + private static final @NotNull List MONTH_NAMES = + Arrays.asList( + "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"); + private static final @NotNull List DAY_NAMES = + Arrays.asList("SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"); + + /** IANA zones without a slash; Java also knows non-IANA short IDs like PST. */ + private static final @NotNull Set TOP_LEVEL_ZONES = + new HashSet<>( + Arrays.asList( + ("CET CST6CDT Cuba EET EST EST5EDT Egypt Eire GB GB-Eire GMT GMT+0 GMT-0 GMT0 " + + "Greenwich HST Hongkong Iceland Iran Israel Jamaica Japan Kwajalein Libya " + + "MET MST MST7MDT NZ NZ-CHAT Navajo PRC PST8PDT Poland Portugal ROC ROK " + + "Singapore Turkey UCT UTC Universal W-SU WET Zulu") + .split(" "))); + + private static volatile @Nullable Set availableZoneIds; + + static { + final @NotNull Map macros = new HashMap<>(); + macros.put("@yearly", "0 0 1 1 *"); + macros.put("@annually", "0 0 1 1 *"); + macros.put("@monthly", "0 0 1 * *"); + macros.put("@weekly", "0 0 * * 0"); + macros.put("@daily", "0 0 * * *"); + macros.put("@midnight", "0 0 * * *"); + macros.put("@hourly", "0 * * * *"); + CRON_MACROS = Collections.unmodifiableMap(macros); + } + + private MonitorConfigUtils() {} + + /** + * Builds a monitor config from exactly one of a cron or a fixed interval. + * + * @param cron 6 field cron (seconds first) or macro, null or empty if unset + * @param zone cron time zone, ignored for intervals + * @param intervalMillis fixed rate, null if unset + * @return null if the schedule can't be expressed as a Sentry monitor schedule + */ + public static @Nullable MonitorConfig fromSchedule( + final @Nullable String cron, + final @Nullable String zone, + final @Nullable Long intervalMillis) { + final boolean hasCron = cron != null && !cron.isEmpty(); + + if (hasCron && cron != null && intervalMillis == null) { + final @Nullable String crontab = toCrontab(cron); + if (crontab == null) { + return null; + } + final @NotNull MonitorConfig config = new MonitorConfig(MonitorSchedule.crontab(crontab)); + // Runs check in up to 59s after the minute Sentry expects, so a 1 minute margin is too tight + if (hasNonZeroSeconds(cron)) { + final @Nullable Long margin = config.getCheckinMargin(); + if (margin == null || margin < MIN_CHECKIN_MARGIN_WITH_SECONDS) { + config.setCheckinMargin(MIN_CHECKIN_MARGIN_WITH_SECONDS); + } + } + if (zone != null && !zone.isEmpty()) { + final @Nullable String timezone = toTimezone(zone); + if (timezone == null) { + return null; + } + config.setTimezone(timezone); + } + return config; + } + + if (!hasCron && intervalMillis != null) { + final long millis = intervalMillis; + if (millis < MINUTE_MILLIS || millis % MINUTE_MILLIS != 0) { + return null; + } + long value = millis / MINUTE_MILLIS; + @NotNull MonitorScheduleUnit unit = MonitorScheduleUnit.MINUTE; + if (value % (24 * 60) == 0) { + value /= 24 * 60; + unit = MonitorScheduleUnit.DAY; + } else if (value % 60 == 0) { + value /= 60; + unit = MonitorScheduleUnit.HOUR; + } + if (value > Integer.MAX_VALUE) { + return null; + } + return new MonitorConfig(MonitorSchedule.interval((int) value, unit)); + } + return null; + } + + private static boolean hasNonZeroSeconds(final @NotNull String cron) { + final @NotNull String seconds = cron.trim().split("\\s+", -1)[0]; + return DIGITS.matcher(seconds).matches() && Integer.parseInt(seconds) > 0; + } + + /** + * Builds a monitor config from Spring {@code @Scheduled} values with placeholders resolved. Reads + * the cron and a cron without zone the way Spring does. + * + * @param cron Spring cron, null or empty if unset + * @param zone cron time zone, null or empty for the JVM default zone + * @param fixedRateMillis null if unset + * @param fixedDelayMillis null if unset; a fixed delay never gets a config + * @param legacyCronParser true for Spring before 5.3, which parses crons with {@code + * CronSequenceGenerator} + * @return null if the schedule can't be expressed as a Sentry monitor schedule + */ + public static @Nullable MonitorConfig fromSpringScheduled( + final @Nullable String cron, + final @Nullable String zone, + final @Nullable Long fixedRateMillis, + final @Nullable Long fixedDelayMillis, + final boolean legacyCronParser) { + // runs start a delay after the previous run ends, so they drift from any interval + if (fixedDelayMillis != null) { + return null; + } + final @Nullable String crontabCron = + legacyCronParser ? toCrontabLegacy(cron) : toCrontabDaysOfWeek(cron); + @Nullable String cronZone = null; + if (crontabCron != null && !crontabCron.isEmpty()) { + cronZone = zone == null || zone.isEmpty() ? TimeZone.getDefault().getID() : zone; + } + return fromSchedule(crontabCron, cronZone, fixedRateMillis); + } + + /** + * Spring before 5.3 steps day of month {@code *}/n from 0, so it runs on n, 2n, ... Days of week + * already match crontab. Null if both day fields are set, as it then sometimes runs at midnight. + */ + static @Nullable String toCrontabLegacy(final @Nullable String cron) { + if (cron == null) { + return null; + } + final @NotNull String[] fields = cron.trim().split("\\s+", -1); + if (fields.length != 6) { + return cron; + } + if (!isAnyDay(fields[3]) && !isAnyDay(fields[5])) { + return null; + } + final @NotNull StringBuilder daysOfMonth = new StringBuilder(); + for (@NotNull String item : fields[3].split(",", -1)) { + if (item.startsWith("*/")) { + item = item.substring(2) + "-31" + item.substring(1); + } + if (daysOfMonth.length() > 0) { + daysOfMonth.append(','); + } + daysOfMonth.append(item); + } + fields[3] = daysOfMonth.toString(); + return join(fields); + } + + private static boolean isAnyDay(final @NotNull String field) { + return "*".equals(field) || "?".equals(field); + } + + /** + * Spring numbers days of week from Monday with 0 or 7 for Sunday, and starts {@code *} on Monday. + * Rewrites them so crontab reads them the same way. Null for {@code #5}, which Spring 5.3 also + * runs in months without a fifth weekday. + */ + static @Nullable String toCrontabDaysOfWeek(final @Nullable String cron) { + if (cron == null) { + return null; + } + final @NotNull String[] fields = cron.trim().split("\\s+", -1); + if (fields.length != 6) { + return cron; + } + final @NotNull StringBuilder daysOfWeek = new StringBuilder(); + for (@NotNull String item : fields[5].toUpperCase(Locale.ROOT).split(",", -1)) { + for (int i = 0; i < DAY_NAMES.size(); i++) { + item = item.replace(DAY_NAMES.get(i), String.valueOf(i == 0 ? 7 : i)); + } + if (item.matches(".*#0*5")) { + return null; + } + if (item.startsWith("*/")) { + item = "1-7" + item.substring(1); + } else if (item.startsWith("7-7")) { + // Sunday only; 0-7 would be every day + item = "7" + item.substring(3); + } else if (item.startsWith("7-")) { + // Sunday starting a range is 0 in Spring + item = "0" + item.substring(1); + } + if (daysOfWeek.length() > 0) { + daysOfWeek.append(','); + } + daysOfWeek.append(item); + } + fields[5] = daysOfWeek.toString(); + return join(fields); + } + + private static @NotNull String join(final @NotNull String[] fields) { + final @NotNull StringBuilder result = new StringBuilder(); + for (int i = 0; i < fields.length; i++) { + if (i > 0) { + result.append(' '); + } + result.append(fields[i]); + } + return result.toString(); + } + + /** + * Parses a plain number in {@code defaultUnit} or a Spring 6.1 simple duration like {@code 30s}. + * ISO-8601 is not supported. + * + * @return milliseconds, or null if unparseable + */ + public static @Nullable Long parsePeriodMillis( + final @Nullable String value, final @NotNull TimeUnit defaultUnit) { + if (value == null) { + return null; + } + final @NotNull Matcher matcher = SIMPLE_DURATION.matcher(value.trim()); + if (!matcher.matches()) { + return null; + } + final long amount; + try { + amount = Long.parseLong(matcher.group(1)); + } catch (NumberFormatException e) { + return null; + } + final @Nullable TimeUnit unit = timeUnit(matcher.group(2), defaultUnit); + return unit == null ? null : unit.toMillis(amount); + } + + private static @Nullable TimeUnit timeUnit( + final @NotNull String suffix, final @NotNull TimeUnit defaultUnit) { + switch (suffix.toLowerCase(Locale.ROOT)) { + case "": + return defaultUnit; + case "ns": + return TimeUnit.NANOSECONDS; + case "us": + return TimeUnit.MICROSECONDS; + case "ms": + return TimeUnit.MILLISECONDS; + case "s": + return TimeUnit.SECONDS; + case "m": + return TimeUnit.MINUTES; + case "h": + return TimeUnit.HOURS; + case "d": + return TimeUnit.DAYS; + default: + return null; + } + } + + /** + * Returns a zone ID Sentry accepts: IANA region IDs as is, whole-hour fixed offsets like {@code + * GMT+2} as {@code Etc/GMT-2}, null otherwise. + */ + static @Nullable String toTimezone(final @NotNull String zone) { + final @NotNull Matcher offset = FIXED_OFFSET.matcher(zone); + if (offset.matches()) { + final @Nullable String minutes = offset.group(3); + if (minutes != null && !"00".equals(minutes)) { + return null; + } + final int hours = Integer.parseInt(offset.group(2)); + if (hours == 0) { + return "Etc/GMT"; + } + final boolean east = "+".equals(offset.group(1)); + if (hours > (east ? 14 : 12)) { + return null; + } + // Etc/GMT signs are inverted + return "Etc/GMT" + (east ? "-" : "+") + hours; + } + if (TOP_LEVEL_ZONES.contains(zone)) { + return zone; + } + if (zone.indexOf('/') > 0 && !zone.startsWith("SystemV/") && availableZones().contains(zone)) { + return zone; + } + return null; + } + + private static @NotNull Set availableZones() { + @Nullable Set zones = availableZoneIds; + if (zones == null) { + zones = new HashSet<>(Arrays.asList(TimeZone.getAvailableIDs())); + availableZoneIds = zones; + } + return zones; + } + + /** + * Drops a fixed seconds field. Returns null if Sentry would reject the result or read it + * differently, as crontab has no seconds and ORs day of month and day of week. + */ + static @Nullable String toCrontab(final @NotNull String cron) { + final @NotNull String trimmed = cron.trim(); + if (CRON_DISABLED.equals(trimmed)) { + return null; + } + if (trimmed.startsWith("@")) { + return CRON_MACROS.get(trimmed.toLowerCase(Locale.ROOT)); + } + final @NotNull String[] fields = trimmed.split("\\s+", -1); + if (fields.length != 6 || !fields[0].matches("\\d{1,2}") || Integer.parseInt(fields[0]) > 59) { + return null; + } + final @NotNull String[] crontab = new String[5]; + for (int i = 0; i < crontab.length; i++) { + final @NotNull String field = fields[i + 1]; + // '?' means '*' in Spring and Quartz cron + final boolean dayField = i == DAY_OF_MONTH || i == DAY_OF_WEEK; + crontab[i] = dayField && "?".equals(field) ? "*" : field; + } + // crontab ORs day of month and day of week unless one starts with '*', Spring and Quartz AND + if (!crontab[DAY_OF_MONTH].startsWith("*") && !crontab[DAY_OF_WEEK].startsWith("*")) { + return null; + } + final @NotNull List> values = new ArrayList<>(); + for (int i = 0; i < crontab.length; i++) { + final @Nullable Set parsed = parseField(crontab[i].toUpperCase(Locale.ROOT), i); + if (parsed == null) { + return null; + } + values.add(parsed); + } + if (!dayExistsInMonths(values.get(DAY_OF_MONTH), values.get(MONTH))) { + return null; + } + final @NotNull StringBuilder result = new StringBuilder(); + for (int i = 0; i < crontab.length; i++) { + if (i > 0) { + result.append(' '); + } + result.append(crontab[i]); + } + return result.toString(); + } + + // Mirrors the parser Sentry uses (cronsim). Returns null for anything it rejects or that we + // can't send as is. Day of week L and # entries are not expanded, as only the count matters. + private static @Nullable Set parseField(final @NotNull String field, final int index) { + final @NotNull Set result = new HashSet<>(); + for (final @NotNull String term : field.split(",", -1)) { + final @Nullable Set parsed = parseTerm(term, index); + if (parsed == null) { + return null; + } + result.addAll(parsed); + } + return result; + } + + private static @Nullable Set parseTerm(final @NotNull String term, final int index) { + if ("*".equals(term)) { + return range(FIELD_MIN[index], FIELD_MAX[index], 1); + } + if (index == DAY_OF_WEEK && term.indexOf('L') >= 0) { + final @NotNull String day = term.substring(0, term.length() - 1); + if (!term.endsWith("L") || !DIGITS.matcher(day).matches() || value(day, index) == null) { + return null; + } + return Collections.singleton(LAST); + } + if (index == DAY_OF_WEEK && term.indexOf('#') >= 0) { + final int hash = term.indexOf('#'); + final @Nullable Integer nth = number(term.substring(hash + 1)); + if (nth == null || nth < 1 || nth > 5 || value(term.substring(0, hash), index) == null) { + return null; + } + return Collections.singleton(LAST); + } + if (term.indexOf('/') >= 0) { + final int slash = term.indexOf('/'); + final @Nullable Integer step = number(term.substring(slash + 1)); + final @NotNull String base = term.substring(0, slash); + if (step == null || step == 0 || "L".equals(base) || "LW".equals(base)) { + return null; + } + final @Nullable Set items = parseTerm(base, index); + if (items == null) { + return null; + } + if (items.size() == 1) { + // cronsim steps a single value range like 10-10/2 to the field max + if (base.indexOf('-') >= 0) { + return null; + } + return range(items.iterator().next(), FIELD_MAX[index], step); + } + final @NotNull List sorted = new ArrayList<>(items); + Collections.sort(sorted); + final @NotNull Set result = new HashSet<>(); + for (int i = 0; i < sorted.size(); i += step) { + result.add(sorted.get(i)); + } + return result; + } + if (term.indexOf('-') >= 0) { + final int dash = term.indexOf('-'); + final @Nullable Integer start = value(term.substring(0, dash), index); + final @Nullable Integer end = value(term.substring(dash + 1), index); + if (start == null || end == null || end < start) { + return null; + } + return range(start, end, 1); + } + if (index == DAY_OF_MONTH && ("L".equals(term) || "LW".equals(term))) { + return Collections.singleton(LAST); + } + final @Nullable Integer value = value(term, index); + return value == null ? null : Collections.singleton(value); + } + + private static @Nullable Integer value(final @NotNull String value, final int index) { + if (index == MONTH && MONTH_NAMES.contains(value)) { + return MONTH_NAMES.indexOf(value) + 1; + } + if (index == DAY_OF_WEEK && DAY_NAMES.contains(value)) { + return DAY_NAMES.indexOf(value); + } + final @Nullable Integer number = number(value); + if (number == null || number < FIELD_MIN[index] || number > FIELD_MAX[index]) { + return null; + } + return number; + } + + private static @Nullable Integer number(final @NotNull String value) { + if (value.isEmpty() || value.length() > 9 || !DIGITS.matcher(value).matches()) { + return null; + } + return Integer.parseInt(value); + } + + private static @NotNull Set range(final int start, final int end, final int step) { + final @NotNull Set result = new HashSet<>(); + for (int i = start; i <= end; i += step) { + result.add(i); + } + return result; + } + + private static boolean dayExistsInMonths( + final @NotNull Set days, final @NotNull Set months) { + final int firstDay = Collections.min(days); + if (firstDay <= 29) { + return true; + } + for (final int month : months) { + if (firstDay <= DAYS_IN_MONTH[month - 1]) { + return true; + } + } + return false; + } +} diff --git a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt new file mode 100644 index 00000000000..e882b3ed070 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt @@ -0,0 +1,497 @@ +package io.sentry.util + +import com.google.common.truth.Truth.assertThat +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryOptions +import java.util.TimeZone +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class MonitorConfigUtilsTest { + + @Test + fun `cron with fixed seconds drops the seconds field`() { + val config = MonitorConfigUtils.fromSchedule("0 15 10 * * MON-FRI", null, null) + + assertThat(config).isNotNull() + assertThat(config!!.schedule.type).isEqualTo("crontab") + assertThat(config.schedule.value).isEqualTo("15 10 * * MON-FRI") + assertThat(config.schedule.unit).isNull() + assertThat(config.timezone).isNull() + } + + @Test + fun `cron at second 0 keeps the default check-in margin`() { + val config = MonitorConfigUtils.fromSchedule("0 * * * * *", null, null) + + assertThat(config!!.checkinMargin).isNull() + } + + @Test + fun `cron with non-zero seconds gets a 2 minute check-in margin`() { + for (cron in listOf("1 * * * * *", "59 * * * * *", "30 15 10 * * *")) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)!!.checkinMargin).isEqualTo(2L) + } + } + + @Test + fun `cron with non-zero seconds raises a smaller default check-in margin`() { + withDefaultCheckinMargin(1L) { + assertThat(MonitorConfigUtils.fromSchedule("59 * * * * *", null, null)!!.checkinMargin) + .isEqualTo(2L) + } + } + + @Test + fun `cron with non-zero seconds keeps a larger default check-in margin`() { + withDefaultCheckinMargin(5L) { + assertThat(MonitorConfigUtils.fromSchedule("59 * * * * *", null, null)!!.checkinMargin) + .isEqualTo(5L) + } + } + + private fun withDefaultCheckinMargin(margin: Long, block: () -> Unit) { + Mockito.mockStatic(Sentry::class.java).use { sentry -> + val scopes = mock() + sentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + whenever(scopes.options) + .thenReturn( + SentryOptions().apply { + cron = SentryOptions.Cron().apply { defaultCheckinMargin = margin } + } + ) + block() + } + } + + @Test + fun `cron whitespace is normalized`() { + val config = MonitorConfigUtils.fromSchedule(" 30 0 2 * * * ", null, null) + + assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") + } + + @Test + fun `cron question mark is converted to asterisk`() { + val config = MonitorConfigUtils.fromSchedule("0 0 9 ? * MON", null, null) + + assertThat(config?.schedule?.value).isEqualTo("0 9 * * MON") + } + + @Test + fun `cron macros are expanded`() { + val expected = + mapOf( + "@yearly" to "0 0 1 1 *", + "@annually" to "0 0 1 1 *", + "@monthly" to "0 0 1 * *", + "@weekly" to "0 0 * * 0", + "@daily" to "0 0 * * *", + "@midnight" to "0 0 * * *", + "@hourly" to "0 * * * *", + "@HOURLY" to "0 * * * *", + ) + for ((macro, crontab) in expected) { + assertThat(MonitorConfigUtils.fromSchedule(macro, null, null)?.schedule?.value) + .isEqualTo(crontab) + } + } + + @Test + fun `unknown cron macro returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("@reboot", null, null)).isNull() + } + + @Test + fun `cron with variable seconds returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("*/30 * * * * *", null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0,30 * * * * *", null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0-10 * * * * *", null, null)).isNull() + } + + @Test + fun `cron without 6 fields returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("0 * * * *", null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0 0 * * * * 2030", null, null)).isNull() + } + + @Test + fun `cron with both day of month and day of week returns null`() { + for (cron in listOf("0 0 9 1-7 * MON", "0 0 9 15 * 1-5", "0 0 9 L * 5L", "0 0 9 1 * 1/2")) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)).isNull() + } + } + + @Test + fun `cron with a day field starting with asterisk is kept`() { + val expected = + mapOf( + "0 0 9 */2 * MON" to "0 9 */2 * MON", + "0 0 9 15 * */2" to "0 9 15 * */2", + "0 0 9 1 * ?" to "0 9 1 * *", + ) + for ((cron, crontab) in expected) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)?.schedule?.value) + .isEqualTo(crontab) + } + } + + @Test + fun `cron syntax Sentry accepts is kept`() { + val expected = + listOf( + "0 */15 * * * *", + "0 5/15 * * * *", + "0 0,30 8-18/2 * * *", + "0 0 0 1,15 JAN-MAR,dec *", + "0 0 0 L * *", + "0 0 0 lw * *", + "0 0 0 31 1-12 *", + "0 0 0 * * 0,7", + "0 0 0 * * sat", + "0 0 0 * * MON-FRI/2", + "0 0 0 * * 5L", + "0 0 0 * * 5#3", + "0 0 0 * * MON#2", + "0 0 0 * * 1-7/2", + ) + for (cron in expected) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)?.schedule?.value) + .isEqualTo(cron.substringAfter(' ')) + } + } + + @Test + fun `cron syntax Sentry rejects returns null`() { + val rejected = + listOf( + "60 * * * * *", + "0 60 * * * *", + "0 0 24 * * *", + "0 0 22-2 * * *", + "0 */0 * * * *", + "0 0,,30 * * * *", + "0 ? * * * *", + "0 0 0 0 * *", + "0 0 0 32 * *", + "0 0 0 15W * *", + "0 0 0 LW/2 * *", + "0 0 0 L-3 * *", + "0 0 0 5L * *", + "0 0 0 1#2 * *", + "0 0 0 30 2 *", + "0 0 0 31 4,6 *", + "0 0 0 * 0 *", + "0 0 0 * 13 *", + "0 0 0 * FOO *", + "0 0 0 * * 8", + "0 0 0 * * SAT-SUN", + "0 0 0 * * L", + "0 0 0 * * FRIL", + "0 0 0 * * L5", + "0 0 0 * * 5#6", + "0 0 0 * * 5#0", + ) + for (cron in rejected) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)).isNull() + } + } + + @Test + fun `stepped single value range returns null`() { + // Spring and Quartz run only at the value, cronsim steps to the field max + for (cron in + listOf("0 10-10/2 * * * *", "0 0 0-0/3 * * *", "0 0 0 ? * 4-4/3", "0 0 0 * 6-6/1 *")) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)).isNull() + } + } + + @Test + fun `disabled cron returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("-", null, null)).isNull() + } + + @Test + fun `zone is set for cron`() { + val config = MonitorConfigUtils.fromSchedule("0 0 2 * * *", "Europe/Vienna", null) + + assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") + assertThat(config?.timezone).isEqualTo("Europe/Vienna") + } + + @Test + fun `IANA zones are kept`() { + for (zone in + listOf("UTC", "GMT", "Etc/GMT+5", "US/Eastern", "America/Argentina/Buenos_Aires")) { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null)?.timezone) + .isEqualTo(zone) + } + } + + @Test + fun `whole hour fixed offsets are converted to Etc zones`() { + val expected = + mapOf( + "GMT+2" to "Etc/GMT-2", + "GMT+02:00" to "Etc/GMT-2", + "UTC-5" to "Etc/GMT+5", + "+03:00" to "Etc/GMT-3", + "-1200" to "Etc/GMT+12", + "GMT+14" to "Etc/GMT-14", + "GMT-00:00" to "Etc/GMT", + ) + for ((zone, timezone) in expected) { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null)?.timezone) + .isEqualTo(timezone) + } + } + + @Test + fun `zones Sentry rejects return null`() { + for (zone in + listOf("GMT+05:30", "GMT+15", "UTC-13", "PST", "IST", "Nowhere/Land", "SystemV/EST5")) { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null)).isNull() + } + } + + @Test + fun `empty zone is not set`() { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", "", null)?.timezone).isNull() + } + + @Test + fun `fixed rate in whole minutes is an interval`() { + val config = MonitorConfigUtils.fromSchedule(null, null, TimeUnit.MINUTES.toMillis(5)) + + assertThat(config).isNotNull() + assertThat(config!!.schedule.type).isEqualTo("interval") + assertThat(config.schedule.value).isEqualTo("5") + assertThat(config.schedule.unit).isEqualTo("minute") + } + + @Test + fun `period in whole hours or days uses the largest unit`() { + val expected = + mapOf( + TimeUnit.MINUTES.toMillis(60) to ("1" to "hour"), + TimeUnit.HOURS.toMillis(36) to ("36" to "hour"), + TimeUnit.HOURS.toMillis(24) to ("1" to "day"), + TimeUnit.DAYS.toMillis(14) to ("14" to "day"), + ) + for ((millis, interval) in expected) { + val config = MonitorConfigUtils.fromSchedule(null, null, millis) + assertThat(config?.schedule?.value).isEqualTo(interval.first) + assertThat(config?.schedule?.unit).isEqualTo(interval.second) + } + } + + @Test + fun `zone is ignored for intervals`() { + val config = + MonitorConfigUtils.fromSchedule(null, "Europe/Vienna", TimeUnit.MINUTES.toMillis(1)) + + assertThat(config?.schedule?.value).isEqualTo("1") + assertThat(config?.timezone).isNull() + } + + @Test + fun `sub-minute period returns null`() { + assertThat(MonitorConfigUtils.fromSchedule(null, null, 30_000L)).isNull() + assertThat(MonitorConfigUtils.fromSchedule(null, null, 0L)).isNull() + } + + @Test + fun `period not in whole minutes returns null`() { + assertThat(MonitorConfigUtils.fromSchedule(null, null, 90_000L)).isNull() + } + + @Test + fun `period too large for an int returns null`() { + assertThat( + MonitorConfigUtils.fromSchedule( + null, + null, + TimeUnit.MINUTES.toMillis(Int.MAX_VALUE.toLong() + 1), + ) + ) + .isNull() + } + + @Test + fun `more than one schedule kind returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", null, 60_000L)).isNull() + } + + @Test + fun `no schedule returns null`() { + assertThat(MonitorConfigUtils.fromSchedule(null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("", null, null)).isNull() + } + + @Test + fun `Spring days of week are rewritten to how crontab reads them`() { + val expected = + mapOf( + "0 15 10 * * MON-FRI" to "15 10 * * 1-5", + "0 0 9 * * */2" to "0 9 * * 1-7/2", + "0 0 9 ? * SUN/2" to "0 9 * * 7/2", + "0 0 9 ? * SUN-TUE,fri-sun" to "0 9 * * 0-2,5-7", + "0 0 9 ? * 7-2" to "0 9 * * 0-2", + "0 0 9 ? * SUN-SUN" to "0 9 * * 7", + "0 0 9 ? * 7-7/2" to "0 9 * * 7/2", + "0 0 9 ? * 0,7" to "0 9 * * 0,7", + "0 0 9 ? * FRIL" to "0 9 * * 5L", + "0 0 9 ? * MON#2" to "0 9 * * 1#2", + "0 0 9 * * *" to "0 9 * * *", + ) + for ((cron, crontab) in expected) { + assertThat( + MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null, false)?.schedule?.value + ) + .isEqualTo(crontab) + } + } + + @Test + fun `Spring cron without zone uses the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + assertThat( + MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", null, null, null, false)?.timezone + ) + .isEqualTo("Asia/Tokyo") + assertThat( + MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "", null, null, false)?.timezone + ) + .isEqualTo("Asia/Tokyo") + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `Spring cron zone is converted`() { + val config = MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "GMT+2", null, null, false) + + assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") + assertThat(config?.timezone).isEqualTo("Etc/GMT-2") + } + + @Test + fun `Spring schedules Sentry can't express return null`() { + for (cron in listOf("0 0 9 1-7 * MON", "-", "\${my.cron.missing}", "*/30 * * * * *")) { + assertThat(MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null, false)).isNull() + } + assertThat( + MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "GMT+05:30", null, null, false) + ) + .isNull() + assertThat(MonitorConfigUtils.fromSpringScheduled(null, null, 30_000L, null, false)).isNull() + } + + @Test + fun `Spring periods are intervals without zone`() { + val rate = + MonitorConfigUtils.fromSpringScheduled("", null, TimeUnit.MINUTES.toMillis(5), null, false) + assertThat(rate?.schedule?.type).isEqualTo("interval") + assertThat(rate?.schedule?.value).isEqualTo("5") + assertThat(rate?.schedule?.unit).isEqualTo("minute") + assertThat(rate?.timezone).isNull() + } + + @Test + fun `Spring fixed delay returns null`() { + for (legacy in listOf(false, true)) { + assertThat(MonitorConfigUtils.fromSpringScheduled(null, null, null, 300_000L, legacy)) + .isNull() + assertThat(MonitorConfigUtils.fromSpringScheduled("", null, 300_000L, 300_000L, legacy)) + .isNull() + } + } + + @Test + fun `Spring fifth weekday returns null`() { + assertThat(MonitorConfigUtils.fromSpringScheduled("0 0 9 ? * MON#5", "UTC", null, null, false)) + .isNull() + } + + @Test + fun `legacy Spring cron is rewritten to how crontab reads it`() { + val expected = + mapOf( + "0 0 9 */2 * *" to "0 9 2-31/2 * *", + "0 0 9 */1,15 * ?" to "0 9 1-31/1,15 * *", + "0 0 9 ? * */2" to "0 9 * * */2", + "0 0 9 ? * SUN/2" to "0 9 * * SUN/2", + "0 0 9 ? * 5-7" to "0 9 * * 5-7", + "0 0 9 1-15/3 * *" to "0 9 1-15/3 * *", + ) + for ((cron, crontab) in expected) { + assertThat( + MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null, true)?.schedule?.value + ) + .isEqualTo(crontab) + } + } + + @Test + fun `legacy Spring cron with both day fields returns null`() { + // CronSequenceGenerator then sometimes runs at midnight + for (cron in listOf("0 0 9 1 * */2", "0 0 9 */2 * */3", "0 0 9 */2 * MON")) { + assertThat(MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null, true)).isNull() + } + } + + @Test + fun `legacy Spring cron without zone uses the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + assertThat( + MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", null, null, null, true)?.timezone + ) + .isEqualTo("Asia/Tokyo") + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `parsePeriodMillis parses plain numbers in the default unit`() { + assertThat(MonitorConfigUtils.parsePeriodMillis("300000", TimeUnit.MILLISECONDS)) + .isEqualTo(300_000L) + assertThat(MonitorConfigUtils.parsePeriodMillis(" 5 ", TimeUnit.MINUTES)).isEqualTo(300_000L) + } + + @Test + fun `parsePeriodMillis parses the simple duration style`() { + val expected = + mapOf( + "2000000ns" to 2L, + "3000us" to 3L, + "500ms" to 500L, + "30s" to 30_000L, + "5m" to 300_000L, + "1h" to 3_600_000L, + "1d" to 86_400_000L, + "5M" to 300_000L, + "+5m" to 300_000L, + ) + for ((value, millis) in expected) { + assertThat(MonitorConfigUtils.parsePeriodMillis(value, TimeUnit.SECONDS)).isEqualTo(millis) + } + } + + @Test + fun `parsePeriodMillis returns null for unparseable values`() { + for (value in listOf("", "abc", "5x", "5 m", "1.5m", "PT5M", "99999999999999999999")) { + assertThat(MonitorConfigUtils.parsePeriodMillis(value, TimeUnit.MILLISECONDS)).isNull() + } + assertThat(MonitorConfigUtils.parsePeriodMillis(null, TimeUnit.MILLISECONDS)).isNull() + } +} From 573cf8f10f7837c85031fe439993912aa734e1be Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 14:51:50 -0500 Subject: [PATCH 36/40] feat(spring): Send monitor config from @Scheduled with @SentryCheckIn (#6215) * ref(core): Add MonitorConfigUtils to convert schedules to monitor configs Framework-free conversion of a 6 field cron expression, fixed rate or fixed delay into a MonitorConfig, plus period parsing, so the Spring and Quartz integrations can share it. * fix(core): Only send monitor configs Sentry reads the same way Return no config when day of month and day of week are both set, for cron syntax Sentry rejects, and for zones that are not IANA region IDs. Whole-hour fixed offsets map to Etc/GMT zones. Intervals use hours or days when they divide evenly. * ref(core): Add MonitorConfigUtils.fromSpringScheduled Move the Spring day-of-week rewrite and the JVM default zone rule into core so the three Spring modules share them. * fix(core): Return no config for stepped single value ranges Spring and Quartz run 10-10/2 only at 10, cronsim steps to the field max. * fix(core): Read Spring crons per parser and skip fixed delays Spring before 5.3 steps day of month */n from 0 and can run at midnight when both day fields are set; read its crons that way. Spring 5.3 runs #5 in months without a fifth weekday, so return null for it. A fixed delay drifts from any interval, so it gets no config. * feat(spring): Send monitor config from @Scheduled with @SentryCheckIn Co-Authored-By: Claude * feat(spring): Send @Scheduled monitor config by default Co-Authored-By: Claude * ref(spring): Shorten upsertMonitorConfig Javadoc Co-Authored-By: Claude * fix(core): Keep Sunday-only day of week ranges SUN-SUN was rewritten to 0-7, which crontab reads as every day. Co-Authored-By: Claude * fix(spring): Only catch runtime exceptions when deriving monitor config Co-Authored-By: Claude * ref(spring): Shorten upsertMonitorConfig Javadoc Co-Authored-By: Claude * ref(spring): Use pattern-matching instanceof for the @Scheduled time unit * fix(core): Use a 2 minute check-in margin for crons with fixed seconds Dropping a non-zero seconds field makes check-ins arrive up to 59s after the expected minute, which a 1 minute margin barely covers. * fix(spring): Default upsertMonitorConfig to false and read @Scheduled like Spring Existing @SentryCheckIn users shouldn't have their monitors changed on upgrade, so sending the schedule is opt-in. Find @Scheduled with Spring's own lookup so an overriding method doesn't inherit a parent's schedule. * chore(changelog): Move the @Scheduled monitor config entry to Unreleased --------- Co-authored-by: Claude --- CHANGELOG.md | 6 + sentry-spring-7/api/sentry-spring-7.api | 1 + .../sentry/spring7/checkin/SentryCheckIn.java | 10 + .../spring7/checkin/SentryCheckInAdvice.java | 119 +++++++- .../sentry/spring7/SentryCheckInAdviceTest.kt | 262 +++++++++++++++++- .../api/sentry-spring-jakarta.api | 1 + .../spring/jakarta/checkin/SentryCheckIn.java | 10 + .../jakarta/checkin/SentryCheckInAdvice.java | 119 +++++++- .../spring/jakarta/SentryCheckInAdviceTest.kt | 262 +++++++++++++++++- sentry-spring/api/sentry-spring.api | 1 + .../sentry/spring/checkin/SentryCheckIn.java | 10 + .../spring/checkin/SentryCheckInAdvice.java | 121 +++++++- .../sentry/spring/SentryCheckInAdviceTest.kt | 262 +++++++++++++++++- 13 files changed, 1178 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5da066cad5c..7f3a2469776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ > - Java 8 support will be dropped. SDK 9.0.0 requires Java 11+. See [#6469](https://github.com/getsentry/sentry-java/issues/6469). > - The Android `minSdk` will be raised from 21 to 26. See [#5508](https://github.com/getsentry/sentry-java/issues/5508). +## Unreleased + +### Features + +- Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = true)` to send the schedule ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) + ## 8.63.0 ### Features diff --git a/sentry-spring-7/api/sentry-spring-7.api b/sentry-spring-7/api/sentry-spring-7.api index 9c55ded782d..009374f671a 100644 --- a/sentry-spring-7/api/sentry-spring-7.api +++ b/sentry-spring-7/api/sentry-spring-7.api @@ -141,6 +141,7 @@ public final class io/sentry/spring7/cache/SentryCacheWrapper : org/springframew public abstract interface annotation class io/sentry/spring7/checkin/SentryCheckIn : java/lang/annotation/Annotation { public abstract fun heartbeat ()Z public abstract fun monitorSlug ()Ljava/lang/String; + public abstract fun upsertMonitorConfig ()Z public abstract fun value ()Ljava/lang/String; } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java index 47cd80ab5b2..ac17f85a985 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java @@ -29,6 +29,16 @@ */ boolean heartbeat() default false; + /** + * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so + * Sentry creates or updates the monitor. + * + *

Heartbeat check-ins never send a monitor config. + * + * @return true to send a monitor config, false by default + */ + boolean upsertMonitorConfig() default false; + /** * Monitor slug. If not set, no check-in will be sent. * diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java index a14f0d4c96a..ca0137d7714 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java @@ -6,13 +6,21 @@ import io.sentry.DateUtils; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; +import io.sentry.MonitorConfig; import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; import io.sentry.protocol.SentryId; import io.sentry.time.Stopwatch; +import io.sentry.util.MonitorConfigUtils; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; import java.lang.reflect.Method; +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.jetbrains.annotations.ApiStatus; @@ -20,7 +28,11 @@ import org.jetbrains.annotations.Nullable; import org.springframework.aop.support.AopUtils; import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.annotation.Schedules; +import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringValueResolver; @@ -31,10 +43,19 @@ @ApiStatus.Internal @Open public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueResolverAware { + // Spring before 5.3 parses crons with CronSequenceGenerator + private static final boolean LEGACY_CRON_PARSER = + !ClassUtils.isPresent( + "org.springframework.scheduling.support.CronExpression", + SentryCheckInAdvice.class.getClassLoader()); + private final @NotNull IScopes scopes; private @Nullable StringValueResolver resolver; + private final @NotNull Map monitorConfigs = + new ConcurrentHashMap<>(); + public SentryCheckInAdvice() { this(ScopesAdapter.getInstance()); } @@ -89,6 +110,11 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) { return invocation.proceed(); } + final @Nullable MonitorConfig monitorConfig = + !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig() + ? monitorConfig(mostSpecificMethod) + : null; + try (final @NotNull ISentryLifecycleToken ignored = scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) { TracingUtils.startNewTrace(scopes); @@ -100,7 +126,9 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) { try { if (!isHeartbeatOnly) { - checkInId = scopes.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS)); + final @NotNull CheckIn inProgress = new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS); + inProgress.setMonitorConfig(monitorConfig); + checkInId = scopes.captureCheckIn(inProgress); } return invocation.proceed(); } catch (Throwable e) { @@ -115,6 +143,95 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) { } } + private @Nullable MonitorConfig monitorConfig(final @NotNull Method method) { + return monitorConfigs.computeIfAbsent( + method, key -> new CachedMonitorConfig(monitorConfigFromScheduled(key))) + .config; + } + + private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) { + try { + final @NotNull Set schedules = + AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class); + if (schedules.size() != 1) { + return null; + } + final @NotNull Scheduled scheduled = schedules.iterator().next(); + // timeUnit only exists from Spring 5.3.10, so read it as an attribute + final @Nullable Object timeUnitAttribute = + AnnotationUtils.getAnnotationAttributes(scheduled).get("timeUnit"); + final @NotNull TimeUnit timeUnit = + timeUnitAttribute instanceof TimeUnit unit ? unit : TimeUnit.MILLISECONDS; + final @Nullable String cron = resolve(scheduled.cron()); + // Spring only reads the zone for a cron + final @Nullable String zone = + cron == null || cron.isEmpty() ? null : resolve(scheduled.zone()); + return MonitorConfigUtils.fromSpringScheduled( + cron, + zone, + periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit), + periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit), + LEGACY_CRON_PARSER); + } catch (RuntimeException e) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Could not derive a monitor config from @Scheduled for method annotated with @SentryCheckIn.", + e); + return null; + } + } + + private @Nullable Long periodMillis( + final long value, final @NotNull String valueString, final @NotNull TimeUnit timeUnit) { + if (value >= 0) { + return timeUnit.toMillis(value); + } + final @Nullable String resolved = resolve(valueString); + if (resolved == null || resolved.isEmpty()) { + return null; + } + final @NotNull String trimmed = resolved.trim(); + @Nullable Long millis = null; + if (trimmed.startsWith("P") || trimmed.startsWith("p")) { + try { + millis = Duration.parse(trimmed).toMillis(); + } catch (DateTimeParseException | ArithmeticException e) { + // logged below + } + } else { + millis = MonitorConfigUtils.parsePeriodMillis(trimmed, timeUnit); + } + if (millis == null) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not sending a monitor config for @SentryCheckIn because the @Scheduled period '%s' could not be parsed.", + trimmed); + } + return millis; + } + + private @Nullable String resolve(final @NotNull String value) { + if (resolver == null || value.isEmpty()) { + return value; + } + return resolver.resolveStringValue(value); + } + + private static final class CachedMonitorConfig { + private final @Nullable MonitorConfig config; + + private CachedMonitorConfig(final @Nullable MonitorConfig config) { + this.config = config; + } + } + @Override public void setEmbeddedValueResolver(StringValueResolver resolver) { this.resolver = resolver; diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt index e15affdbdd5..22e02b0b297 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt @@ -4,16 +4,21 @@ import io.sentry.CheckIn import io.sentry.CheckInStatus import io.sentry.IScopes import io.sentry.ISentryLifecycleToken +import io.sentry.MonitorConfig import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.protocol.SentryId import io.sentry.spring7.checkin.SentryCheckIn import io.sentry.spring7.checkin.SentryCheckInAdviceConfiguration import io.sentry.spring7.checkin.SentryCheckInPointcutConfiguration +import java.util.TimeZone +import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame import org.junit.jupiter.api.assertThrows import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -32,6 +37,8 @@ import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.EnableAspectJAutoProxy import org.springframework.context.annotation.Import import org.springframework.context.support.PropertySourcesPlaceholderConfigurer +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.scheduling.annotation.Schedules import org.springframework.test.context.TestPropertySource import org.springframework.test.context.junit.jupiter.SpringJUnitConfig import org.springframework.test.context.junit4.SpringRunner @@ -39,7 +46,15 @@ import org.springframework.util.StringValueResolver @RunWith(SpringRunner::class) @SpringJUnitConfig(SentryCheckInAdviceTest.Config::class) -@TestPropertySource(properties = ["my.cron.slug = mypropertycronslug"]) +@TestPropertySource( + properties = + [ + "my.cron.slug = mypropertycronslug", + "my.cron.schedule = 0 30 2 * * *", + "my.cron.zone = America/New_York", + "my.cron.empty.zone = ", + ] +) class SentryCheckInAdviceTest { @Autowired lateinit var sampleService: SampleService @@ -50,6 +65,10 @@ class SentryCheckInAdviceTest { @Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties + @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled + + @Autowired lateinit var sampleServiceInheritedSchedule: SampleServiceInheritedSchedule + @Autowired lateinit var scopes: IScopes val lifecycleToken = mock() @@ -74,6 +93,7 @@ class SentryCheckInAdviceTest { val inProgressCheckIn = checkInCaptor.firstValue assertEquals("monitor_slug_1", inProgressCheckIn.monitorSlug) assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status) + assertNull(inProgressCheckIn.monitorConfig) val doneCheckIn = checkInCaptor.lastValue assertEquals("monitor_slug_1", doneCheckIn.monitorSlug) @@ -228,6 +248,160 @@ class SentryCheckInAdviceTest { order.verify(lifecycleToken).close() } + @Test + fun `cron with zone is sent as crontab monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZone() } + assertNotNull(config) + assertEquals("crontab", config.schedule.type) + assertEquals("15 10 * * 1-5", config.schedule.value) + assertNull(config.schedule.unit) + assertEquals("Europe/Vienna", config.timezone) + } + + @Test + fun `cron without zone is sent in the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + val config = inProgressMonitorConfig { sampleServiceScheduled.cron() } + assertEquals("0 2 * * *", config?.schedule?.value) + assertEquals("Asia/Tokyo", config?.timezone) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `cron and zone placeholders are resolved`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronFromProperties() } + assertEquals("30 2 * * *", config?.schedule?.value) + assertEquals("America/New_York", config?.timezone) + } + + @Test + fun `fixed rate with time unit is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + assertNotNull(config) + assertEquals("interval", config.schedule.type) + assertEquals("2", config.schedule.value) + assertEquals("hour", config.schedule.unit) + } + + @Test + fun `ISO-8601 fixed rate string is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateIso() } + assertEquals("10", config?.schedule?.value) + assertEquals("minute", config?.schedule?.unit) + } + + @Test + fun `simple duration fixed rate string is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateSimpleDuration() } + assertEquals("interval", config?.schedule?.type) + assertEquals("5", config?.schedule?.value) + assertEquals("minute", config?.schedule?.unit) + } + + @Test + fun `fixed delay sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelay() }) + assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelayIso() }) + } + + @Test + fun `disabled cron sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.disabledCron() }) + } + + @Test + fun `zone placeholder resolving to empty uses the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithEmptyZone() } + assertEquals("0 3 * * *", config?.schedule?.value) + assertEquals("Asia/Tokyo", config?.timezone) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `cron zone overrides the default timezone from options`() { + val options = + SentryOptions().apply { + cron = SentryOptions.Cron().apply { defaultTimezone = "Europe/Berlin" } + } + whenever(scopes.options).thenReturn(options) + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZoneAndDefaults() } + assertEquals("Europe/Vienna", config?.timezone) + } + + @Test + fun `unresolvable cron placeholder sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.unresolvableCron() }) + } + + @Test + fun `exception while deriving monitor config sends no monitor config and runs the job`() { + var result = 0 + assertNull(inProgressMonitorConfig { result = sampleServiceScheduled.derivationThrows() }) + assertEquals(1, result) + } + + @Test + fun `monitor config is derived once per method`() { + val first = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + val second = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + assertNotNull(first) + assertSame(first, second) + } + + @Test + fun `multiple schedules send no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.multipleSchedules() }) + } + + @Test + fun `schedules container sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.schedulesContainer() }) + } + + @Test + fun `upsertMonitorConfig false sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDisabled() }) + } + + @Test + fun `upsertMonitorConfig defaults to false`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDefault() }) + } + + @Test + fun `@Scheduled on an overridden parent method sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceInheritedSchedule.job() }) + } + + @Test + fun `heartbeat with @Scheduled sends a single check-in without monitor config`() { + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + sampleServiceScheduled.heartbeat() + assertEquals(1, checkInCaptor.allValues.size) + assertEquals(CheckInStatus.OK.apiName(), checkInCaptor.firstValue.status) + assertNull(checkInCaptor.firstValue.monitorConfig) + } + + private fun inProgressMonitorConfig(block: () -> Unit): MonitorConfig? { + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + block() + assertEquals(2, checkInCaptor.allValues.size) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkInCaptor.firstValue.status) + assertNull(checkInCaptor.lastValue.monitorConfig) + return checkInCaptor.firstValue.monitorConfig + } + @Configuration @EnableAspectJAutoProxy(proxyTargetClass = true) @Import(SentryCheckInAdviceConfiguration::class, SentryCheckInPointcutConfiguration::class) @@ -241,6 +415,10 @@ class SentryCheckInAdviceTest { @Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties() + @Bean open fun sampleServiceScheduled() = SampleServiceScheduled() + + @Bean open fun sampleServiceInheritedSchedule() = SampleServiceInheritedSchedule() + @Bean open fun scopes(): IScopes { val scopes = mock() @@ -291,6 +469,88 @@ class SentryCheckInAdviceTest { open fun helloExceptionProperty() = 1 } + open class SampleServiceScheduled { + + @SentryCheckIn("cron_zone", upsertMonitorConfig = true) + @Scheduled(cron = "0 15 10 * * MON-FRI", zone = "Europe/Vienna") + open fun cronWithZone() {} + + @SentryCheckIn("cron", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 2 * * *") + open fun cron() {} + + @SentryCheckIn("cron_properties", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}") + open fun cronFromProperties() {} + + @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true) + @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS) + open fun fixedRateHours() {} + + @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true) + @Scheduled(fixedRateString = "PT10M") + open fun fixedRateIso() {} + + @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true) + @Scheduled(fixedRateString = "5m") + open fun fixedRateSimpleDuration() {} + + @SentryCheckIn("fixed_delay", upsertMonitorConfig = true) + @Scheduled(fixedDelay = 300_000) + open fun fixedDelay() {} + + @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true) + @Scheduled(fixedDelayString = "PT10M") + open fun fixedDelayIso() {} + + @SentryCheckIn("disabled_cron", upsertMonitorConfig = true) + @Scheduled(cron = "-") + open fun disabledCron() {} + + @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}") + open fun cronWithEmptyZone() {} + + @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna") + open fun cronWithZoneAndDefaults() {} + + @SentryCheckIn("schedules_container", upsertMonitorConfig = true) + @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *")) + open fun schedulesContainer() {} + + @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.missing}") + open fun unresolvableCron() {} + + @SentryCheckIn("derivation_throws", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.exception.property}") + open fun derivationThrows() = 1 + + @SentryCheckIn("multiple", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + @Scheduled(cron = "0 0 13 * * *") + open fun multipleSchedules() {} + + @SentryCheckIn("upsert_disabled", upsertMonitorConfig = false) + @Scheduled(cron = "0 0 1 * * *") + open fun upsertDisabled() {} + + @SentryCheckIn("upsert_default") @Scheduled(cron = "0 0 1 * * *") open fun upsertDefault() {} + + @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + open fun heartbeat() {} + } + + open class ScheduledParent { + @Scheduled(cron = "0 0 1 * * *") open fun job() {} + } + + open class SampleServiceInheritedSchedule : ScheduledParent() { + @SentryCheckIn("inherited", upsertMonitorConfig = true) override fun job() {} + } + class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() { override fun doProcessProperties( diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index 0bdcca50de8..90a746da6c2 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -141,6 +141,7 @@ public final class io/sentry/spring/jakarta/cache/SentryCacheWrapper : org/sprin public abstract interface annotation class io/sentry/spring/jakarta/checkin/SentryCheckIn : java/lang/annotation/Annotation { public abstract fun heartbeat ()Z public abstract fun monitorSlug ()Ljava/lang/String; + public abstract fun upsertMonitorConfig ()Z public abstract fun value ()Ljava/lang/String; } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java index bfb6b724b8f..89b04aa561a 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java @@ -29,6 +29,16 @@ */ boolean heartbeat() default false; + /** + * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so + * Sentry creates or updates the monitor. + * + *

Heartbeat check-ins never send a monitor config. + * + * @return true to send a monitor config, false by default + */ + boolean upsertMonitorConfig() default false; + /** * Monitor slug. If not set, no check-in will be sent. * diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java index 1e3697e781d..76b03a917bc 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java @@ -6,13 +6,21 @@ import io.sentry.DateUtils; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; +import io.sentry.MonitorConfig; import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; import io.sentry.protocol.SentryId; import io.sentry.time.Stopwatch; +import io.sentry.util.MonitorConfigUtils; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; import java.lang.reflect.Method; +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.jetbrains.annotations.ApiStatus; @@ -20,7 +28,11 @@ import org.jetbrains.annotations.Nullable; import org.springframework.aop.support.AopUtils; import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.annotation.Schedules; +import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringValueResolver; @@ -31,10 +43,19 @@ @ApiStatus.Internal @Open public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueResolverAware { + // Spring before 5.3 parses crons with CronSequenceGenerator + private static final boolean LEGACY_CRON_PARSER = + !ClassUtils.isPresent( + "org.springframework.scheduling.support.CronExpression", + SentryCheckInAdvice.class.getClassLoader()); + private final @NotNull IScopes scopes; private @Nullable StringValueResolver resolver; + private final @NotNull Map monitorConfigs = + new ConcurrentHashMap<>(); + public SentryCheckInAdvice() { this(ScopesAdapter.getInstance()); } @@ -87,6 +108,11 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl return invocation.proceed(); } + final @Nullable MonitorConfig monitorConfig = + !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig() + ? monitorConfig(mostSpecificMethod) + : null; + try (final @NotNull ISentryLifecycleToken ignored = scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) { TracingUtils.startNewTrace(scopes); @@ -98,7 +124,9 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl try { if (!isHeartbeatOnly) { - checkInId = scopes.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS)); + final @NotNull CheckIn inProgress = new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS); + inProgress.setMonitorConfig(monitorConfig); + checkInId = scopes.captureCheckIn(inProgress); } return invocation.proceed(); } catch (Throwable e) { @@ -113,6 +141,95 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } } + private @Nullable MonitorConfig monitorConfig(final @NotNull Method method) { + return monitorConfigs.computeIfAbsent( + method, key -> new CachedMonitorConfig(monitorConfigFromScheduled(key))) + .config; + } + + private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) { + try { + final @NotNull Set schedules = + AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class); + if (schedules.size() != 1) { + return null; + } + final @NotNull Scheduled scheduled = schedules.iterator().next(); + // timeUnit only exists from Spring 5.3.10, so read it as an attribute + final @Nullable Object timeUnitAttribute = + AnnotationUtils.getAnnotationAttributes(scheduled).get("timeUnit"); + final @NotNull TimeUnit timeUnit = + timeUnitAttribute instanceof TimeUnit unit ? unit : TimeUnit.MILLISECONDS; + final @Nullable String cron = resolve(scheduled.cron()); + // Spring only reads the zone for a cron + final @Nullable String zone = + cron == null || cron.isEmpty() ? null : resolve(scheduled.zone()); + return MonitorConfigUtils.fromSpringScheduled( + cron, + zone, + periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit), + periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit), + LEGACY_CRON_PARSER); + } catch (RuntimeException e) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Could not derive a monitor config from @Scheduled for method annotated with @SentryCheckIn.", + e); + return null; + } + } + + private @Nullable Long periodMillis( + final long value, final @NotNull String valueString, final @NotNull TimeUnit timeUnit) { + if (value >= 0) { + return timeUnit.toMillis(value); + } + final @Nullable String resolved = resolve(valueString); + if (resolved == null || resolved.isEmpty()) { + return null; + } + final @NotNull String trimmed = resolved.trim(); + @Nullable Long millis = null; + if (trimmed.startsWith("P") || trimmed.startsWith("p")) { + try { + millis = Duration.parse(trimmed).toMillis(); + } catch (DateTimeParseException | ArithmeticException e) { + // logged below + } + } else { + millis = MonitorConfigUtils.parsePeriodMillis(trimmed, timeUnit); + } + if (millis == null) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not sending a monitor config for @SentryCheckIn because the @Scheduled period '%s' could not be parsed.", + trimmed); + } + return millis; + } + + private @Nullable String resolve(final @NotNull String value) { + if (resolver == null || value.isEmpty()) { + return value; + } + return resolver.resolveStringValue(value); + } + + private static final class CachedMonitorConfig { + private final @Nullable MonitorConfig config; + + private CachedMonitorConfig(final @Nullable MonitorConfig config) { + this.config = config; + } + } + @Override public void setEmbeddedValueResolver(StringValueResolver resolver) { this.resolver = resolver; diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt index c6edd834530..84f6e2a8118 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt @@ -4,16 +4,21 @@ import io.sentry.CheckIn import io.sentry.CheckInStatus import io.sentry.IScopes import io.sentry.ISentryLifecycleToken +import io.sentry.MonitorConfig import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.protocol.SentryId import io.sentry.spring.jakarta.checkin.SentryCheckIn import io.sentry.spring.jakarta.checkin.SentryCheckInAdviceConfiguration import io.sentry.spring.jakarta.checkin.SentryCheckInPointcutConfiguration +import java.util.TimeZone +import java.util.concurrent.TimeUnit import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame import org.junit.jupiter.api.assertThrows import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -32,6 +37,8 @@ import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.EnableAspectJAutoProxy import org.springframework.context.annotation.Import import org.springframework.context.support.PropertySourcesPlaceholderConfigurer +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.scheduling.annotation.Schedules import org.springframework.test.context.TestPropertySource import org.springframework.test.context.junit.jupiter.SpringJUnitConfig import org.springframework.test.context.junit4.SpringRunner @@ -39,7 +46,15 @@ import org.springframework.util.StringValueResolver @RunWith(SpringRunner::class) @SpringJUnitConfig(SentryCheckInAdviceTest.Config::class) -@TestPropertySource(properties = ["my.cron.slug = mypropertycronslug"]) +@TestPropertySource( + properties = + [ + "my.cron.slug = mypropertycronslug", + "my.cron.schedule = 0 30 2 * * *", + "my.cron.zone = America/New_York", + "my.cron.empty.zone = ", + ] +) class SentryCheckInAdviceTest { @Autowired lateinit var sampleService: SampleService @@ -50,6 +65,10 @@ class SentryCheckInAdviceTest { @Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties + @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled + + @Autowired lateinit var sampleServiceInheritedSchedule: SampleServiceInheritedSchedule + @Autowired lateinit var scopes: IScopes val lifecycleToken = mock() @@ -74,6 +93,7 @@ class SentryCheckInAdviceTest { val inProgressCheckIn = checkInCaptor.firstValue assertEquals("monitor_slug_1", inProgressCheckIn.monitorSlug) assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status) + assertNull(inProgressCheckIn.monitorConfig) val doneCheckIn = checkInCaptor.lastValue assertEquals("monitor_slug_1", doneCheckIn.monitorSlug) @@ -228,6 +248,160 @@ class SentryCheckInAdviceTest { order.verify(lifecycleToken).close() } + @Test + fun `cron with zone is sent as crontab monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZone() } + assertNotNull(config) + assertEquals("crontab", config.schedule.type) + assertEquals("15 10 * * 1-5", config.schedule.value) + assertNull(config.schedule.unit) + assertEquals("Europe/Vienna", config.timezone) + } + + @Test + fun `cron without zone is sent in the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + val config = inProgressMonitorConfig { sampleServiceScheduled.cron() } + assertEquals("0 2 * * *", config?.schedule?.value) + assertEquals("Asia/Tokyo", config?.timezone) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `cron and zone placeholders are resolved`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronFromProperties() } + assertEquals("30 2 * * *", config?.schedule?.value) + assertEquals("America/New_York", config?.timezone) + } + + @Test + fun `fixed rate with time unit is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + assertNotNull(config) + assertEquals("interval", config.schedule.type) + assertEquals("2", config.schedule.value) + assertEquals("hour", config.schedule.unit) + } + + @Test + fun `ISO-8601 fixed rate string is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateIso() } + assertEquals("10", config?.schedule?.value) + assertEquals("minute", config?.schedule?.unit) + } + + @Test + fun `simple duration fixed rate string is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateSimpleDuration() } + assertEquals("interval", config?.schedule?.type) + assertEquals("5", config?.schedule?.value) + assertEquals("minute", config?.schedule?.unit) + } + + @Test + fun `fixed delay sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelay() }) + assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelayIso() }) + } + + @Test + fun `disabled cron sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.disabledCron() }) + } + + @Test + fun `zone placeholder resolving to empty uses the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithEmptyZone() } + assertEquals("0 3 * * *", config?.schedule?.value) + assertEquals("Asia/Tokyo", config?.timezone) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `cron zone overrides the default timezone from options`() { + val options = + SentryOptions().apply { + cron = SentryOptions.Cron().apply { defaultTimezone = "Europe/Berlin" } + } + whenever(scopes.options).thenReturn(options) + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZoneAndDefaults() } + assertEquals("Europe/Vienna", config?.timezone) + } + + @Test + fun `unresolvable cron placeholder sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.unresolvableCron() }) + } + + @Test + fun `exception while deriving monitor config sends no monitor config and runs the job`() { + var result = 0 + assertNull(inProgressMonitorConfig { result = sampleServiceScheduled.derivationThrows() }) + assertEquals(1, result) + } + + @Test + fun `monitor config is derived once per method`() { + val first = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + val second = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + assertNotNull(first) + assertSame(first, second) + } + + @Test + fun `multiple schedules send no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.multipleSchedules() }) + } + + @Test + fun `schedules container sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.schedulesContainer() }) + } + + @Test + fun `upsertMonitorConfig false sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDisabled() }) + } + + @Test + fun `upsertMonitorConfig defaults to false`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDefault() }) + } + + @Test + fun `@Scheduled on an overridden parent method sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceInheritedSchedule.job() }) + } + + @Test + fun `heartbeat with @Scheduled sends a single check-in without monitor config`() { + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + sampleServiceScheduled.heartbeat() + assertEquals(1, checkInCaptor.allValues.size) + assertEquals(CheckInStatus.OK.apiName(), checkInCaptor.firstValue.status) + assertNull(checkInCaptor.firstValue.monitorConfig) + } + + private fun inProgressMonitorConfig(block: () -> Unit): MonitorConfig? { + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + block() + assertEquals(2, checkInCaptor.allValues.size) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkInCaptor.firstValue.status) + assertNull(checkInCaptor.lastValue.monitorConfig) + return checkInCaptor.firstValue.monitorConfig + } + @Configuration @EnableAspectJAutoProxy(proxyTargetClass = true) @Import(SentryCheckInAdviceConfiguration::class, SentryCheckInPointcutConfiguration::class) @@ -241,6 +415,10 @@ class SentryCheckInAdviceTest { @Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties() + @Bean open fun sampleServiceScheduled() = SampleServiceScheduled() + + @Bean open fun sampleServiceInheritedSchedule() = SampleServiceInheritedSchedule() + @Bean open fun scopes(): IScopes { val scopes = mock() @@ -291,6 +469,88 @@ class SentryCheckInAdviceTest { open fun helloExceptionProperty() = 1 } + open class SampleServiceScheduled { + + @SentryCheckIn("cron_zone", upsertMonitorConfig = true) + @Scheduled(cron = "0 15 10 * * MON-FRI", zone = "Europe/Vienna") + open fun cronWithZone() {} + + @SentryCheckIn("cron", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 2 * * *") + open fun cron() {} + + @SentryCheckIn("cron_properties", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}") + open fun cronFromProperties() {} + + @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true) + @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS) + open fun fixedRateHours() {} + + @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true) + @Scheduled(fixedRateString = "PT10M") + open fun fixedRateIso() {} + + @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true) + @Scheduled(fixedRateString = "5m") + open fun fixedRateSimpleDuration() {} + + @SentryCheckIn("fixed_delay", upsertMonitorConfig = true) + @Scheduled(fixedDelay = 300_000) + open fun fixedDelay() {} + + @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true) + @Scheduled(fixedDelayString = "PT10M") + open fun fixedDelayIso() {} + + @SentryCheckIn("disabled_cron", upsertMonitorConfig = true) + @Scheduled(cron = "-") + open fun disabledCron() {} + + @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}") + open fun cronWithEmptyZone() {} + + @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna") + open fun cronWithZoneAndDefaults() {} + + @SentryCheckIn("schedules_container", upsertMonitorConfig = true) + @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *")) + open fun schedulesContainer() {} + + @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.missing}") + open fun unresolvableCron() {} + + @SentryCheckIn("derivation_throws", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.exception.property}") + open fun derivationThrows() = 1 + + @SentryCheckIn("multiple", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + @Scheduled(cron = "0 0 13 * * *") + open fun multipleSchedules() {} + + @SentryCheckIn("upsert_disabled", upsertMonitorConfig = false) + @Scheduled(cron = "0 0 1 * * *") + open fun upsertDisabled() {} + + @SentryCheckIn("upsert_default") @Scheduled(cron = "0 0 1 * * *") open fun upsertDefault() {} + + @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + open fun heartbeat() {} + } + + open class ScheduledParent { + @Scheduled(cron = "0 0 1 * * *") open fun job() {} + } + + open class SampleServiceInheritedSchedule : ScheduledParent() { + @SentryCheckIn("inherited", upsertMonitorConfig = true) override fun job() {} + } + class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() { override fun doProcessProperties( diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index 7b36f268231..a9dd9ae363e 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -139,6 +139,7 @@ public final class io/sentry/spring/cache/SentryCacheWrapper : org/springframewo public abstract interface annotation class io/sentry/spring/checkin/SentryCheckIn : java/lang/annotation/Annotation { public abstract fun heartbeat ()Z public abstract fun monitorSlug ()Ljava/lang/String; + public abstract fun upsertMonitorConfig ()Z public abstract fun value ()Ljava/lang/String; } diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java index 805f4bd5464..55fedd93c2e 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java @@ -29,6 +29,16 @@ */ boolean heartbeat() default false; + /** + * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so + * Sentry creates or updates the monitor. + * + *

Heartbeat check-ins never send a monitor config. + * + * @return true to send a monitor config, false by default + */ + boolean upsertMonitorConfig() default false; + /** * Monitor slug. If not set, no check-in will be sent. * diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java index 2abd2cfb994..d43d72a03bb 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java @@ -6,13 +6,21 @@ import io.sentry.DateUtils; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; +import io.sentry.MonitorConfig; import io.sentry.ScopesAdapter; import io.sentry.SentryLevel; import io.sentry.protocol.SentryId; import io.sentry.time.Stopwatch; +import io.sentry.util.MonitorConfigUtils; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; import java.lang.reflect.Method; +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.jetbrains.annotations.ApiStatus; @@ -20,7 +28,11 @@ import org.jetbrains.annotations.Nullable; import org.springframework.aop.support.AopUtils; import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.annotation.Schedules; +import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringValueResolver; @@ -31,10 +43,19 @@ @ApiStatus.Internal @Open public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueResolverAware { + // Spring before 5.3 parses crons with CronSequenceGenerator + private static final boolean LEGACY_CRON_PARSER = + !ClassUtils.isPresent( + "org.springframework.scheduling.support.CronExpression", + SentryCheckInAdvice.class.getClassLoader()); + private final @NotNull IScopes scopes; private @Nullable StringValueResolver resolver; + private final @NotNull Map monitorConfigs = + new ConcurrentHashMap<>(); + public SentryCheckInAdvice() { this(ScopesAdapter.getInstance()); } @@ -90,6 +111,11 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl return invocation.proceed(); } + final @Nullable MonitorConfig monitorConfig = + !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig() + ? monitorConfig(mostSpecificMethod) + : null; + try (final @NotNull ISentryLifecycleToken ignored = scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) { TracingUtils.startNewTrace(scopes); @@ -101,7 +127,9 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl try { if (!isHeartbeatOnly) { - checkInId = scopes.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS)); + final @NotNull CheckIn inProgress = new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS); + inProgress.setMonitorConfig(monitorConfig); + checkInId = scopes.captureCheckIn(inProgress); } return invocation.proceed(); } catch (Throwable e) { @@ -116,6 +144,97 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } } + private @Nullable MonitorConfig monitorConfig(final @NotNull Method method) { + return monitorConfigs.computeIfAbsent( + method, key -> new CachedMonitorConfig(monitorConfigFromScheduled(key))) + .config; + } + + private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) { + try { + final @NotNull Set schedules = + AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class); + if (schedules.size() != 1) { + return null; + } + final @NotNull Scheduled scheduled = schedules.iterator().next(); + // timeUnit only exists from Spring 5.3.10, so read it as an attribute + final @Nullable Object timeUnitAttribute = + AnnotationUtils.getAnnotationAttributes(scheduled).get("timeUnit"); + final @NotNull TimeUnit timeUnit = + timeUnitAttribute instanceof TimeUnit + ? (TimeUnit) timeUnitAttribute + : TimeUnit.MILLISECONDS; + final @Nullable String cron = resolve(scheduled.cron()); + // Spring only reads the zone for a cron + final @Nullable String zone = + cron == null || cron.isEmpty() ? null : resolve(scheduled.zone()); + return MonitorConfigUtils.fromSpringScheduled( + cron, + zone, + periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit), + periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit), + LEGACY_CRON_PARSER); + } catch (RuntimeException e) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Could not derive a monitor config from @Scheduled for method annotated with @SentryCheckIn.", + e); + return null; + } + } + + private @Nullable Long periodMillis( + final long value, final @NotNull String valueString, final @NotNull TimeUnit timeUnit) { + if (value >= 0) { + return timeUnit.toMillis(value); + } + final @Nullable String resolved = resolve(valueString); + if (resolved == null || resolved.isEmpty()) { + return null; + } + final @NotNull String trimmed = resolved.trim(); + @Nullable Long millis = null; + if (trimmed.startsWith("P") || trimmed.startsWith("p")) { + try { + millis = Duration.parse(trimmed).toMillis(); + } catch (DateTimeParseException | ArithmeticException e) { + // logged below + } + } else { + millis = MonitorConfigUtils.parsePeriodMillis(trimmed, timeUnit); + } + if (millis == null) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not sending a monitor config for @SentryCheckIn because the @Scheduled period '%s' could not be parsed.", + trimmed); + } + return millis; + } + + private @Nullable String resolve(final @NotNull String value) { + if (resolver == null || value.isEmpty()) { + return value; + } + return resolver.resolveStringValue(value); + } + + private static final class CachedMonitorConfig { + private final @Nullable MonitorConfig config; + + private CachedMonitorConfig(final @Nullable MonitorConfig config) { + this.config = config; + } + } + @Override public void setEmbeddedValueResolver(StringValueResolver resolver) { this.resolver = resolver; diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt index 2c5e7d0cd06..0bac0df528c 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt @@ -4,17 +4,22 @@ import io.sentry.CheckIn import io.sentry.CheckInStatus import io.sentry.IScopes import io.sentry.ISentryLifecycleToken +import io.sentry.MonitorConfig import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.protocol.SentryId import io.sentry.spring.checkin.SentryCheckIn import io.sentry.spring.checkin.SentryCheckInAdviceConfiguration import io.sentry.spring.checkin.SentryCheckInPointcutConfiguration +import java.util.TimeZone +import java.util.concurrent.TimeUnit import kotlin.RuntimeException import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame import org.junit.jupiter.api.assertThrows import org.junit.runner.RunWith import org.mockito.kotlin.any @@ -33,6 +38,8 @@ import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.EnableAspectJAutoProxy import org.springframework.context.annotation.Import import org.springframework.context.support.PropertySourcesPlaceholderConfigurer +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.scheduling.annotation.Schedules import org.springframework.test.context.TestPropertySource import org.springframework.test.context.junit.jupiter.SpringJUnitConfig import org.springframework.test.context.junit4.SpringRunner @@ -40,7 +47,15 @@ import org.springframework.util.StringValueResolver @RunWith(SpringRunner::class) @SpringJUnitConfig(SentryCheckInAdviceTest.Config::class) -@TestPropertySource(properties = ["my.cron.slug = mypropertycronslug"]) +@TestPropertySource( + properties = + [ + "my.cron.slug = mypropertycronslug", + "my.cron.schedule = 0 30 2 * * *", + "my.cron.zone = America/New_York", + "my.cron.empty.zone = ", + ] +) class SentryCheckInAdviceTest { @Autowired lateinit var sampleService: SampleService @@ -51,6 +66,10 @@ class SentryCheckInAdviceTest { @Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties + @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled + + @Autowired lateinit var sampleServiceInheritedSchedule: SampleServiceInheritedSchedule + @Autowired lateinit var scopes: IScopes val lifecycleToken = mock() @@ -75,6 +94,7 @@ class SentryCheckInAdviceTest { val inProgressCheckIn = checkInCaptor.firstValue assertEquals("monitor_slug_1", inProgressCheckIn.monitorSlug) assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status) + assertNull(inProgressCheckIn.monitorConfig) val doneCheckIn = checkInCaptor.lastValue assertEquals("monitor_slug_1", doneCheckIn.monitorSlug) @@ -229,6 +249,160 @@ class SentryCheckInAdviceTest { order.verify(lifecycleToken).close() } + @Test + fun `cron with zone is sent as crontab monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZone() } + assertNotNull(config) + assertEquals("crontab", config.schedule.type) + assertEquals("15 10 * * 1-5", config.schedule.value) + assertNull(config.schedule.unit) + assertEquals("Europe/Vienna", config.timezone) + } + + @Test + fun `cron without zone is sent in the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + val config = inProgressMonitorConfig { sampleServiceScheduled.cron() } + assertEquals("0 2 * * *", config?.schedule?.value) + assertEquals("Asia/Tokyo", config?.timezone) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `cron and zone placeholders are resolved`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronFromProperties() } + assertEquals("30 2 * * *", config?.schedule?.value) + assertEquals("America/New_York", config?.timezone) + } + + @Test + fun `fixed rate with time unit is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + assertNotNull(config) + assertEquals("interval", config.schedule.type) + assertEquals("2", config.schedule.value) + assertEquals("hour", config.schedule.unit) + } + + @Test + fun `ISO-8601 fixed rate string is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateIso() } + assertEquals("10", config?.schedule?.value) + assertEquals("minute", config?.schedule?.unit) + } + + @Test + fun `simple duration fixed rate string is sent as interval monitor config`() { + val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateSimpleDuration() } + assertEquals("interval", config?.schedule?.type) + assertEquals("5", config?.schedule?.value) + assertEquals("minute", config?.schedule?.unit) + } + + @Test + fun `fixed delay sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelay() }) + assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelayIso() }) + } + + @Test + fun `disabled cron sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.disabledCron() }) + } + + @Test + fun `zone placeholder resolving to empty uses the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithEmptyZone() } + assertEquals("0 3 * * *", config?.schedule?.value) + assertEquals("Asia/Tokyo", config?.timezone) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `cron zone overrides the default timezone from options`() { + val options = + SentryOptions().apply { + cron = SentryOptions.Cron().apply { defaultTimezone = "Europe/Berlin" } + } + whenever(scopes.options).thenReturn(options) + val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZoneAndDefaults() } + assertEquals("Europe/Vienna", config?.timezone) + } + + @Test + fun `unresolvable cron placeholder sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.unresolvableCron() }) + } + + @Test + fun `exception while deriving monitor config sends no monitor config and runs the job`() { + var result = 0 + assertNull(inProgressMonitorConfig { result = sampleServiceScheduled.derivationThrows() }) + assertEquals(1, result) + } + + @Test + fun `monitor config is derived once per method`() { + val first = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + val second = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() } + assertNotNull(first) + assertSame(first, second) + } + + @Test + fun `multiple schedules send no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.multipleSchedules() }) + } + + @Test + fun `schedules container sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.schedulesContainer() }) + } + + @Test + fun `upsertMonitorConfig false sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDisabled() }) + } + + @Test + fun `upsertMonitorConfig defaults to false`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDefault() }) + } + + @Test + fun `@Scheduled on an overridden parent method sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceInheritedSchedule.job() }) + } + + @Test + fun `heartbeat with @Scheduled sends a single check-in without monitor config`() { + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + sampleServiceScheduled.heartbeat() + assertEquals(1, checkInCaptor.allValues.size) + assertEquals(CheckInStatus.OK.apiName(), checkInCaptor.firstValue.status) + assertNull(checkInCaptor.firstValue.monitorConfig) + } + + private fun inProgressMonitorConfig(block: () -> Unit): MonitorConfig? { + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + block() + assertEquals(2, checkInCaptor.allValues.size) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkInCaptor.firstValue.status) + assertNull(checkInCaptor.lastValue.monitorConfig) + return checkInCaptor.firstValue.monitorConfig + } + @Configuration @EnableAspectJAutoProxy(proxyTargetClass = true) @Import(SentryCheckInAdviceConfiguration::class, SentryCheckInPointcutConfiguration::class) @@ -242,6 +416,10 @@ class SentryCheckInAdviceTest { @Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties() + @Bean open fun sampleServiceScheduled() = SampleServiceScheduled() + + @Bean open fun sampleServiceInheritedSchedule() = SampleServiceInheritedSchedule() + @Bean open fun scopes(): IScopes { val scopes = mock() @@ -292,6 +470,88 @@ class SentryCheckInAdviceTest { open fun helloExceptionProperty() = 1 } + open class SampleServiceScheduled { + + @SentryCheckIn("cron_zone", upsertMonitorConfig = true) + @Scheduled(cron = "0 15 10 * * MON-FRI", zone = "Europe/Vienna") + open fun cronWithZone() {} + + @SentryCheckIn("cron", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 2 * * *") + open fun cron() {} + + @SentryCheckIn("cron_properties", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}") + open fun cronFromProperties() {} + + @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true) + @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS) + open fun fixedRateHours() {} + + @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true) + @Scheduled(fixedRateString = "PT10M") + open fun fixedRateIso() {} + + @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true) + @Scheduled(fixedRateString = "5m") + open fun fixedRateSimpleDuration() {} + + @SentryCheckIn("fixed_delay", upsertMonitorConfig = true) + @Scheduled(fixedDelay = 300_000) + open fun fixedDelay() {} + + @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true) + @Scheduled(fixedDelayString = "PT10M") + open fun fixedDelayIso() {} + + @SentryCheckIn("disabled_cron", upsertMonitorConfig = true) + @Scheduled(cron = "-") + open fun disabledCron() {} + + @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}") + open fun cronWithEmptyZone() {} + + @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna") + open fun cronWithZoneAndDefaults() {} + + @SentryCheckIn("schedules_container", upsertMonitorConfig = true) + @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *")) + open fun schedulesContainer() {} + + @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.missing}") + open fun unresolvableCron() {} + + @SentryCheckIn("derivation_throws", upsertMonitorConfig = true) + @Scheduled(cron = "\${my.cron.exception.property}") + open fun derivationThrows() = 1 + + @SentryCheckIn("multiple", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + @Scheduled(cron = "0 0 13 * * *") + open fun multipleSchedules() {} + + @SentryCheckIn("upsert_disabled", upsertMonitorConfig = false) + @Scheduled(cron = "0 0 1 * * *") + open fun upsertDisabled() {} + + @SentryCheckIn("upsert_default") @Scheduled(cron = "0 0 1 * * *") open fun upsertDefault() {} + + @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + open fun heartbeat() {} + } + + open class ScheduledParent { + @Scheduled(cron = "0 0 1 * * *") open fun job() {} + } + + open class SampleServiceInheritedSchedule : ScheduledParent() { + @SentryCheckIn("inherited", upsertMonitorConfig = true) override fun job() {} + } + class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() { override fun doProcessProperties( From 1aab810207df24f00eda73f456f02ed85f3da467 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 14:59:26 -0500 Subject: [PATCH 37/40] feat(quartz): Send monitor config from the trigger with check-ins (#6216) * ref(core): Add MonitorConfigUtils to convert schedules to monitor configs Framework-free conversion of a 6 field cron expression, fixed rate or fixed delay into a MonitorConfig, plus period parsing, so the Spring and Quartz integrations can share it. * fix(core): Only send monitor configs Sentry reads the same way Return no config when day of month and day of week are both set, for cron syntax Sentry rejects, and for zones that are not IANA region IDs. Whole-hour fixed offsets map to Etc/GMT zones. Intervals use hours or days when they divide evenly. * ref(core): Add MonitorConfigUtils.fromSpringScheduled Move the Spring day-of-week rewrite and the JVM default zone rule into core so the three Spring modules share them. * fix(core): Return no config for stepped single value ranges Spring and Quartz run 10-10/2 only at 10, cronsim steps to the field max. * fix(core): Read Spring crons per parser and skip fixed delays Spring before 5.3 steps day of month */n from 0 and can run at midnight when both day fields are set; read its crons that way. Spring 5.3 runs #5 in months without a fifth weekday, so return null for it. A fixed delay drifts from any interval, so it gets no config. * feat(spring): Send monitor config from @Scheduled with @SentryCheckIn Co-Authored-By: Claude * feat(quartz): Send monitor config from the trigger with check-ins Co-Authored-By: Claude * feat(spring): Send @Scheduled monitor config by default Co-Authored-By: Claude * feat(quartz): Send trigger monitor config by default Co-Authored-By: Claude * ref(spring): Shorten upsertMonitorConfig Javadoc Co-Authored-By: Claude * fix(core): Keep Sunday-only day of week ranges SUN-SUN was rewritten to 0-7, which crontab reads as every day. Co-Authored-By: Claude * fix(spring): Only catch runtime exceptions when deriving monitor config Co-Authored-By: Claude * fix(quartz): Catch only expected errors when deriving monitor config Co-Authored-By: Claude * ref(spring): Shorten upsertMonitorConfig Javadoc Co-Authored-By: Claude * ref(spring): Use pattern-matching instanceof for the @Scheduled time unit * fix(core): Use a 2 minute check-in margin for crons with fixed seconds Dropping a non-zero seconds field makes check-ins arrive up to 59s after the expected minute, which a 1 minute margin barely covers. * fix(spring): Default upsertMonitorConfig to false and read @Scheduled like Spring Existing @SentryCheckIn users shouldn't have their monitors changed on upgrade, so sending the schedule is opt-in. Find @Scheduled with Spring's own lookup so an overriding method doesn't inherit a parent's schedule. * fix(quartz): Send the trigger's monitor config only when opted in Match @SentryCheckIn: existing check-in users keep their monitors unchanged unless they set sentry-upsert-monitor-config to "true". --------- Co-authored-by: Claude --- CHANGELOG.md | 1 + sentry-quartz/api/sentry-quartz.api | 1 + sentry-quartz/build.gradle.kts | 2 + .../io/sentry/quartz/SentryJobListener.java | 179 +++++++++- .../io/sentry/quartz/SentryJobListenerTest.kt | 332 ++++++++++++++++++ 5 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 sentry-quartz/src/test/kotlin/io/sentry/quartz/SentryJobListenerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3a2469776..202ca434ba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Features +- Quartz `SentryJobListener` can send a monitor config derived from the trigger with check-ins, so Sentry creates or updates the monitor from code. Set the `sentry-upsert-monitor-config` job or trigger data key to `"true"` to send the schedule ([#6216](https://github.com/getsentry/sentry-java/pull/6216)) - Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = true)` to send the schedule ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) ## 8.63.0 diff --git a/sentry-quartz/api/sentry-quartz.api b/sentry-quartz/api/sentry-quartz.api index bb8b142a912..95179351c17 100644 --- a/sentry-quartz/api/sentry-quartz.api +++ b/sentry-quartz/api/sentry-quartz.api @@ -7,6 +7,7 @@ public final class io/sentry/quartz/SentryJobListener : org/quartz/JobListener { public static final field SENTRY_CHECK_IN_ID_KEY Ljava/lang/String; public static final field SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY Ljava/lang/String; public static final field SENTRY_SLUG_KEY Ljava/lang/String; + public static final field SENTRY_UPSERT_MONITOR_CONFIG_KEY Ljava/lang/String; public fun ()V public fun (Lio/sentry/IScopes;)V public fun getName ()Ljava/lang/String; diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts index d8ede48f169..dc655971f60 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -35,6 +35,8 @@ dependencies { testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) + testImplementation(libs.google.truth) + testImplementation(libs.quartz) } tasks.withType().configureEach { diff --git a/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java b/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java index 95b69169b7e..a548066e157 100644 --- a/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java +++ b/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java @@ -5,20 +5,33 @@ import io.sentry.CheckInStatus; import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; +import io.sentry.MonitorConfig; import io.sentry.ScopesAdapter; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryLevel; import io.sentry.protocol.SentryId; import io.sentry.util.LifecycleHelper; +import io.sentry.util.MonitorConfigUtils; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.TimeZone; +import java.util.regex.Pattern; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.quartz.CronTrigger; import org.quartz.JobDataMap; +import org.quartz.JobDetail; import org.quartz.JobExecutionContext; import org.quartz.JobExecutionException; import org.quartz.JobListener; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.SimpleTrigger; +import org.quartz.Trigger; @ApiStatus.Experimental public final class SentryJobListener implements JobListener { @@ -32,6 +45,19 @@ public final class SentryJobListener implements JobListener { public static final String SENTRY_SLUG_KEY = "sentry-slug"; public static final String SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY = "sentry-scope-lifecycle"; + /** + * Job or trigger data key. Set it to {@code "true"} to send the trigger's schedule with + * check-ins, so Sentry creates or updates the monitor. + */ + public static final String SENTRY_UPSERT_MONITOR_CONFIG_KEY = "sentry-upsert-monitor-config"; + + private static final @NotNull List DAY_NAMES = + Arrays.asList("SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"); + + // Quartz ignores a step after a name, so SUN/2 is every Sunday + private static final @NotNull Pattern NAME_STEP = + Pattern.compile("(^|,)[A-Za-z]{3}(-[A-Za-z]{3})?/"); + private final @NotNull IScopes scopes; public SentryJobListener() { @@ -55,15 +81,18 @@ public void jobToBeExecuted(final @NotNull JobExecutionContext context) { if (maybeSlug == null) { return; } + final @Nullable MonitorConfig monitorConfig = + shouldUpsertMonitorConfig(context) ? monitorConfigFromTrigger(context) : null; final @NotNull ISentryLifecycleToken lifecycleToken = scopes.forkedScopes("SentryJobListener").makeCurrent(); + context.put(SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY, lifecycleToken); TracingUtils.startNewTrace(scopes); final @NotNull String slug = maybeSlug; final @NotNull CheckIn checkIn = new CheckIn(slug, CheckInStatus.IN_PROGRESS); + checkIn.setMonitorConfig(monitorConfig); final @NotNull SentryId checkInId = scopes.captureCheckIn(checkIn); context.put(SENTRY_CHECK_IN_ID_KEY, checkInId); context.put(SENTRY_SLUG_KEY, slug); - context.put(SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY, lifecycleToken); } catch (Throwable t) { scopes .getOptions() @@ -84,6 +113,154 @@ public void jobToBeExecuted(final @NotNull JobExecutionContext context) { return null; } + private boolean shouldUpsertMonitorConfig(final @NotNull JobExecutionContext context) { + final @Nullable JobDataMap jobDataMap = context.getMergedJobDataMap(); + if (jobDataMap == null) { + return false; + } + final @Nullable Object o = jobDataMap.get(SENTRY_UPSERT_MONITOR_CONFIG_KEY); + return o != null && "true".equalsIgnoreCase(o.toString().trim()); + } + + private @Nullable MonitorConfig monitorConfigFromTrigger( + final @NotNull JobExecutionContext context) { + try { + final @Nullable Trigger trigger = context.getTrigger(); + // Sentry can't express calendar exclusions + if (trigger == null || trigger.getCalendarName() != null) { + return null; + } + // each trigger would overwrite the others' schedule + if (hasSeveralTriggers(context)) { + return null; + } + if (trigger instanceof CronTrigger) { + final @NotNull CronTrigger cronTrigger = (CronTrigger) trigger; + final @Nullable String cron = toSixFieldCron(cronTrigger.getCronExpression()); + if (cron == null) { + return null; + } + final @Nullable TimeZone timeZone = cronTrigger.getTimeZone(); + return MonitorConfigUtils.fromSchedule( + cron, timeZone == null ? null : timeZone.getID(), null); + } + if (trigger instanceof SimpleTrigger) { + final @NotNull SimpleTrigger simpleTrigger = (SimpleTrigger) trigger; + if (simpleTrigger.getRepeatCount() != SimpleTrigger.REPEAT_INDEFINITELY) { + return null; + } + return MonitorConfigUtils.fromSchedule(null, null, simpleTrigger.getRepeatInterval()); + } + return null; + } catch (RuntimeException | SchedulerException | NoSuchMethodError | AbstractMethodError e) { + // Trigger implementations may come from other Quartz versions or third parties + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, "Could not derive a monitor config from the Quartz trigger.", e); + return null; + } + } + + private static boolean hasSeveralTriggers(final @NotNull JobExecutionContext context) + throws SchedulerException { + final @Nullable Scheduler scheduler = context.getScheduler(); + final @Nullable JobDetail jobDetail = context.getJobDetail(); + if (scheduler == null || jobDetail == null) { + return false; + } + return scheduler.getTriggersOfJob(jobDetail.getKey()).size() > 1; + } + + /** + * Quartz numbers days of week 1-7 from Sunday; crontab uses 0-6. Null if a year is set or a name + * has a step. + */ + static @Nullable String toSixFieldCron(final @Nullable String quartzCron) { + if (quartzCron == null) { + return null; + } + final @NotNull String[] fields = quartzCron.trim().split("\\s+", -1); + if (fields.length == 7 && !"*".equals(fields[6])) { + return null; + } + if (fields.length != 6 && fields.length != 7) { + return null; + } + if (NAME_STEP.matcher(fields[4]).find() || NAME_STEP.matcher(fields[5]).find()) { + return null; + } + final @Nullable String dayOfWeek = toZeroBasedDayOfWeek(fields[5]); + if (dayOfWeek == null) { + return null; + } + final @NotNull StringBuilder cron = new StringBuilder(); + for (int i = 0; i < 5; i++) { + cron.append(fields[i]).append(' '); + } + return cron.append(dayOfWeek).toString(); + } + + private static @Nullable String toZeroBasedDayOfWeek(final @NotNull String field) { + final @NotNull StringBuilder result = new StringBuilder(); + for (final @NotNull String item : field.split(",", -1)) { + final @Nullable String converted = toZeroBasedDayOfWeekItem(item.toUpperCase(Locale.ROOT)); + if (converted == null) { + return null; + } + if (result.length() > 0) { + result.append(','); + } + result.append(converted); + } + return result.toString(); + } + + private static @Nullable String toZeroBasedDayOfWeekItem(final @NotNull String item) { + if (item.startsWith("*") || item.startsWith("?")) { + // '*' starts on Sunday in both + return item; + } + final int hash = item.indexOf('#'); + if (hash >= 0) { + final @Nullable Integer day = toZeroBasedDay(item.substring(0, hash)); + return day == null ? null : day + item.substring(hash); + } + if (item.length() > 1 && item.endsWith("L")) { + final @Nullable Integer day = toZeroBasedDay(item.substring(0, item.length() - 1)); + return day == null ? null : day + "L"; + } + final int slash = item.indexOf('/'); + final @NotNull String days = slash >= 0 ? item.substring(0, slash) : item; + final @NotNull String step = slash >= 0 ? item.substring(slash) : ""; + final @NotNull String[] range = days.split("-", -1); + if (range.length > 2) { + return null; + } + final @Nullable Integer start = toZeroBasedDay(range[0]); + if (start == null) { + return null; + } + if (range.length == 2) { + final @Nullable Integer end = toZeroBasedDay(range[1]); + return end == null ? null : start + "-" + end + step; + } + if (slash < 0 || start == 6) { + return String.valueOf(start); + } + // a step from a single day ends on Saturday in Quartz, but on Sunday (7) in crontab + return start + "-6" + step; + } + + private static @Nullable Integer toZeroBasedDay(final @NotNull String day) { + if (day.matches("[1-7]")) { + return Integer.parseInt(day) - 1; + } + final int index = DAY_NAMES.indexOf(day); + return index >= 0 ? index : null; + } + @Override public void jobExecutionVetoed(JobExecutionContext context) { // do nothing diff --git a/sentry-quartz/src/test/kotlin/io/sentry/quartz/SentryJobListenerTest.kt b/sentry-quartz/src/test/kotlin/io/sentry/quartz/SentryJobListenerTest.kt new file mode 100644 index 00000000000..7f6ddd68bab --- /dev/null +++ b/sentry-quartz/src/test/kotlin/io/sentry/quartz/SentryJobListenerTest.kt @@ -0,0 +1,332 @@ +package io.sentry.quartz + +import com.google.common.truth.Truth.assertThat +import io.sentry.CheckIn +import io.sentry.CheckInStatus +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.MonitorConfig +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import java.util.TimeZone +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.quartz.CalendarIntervalScheduleBuilder +import org.quartz.CronScheduleBuilder +import org.quartz.JobDataMap +import org.quartz.JobDetail +import org.quartz.JobExecutionContext +import org.quartz.JobKey +import org.quartz.Scheduler +import org.quartz.SimpleScheduleBuilder +import org.quartz.Trigger +import org.quartz.TriggerBuilder + +class SentryJobListenerTest { + + private val scopes = mock() + private val lifecycleToken = mock() + + @BeforeTest + fun setup() { + val forkedScopes = mock() + whenever(scopes.forkedScopes(any())).thenReturn(forkedScopes) + whenever(forkedScopes.makeCurrent()).thenReturn(lifecycleToken) + whenever(scopes.options).thenReturn(SentryOptions()) + } + + @Test + fun `cron trigger is sent as crontab monitor config with timezone`() { + val config = + inProgressMonitorConfig( + cronTrigger("0 15 10 ? * MON-FRI", TimeZone.getTimeZone("America/New_York")) + ) + + assertThat(config).isNotNull() + assertThat(config!!.schedule.type).isEqualTo("crontab") + assertThat(config.schedule.value).isEqualTo("15 10 * * 1-5") + assertThat(config.timezone).isEqualTo("America/New_York") + } + + @Test + fun `cron trigger with wildcard year is sent as crontab`() { + val config = inProgressMonitorConfig(cronTrigger("0 0 2 * * ? *")) + + assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") + } + + @Test + fun `cron trigger with specific year sends no monitor config`() { + assertThat(inProgressMonitorConfig(cronTrigger("0 0 2 * * ? 2030"))).isNull() + } + + @Test + fun `cron trigger with variable seconds sends no monitor config`() { + assertThat(inProgressMonitorConfig(cronTrigger("0/30 * * * * ?"))).isNull() + } + + @Test + fun `cron trigger days of week are converted to start at zero`() { + val config = inProgressMonitorConfig(cronTrigger("0 0 9 ? * 2-6")) + + assertThat(config?.schedule?.value).isEqualTo("0 9 * * 1-5") + } + + @Test + fun `cron trigger with syntax Sentry rejects sends no monitor config`() { + for (cron in + listOf("0 0 12 15W * ?", "0 0 12 L-3 * ?", "0 0 12 ? * FRI-MON", "0 0 22-2 * * ?")) { + assertThat(inProgressMonitorConfig(cronTrigger(cron))).isNull() + } + } + + @Test + fun `cron trigger with whole hour offset zone is sent as Etc zone`() { + val config = inProgressMonitorConfig(cronTrigger("0 0 2 * * ?", TimeZone.getTimeZone("GMT+2"))) + + assertThat(config?.timezone).isEqualTo("Etc/GMT-2") + } + + @Test + fun `cron trigger with zone Sentry rejects sends no monitor config`() { + val trigger = cronTrigger("0 0 2 * * ?", TimeZone.getTimeZone("GMT+05:30")) + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `simple trigger with whole minute interval is sent as interval monitor config`() { + val trigger = + TriggerBuilder.newTrigger() + .withSchedule(SimpleScheduleBuilder.repeatMinutelyForever(5)) + .build() + + val config = inProgressMonitorConfig(trigger) + + assertThat(config).isNotNull() + assertThat(config!!.schedule.type).isEqualTo("interval") + assertThat(config.schedule.value).isEqualTo("5") + assertThat(config.schedule.unit).isEqualTo("minute") + } + + @Test + fun `simple trigger with sub minute interval sends no monitor config`() { + val trigger = + TriggerBuilder.newTrigger() + .withSchedule(SimpleScheduleBuilder.repeatSecondlyForever(90)) + .build() + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `simple trigger without repeat sends no monitor config`() { + val trigger = + TriggerBuilder.newTrigger().withSchedule(SimpleScheduleBuilder.simpleSchedule()).build() + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `simple trigger with a finite repeat count sends no monitor config`() { + val trigger = + TriggerBuilder.newTrigger() + .withSchedule(SimpleScheduleBuilder.repeatMinutelyForTotalCount(5)) + .build() + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `scope token is stored even if capturing the check-in fails`() { + val jobDataMap = JobDataMap() + jobDataMap[SentryJobListener.SENTRY_SLUG_KEY] = "my-job" + val context = mock() + whenever(context.mergedJobDataMap).thenReturn(jobDataMap) + whenever(context.trigger).thenReturn(cronTrigger("0 0 2 * * ?")) + whenever(scopes.captureCheckIn(any())).thenThrow(IllegalStateException("boom")) + + SentryJobListener(scopes).jobToBeExecuted(context) + + verify(context).put(SentryJobListener.SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY, lifecycleToken) + } + + @Test + fun `other triggers send no monitor config`() { + val trigger = + TriggerBuilder.newTrigger() + .withSchedule( + CalendarIntervalScheduleBuilder.calendarIntervalSchedule().withIntervalInDays(1) + ) + .build() + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `trigger with a calendar sends no monitor config`() { + val trigger = + TriggerBuilder.newTrigger() + .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?")) + .modifiedByCalendar("holidays") + .build() + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `sends no monitor config by default`() { + assertThat(inProgressMonitorConfig(cronTrigger("0 0 2 * * ?"), JobDataMap())).isNull() + } + + @Test + fun `upsert monitor config as boolean true sends monitor config`() { + val jobDataMap = JobDataMap() + jobDataMap[SentryJobListener.SENTRY_UPSERT_MONITOR_CONFIG_KEY] = true + + assertThat(inProgressMonitorConfig(cronTrigger("0 0 2 * * ?"), jobDataMap)).isNotNull() + } + + @Test + fun `upsert monitor config true sends monitor config`() { + val jobDataMap = JobDataMap() + jobDataMap[SentryJobListener.SENTRY_UPSERT_MONITOR_CONFIG_KEY] = "true" + + assertThat(inProgressMonitorConfig(cronTrigger("0 0 2 * * ?"), jobDataMap)).isNotNull() + } + + @Test + fun `upsert monitor config false sends no monitor config`() { + val jobDataMap = JobDataMap() + jobDataMap[SentryJobListener.SENTRY_UPSERT_MONITOR_CONFIG_KEY] = "false" + + assertThat(inProgressMonitorConfig(cronTrigger("0 0 2 * * ?"), jobDataMap)).isNull() + } + + @Test + fun `upsert monitor config as boolean false sends no monitor config`() { + val jobDataMap = JobDataMap() + jobDataMap[SentryJobListener.SENTRY_UPSERT_MONITOR_CONFIG_KEY] = false + + assertThat(inProgressMonitorConfig(cronTrigger("0 0 2 * * ?"), jobDataMap)).isNull() + } + + @Test + fun `job with one trigger sends monitor config`() { + val trigger = cronTrigger("0 0 2 * * ?") + + assertThat(inProgressMonitorConfig(trigger, triggersOfJob = listOf(trigger))).isNotNull() + } + + @Test + fun `job with several triggers sends no monitor config`() { + val trigger = cronTrigger("0 0 2 * * ?") + val triggers = listOf(trigger, cronTrigger("0 0 3 * * ?")) + + assertThat(inProgressMonitorConfig(trigger, triggersOfJob = triggers)).isNull() + } + + @Test + fun `failing trigger still sends the check-in without monitor config`() { + val trigger = mock() + whenever(trigger.cronExpression).thenThrow(IllegalStateException("boom")) + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `trigger throwing an error still sends the check-in without monitor config`() { + val trigger = mock() + whenever(trigger.cronExpression).thenThrow(NoSuchMethodError("boom")) + + assertThat(inProgressMonitorConfig(trigger)).isNull() + } + + @Test + fun `quartz cron is converted to six fields`() { + val expected = + mapOf( + "0 0 12 * * ?" to "0 0 12 * * ?", + "0 0 12 * * ? *" to "0 0 12 * * ?", + "0 0 12 ? * 1,7" to "0 0 12 ? * 0,6", + "0 0 12 ? * SUN,sat" to "0 0 12 ? * 0,6", + "0 0 12 ? * MON-FRI" to "0 0 12 ? * 1-5", + "0 0 12 ? * 6#3" to "0 0 12 ? * 5#3", + "0 0 12 ? * FRI#3" to "0 0 12 ? * 5#3", + "0 0 12 ? * 6L" to "0 0 12 ? * 5L", + "0 0 12 ? * FRIL" to "0 0 12 ? * 5L", + "0 0 12 ? * 1/2" to "0 0 12 ? * 0-6/2", + "0 0 12 ? * 2/2" to "0 0 12 ? * 1-6/2", + "0 0 12 ? * 7/2" to "0 0 12 ? * 6", + "0 0 12 ? * 2-6/2" to "0 0 12 ? * 1-5/2", + "0 0 12 ? * */2" to "0 0 12 ? * */2", + "0 0 12 L * ?" to "0 0 12 L * ?", + ) + for ((quartz, cron) in expected) { + assertThat(SentryJobListener.toSixFieldCron(quartz)).isEqualTo(cron) + } + } + + @Test + fun `quartz cron that cannot be converted returns null`() { + for (quartz in + listOf( + "0 0 12 * * ? 2030", + "0 0 12 ? * L", + "0 0 12 ? * 8", + "0 0 12 ? * XYZL", + "0 0 12 ? * SUN/2", + "0 0 12 ? * mon-wed/3", + "0 0 12 ? * 1,MON/2", + "0 0 12 * JUN/3 ?", + "0 12 * * *", + )) { + assertThat(SentryJobListener.toSixFieldCron(quartz)).isNull() + } + } + + private fun cronTrigger(cron: String, timeZone: TimeZone = TimeZone.getTimeZone("UTC")): Trigger = + TriggerBuilder.newTrigger() + .withSchedule(CronScheduleBuilder.cronSchedule(cron).inTimeZone(timeZone)) + .build() + + private fun upsertJobDataMap(): JobDataMap { + val jobDataMap = JobDataMap() + jobDataMap[SentryJobListener.SENTRY_UPSERT_MONITOR_CONFIG_KEY] = "true" + return jobDataMap + } + + private fun inProgressMonitorConfig( + trigger: Trigger, + jobDataMap: JobDataMap = upsertJobDataMap(), + triggersOfJob: List? = null, + ): MonitorConfig? { + jobDataMap[SentryJobListener.SENTRY_SLUG_KEY] = "my-job" + val context = mock() + whenever(context.mergedJobDataMap).thenReturn(jobDataMap) + whenever(context.trigger).thenReturn(trigger) + if (triggersOfJob != null) { + val jobKey = JobKey.jobKey("my-job") + val jobDetail = mock() + whenever(jobDetail.key).thenReturn(jobKey) + val scheduler = mock() + whenever(scheduler.getTriggersOfJob(jobKey)).thenReturn(triggersOfJob) + whenever(context.jobDetail).thenReturn(jobDetail) + whenever(context.scheduler).thenReturn(scheduler) + } + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId()) + + SentryJobListener(scopes).jobToBeExecuted(context) + + assertThat(checkInCaptor.allValues).hasSize(1) + assertThat(checkInCaptor.firstValue.monitorSlug).isEqualTo("my-job") + assertThat(checkInCaptor.firstValue.status).isEqualTo(CheckInStatus.IN_PROGRESS.apiName()) + return checkInCaptor.firstValue.monitorConfig + } +} From 0f3b35070dabef944a65b8bf4e731d2d562a373e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:33:23 +0100 Subject: [PATCH 38/40] chore(deps): bump com.launchdarkly:launchdarkly-java-server-sdk (#6461) Bumps [com.launchdarkly:launchdarkly-java-server-sdk](https://github.com/launchdarkly/java-core) from 7.13.4 to 7.17.3. - [Release notes](https://github.com/launchdarkly/java-core/releases) - [Commits](https://github.com/launchdarkly/java-core/compare/launchdarkly-java-server-sdk-7.13.4...launchdarkly-java-server-sdk-7.17.3) --- updated-dependencies: - dependency-name: com.launchdarkly:launchdarkly-java-server-sdk dependency-version: 7.17.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 65f51bc19a6..867609e0aa6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -151,7 +151,7 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" } ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktorClient" } launchdarkly-android = { module = "com.launchdarkly:launchdarkly-android-client-sdk", version = "5.9.2" } -launchdarkly-server = { module = "com.launchdarkly:launchdarkly-java-server-sdk", version = "7.13.4" } +launchdarkly-server = { module = "com.launchdarkly:launchdarkly-java-server-sdk", version = "7.17.3" } log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j2" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j2" } leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.14" } From 24363dfde6e990ca88e3a5886b23da4b937891b6 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 16:40:55 -0500 Subject: [PATCH 39/40] feat(spring): Add an option to monitor all @Scheduled methods in Spring Boot 3 (#6275) * ref(core): Add MonitorConfigUtils to convert schedules to monitor configs Framework-free conversion of a 6 field cron expression, fixed rate or fixed delay into a MonitorConfig, plus period parsing, so the Spring and Quartz integrations can share it. * fix(core): Only send monitor configs Sentry reads the same way Return no config when day of month and day of week are both set, for cron syntax Sentry rejects, and for zones that are not IANA region IDs. Whole-hour fixed offsets map to Etc/GMT zones. Intervals use hours or days when they divide evenly. * ref(core): Add MonitorConfigUtils.fromSpringScheduled Move the Spring day-of-week rewrite and the JVM default zone rule into core so the three Spring modules share them. * fix(core): Return no config for stepped single value ranges Spring and Quartz run 10-10/2 only at 10, cronsim steps to the field max. * fix(core): Read Spring crons per parser and skip fixed delays Spring before 5.3 steps day of month */n from 0 and can run at midnight when both day fields are set; read its crons that way. Spring 5.3 runs #5 in months without a fifth weekday, so return null for it. A fixed delay drifts from any interval, so it gets no config. * feat(spring): Send monitor config from @Scheduled with @SentryCheckIn Co-Authored-By: Claude * feat(spring): Send @Scheduled monitor config by default Co-Authored-By: Claude * ref(spring): Shorten upsertMonitorConfig Javadoc Co-Authored-By: Claude * fix(core): Keep Sunday-only day of week ranges SUN-SUN was rewritten to 0-7, which crontab reads as every day. Co-Authored-By: Claude * fix(spring): Only catch runtime exceptions when deriving monitor config Co-Authored-By: Claude * ref(spring): Shorten upsertMonitorConfig Javadoc Co-Authored-By: Claude * feat(spring-jakarta): Monitor all @Scheduled methods with sentry.monitor-scheduled-tasks Adds sentry.monitor-scheduled-tasks and sentry.exclude-scheduled-tasks to Spring Boot 3. Methods without @SentryCheckIn get a slug from the class and method name and send their @Scheduled schedule as the monitor config. Co-Authored-By: Claude * changelog Co-Authored-By: Claude * fix(spring-jakarta): Skip reactive @Scheduled methods when monitoring all tasks Spring calls reactive and suspend @Scheduled methods once at startup and subscribes to the result on each run, so check-ins around the call would only be sent once. Co-Authored-By: Claude * fix(spring): Skip jobs with unsupported schedules when monitoring all jobs * ref(spring): Use pattern-matching instanceof for the @Scheduled time unit * fix(core): Use a 2 minute check-in margin for crons with fixed seconds Dropping a non-zero seconds field makes check-ins arrive up to 59s after the expected minute, which a 1 minute margin barely covers. * fix(spring): Default upsertMonitorConfig to false and read @Scheduled like Spring Existing @SentryCheckIn users shouldn't have their monitors changed on upgrade, so sending the schedule is opt-in. Find @Scheduled with Spring's own lookup so an overriding method doesn't inherit a parent's schedule. * fix(spring): Find @Scheduled tasks to monitor the way Spring does An overriding method without @Scheduled isn't scheduled by Spring, so don't monitor it either. * feat(spring): Warn when two @Scheduled methods share a monitor slug Derived slugs use only the short class name and method name, so different methods can map to the same slug and share one monitor. Log a warning the first time that happens so users can set an explicit slug. * ref(spring): Reuse the target class for the slug collision check Use the same class for the task name and the collision check so both see one value. * fix(spring): Skip private and static @Scheduled methods when monitoring all tasks Spring invokes private and static scheduled methods on the target, so the proxy never sees them. Don't match them, so they aren't proxied for nothing. --------- Co-authored-by: Claude --- CHANGELOG.md | 1 + .../api/sentry-spring-boot-jakarta.api | 4 + .../boot/jakarta/SentryAutoConfiguration.java | 29 ++ .../spring/boot/jakarta/SentryProperties.java | 25 ++ .../jakarta/SentryAutoConfigurationTest.kt | 58 +++ .../api/sentry-spring-jakarta.api | 6 + .../jakarta/checkin/SentryCheckInAdvice.java | 108 +++++- .../SentryScheduledCheckInPointcut.java | 66 ++++ .../checkin/SentryScheduledCheckInTest.kt | 365 ++++++++++++++++++ 9 files changed, 657 insertions(+), 5 deletions(-) create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut.java create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 202ca434ba9..4107659a304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Features +- Monitor every Spring `@Scheduled` method with Sentry Crons by setting `sentry.monitor-scheduled-tasks=true`, without adding `@SentryCheckIn` to each method. Sentry creates each monitor from the method's `@Scheduled` schedule. Exclude methods with `sentry.exclude-scheduled-tasks` ([#6275](https://github.com/getsentry/sentry-java/pull/6275)) - Quartz `SentryJobListener` can send a monitor config derived from the trigger with check-ins, so Sentry creates or updates the monitor from code. Set the `sentry-upsert-monitor-config` job or trigger data key to `"true"` to send the schedule ([#6216](https://github.com/getsentry/sentry-java/pull/6216)) - Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = true)` to send the schedule ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) diff --git a/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api b/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api index 57ea90133ba..9b9c46158bc 100644 --- a/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api +++ b/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api @@ -43,19 +43,23 @@ public class io/sentry/spring/boot/jakarta/SentryProperties : io/sentry/SentryOp public fun ()V public fun getClients ()Lio/sentry/spring/boot/jakarta/SentryProperties$Clients; public fun getExceptionResolverOrder ()I + public fun getExcludeScheduledTasks ()Ljava/util/List; public fun getGraphql ()Lio/sentry/spring/boot/jakarta/SentryProperties$Graphql; public fun getLogging ()Lio/sentry/spring/boot/jakarta/SentryProperties$Logging; public fun getReactive ()Lio/sentry/spring/boot/jakarta/SentryProperties$Reactive; public fun getUserFilterOrder ()Ljava/lang/Integer; public fun isEnableAotCompatibility ()Z public fun isKeepTransactionsOpenForAsyncResponses ()Z + public fun isMonitorScheduledTasks ()Z public fun isUseGitCommitIdAsRelease ()Z public fun setClients (Lio/sentry/spring/boot/jakarta/SentryProperties$Clients;)V public fun setEnableAotCompatibility (Z)V public fun setExceptionResolverOrder (I)V + public fun setExcludeScheduledTasks (Ljava/util/List;)V public fun setGraphql (Lio/sentry/spring/boot/jakarta/SentryProperties$Graphql;)V public fun setKeepTransactionsOpenForAsyncResponses (Z)V public fun setLogging (Lio/sentry/spring/boot/jakarta/SentryProperties$Logging;)V + public fun setMonitorScheduledTasks (Z)V public fun setReactive (Lio/sentry/spring/boot/jakarta/SentryProperties$Reactive;)V public fun setUseGitCommitIdAsRelease (Z)V public fun setUserFilterOrder (Ljava/lang/Integer;)V diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java index c47c7e76340..aaa91afa424 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java @@ -26,9 +26,11 @@ import io.sentry.spring.jakarta.SpringProfilesEventProcessor; import io.sentry.spring.jakarta.SpringSecuritySentryUserProvider; import io.sentry.spring.jakarta.cache.SentryCacheBeanPostProcessor; +import io.sentry.spring.jakarta.checkin.SentryCheckInAdvice; import io.sentry.spring.jakarta.checkin.SentryCheckInAdviceConfiguration; import io.sentry.spring.jakarta.checkin.SentryCheckInPointcutConfiguration; import io.sentry.spring.jakarta.checkin.SentryQuartzConfiguration; +import io.sentry.spring.jakarta.checkin.SentryScheduledCheckInPointcut; import io.sentry.spring.jakarta.exception.SentryCaptureExceptionParameterPointcutConfiguration; import io.sentry.spring.jakarta.exception.SentryExceptionParameterAdviceConfiguration; import io.sentry.spring.jakarta.kafka.SentryKafkaConsumerBeanPostProcessor; @@ -49,11 +51,16 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; +import org.aopalliance.aop.Advice; import org.aspectj.lang.ProceedingJoinPoint; import org.jetbrains.annotations.NotNull; import org.quartz.core.QuartzScheduler; import org.slf4j.MDC; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.DefaultPointcutAdvisor; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -73,6 +80,7 @@ import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Role; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.env.Environment; @@ -309,6 +317,27 @@ static class SentryCheckInAspectsConfiguration { @Import(SentryCheckInPointcutConfiguration.class) @Open static class SentryCheckInPointcutAutoConfiguration {} + + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(name = "sentry.monitor-scheduled-tasks", havingValue = "true") + @Open + static class SentryScheduledCheckInConfiguration { + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advice sentryScheduledCheckInAdvice() { + return new SentryCheckInAdvice(ScopesAdapter.getInstance(), true); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advisor sentryScheduledCheckInAdvisor( + final @NotNull @Qualifier("sentryScheduledCheckInAdvice") Advice advice, + final @NotNull SentryProperties sentryProperties) { + return new DefaultPointcutAdvisor( + new SentryScheduledCheckInPointcut(sentryProperties.getExcludeScheduledTasks()), + advice); + } + } } /** Registers beans specific to Spring MVC. */ diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java index 485d5838d2b..c7241a9466e 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java @@ -50,6 +50,15 @@ public class SentryProperties extends SentryOptions { /** Clients integration properties. */ private @NotNull Clients clients = new Clients(); + /** Monitor every {@code @Scheduled} method with Sentry Crons. */ + private boolean monitorScheduledTasks = false; + + /** + * {@code @Scheduled} methods not to monitor when {@code monitorScheduledTasks} is on, as {@code + * ClassName.methodName} or a regex matching it. + */ + private @NotNull List excludeScheduledTasks = new ArrayList<>(); + public boolean isUseGitCommitIdAsRelease() { return useGitCommitIdAsRelease; } @@ -135,6 +144,22 @@ public void setClients(@NotNull Clients clients) { this.clients = clients; } + public boolean isMonitorScheduledTasks() { + return monitorScheduledTasks; + } + + public void setMonitorScheduledTasks(boolean monitorScheduledTasks) { + this.monitorScheduledTasks = monitorScheduledTasks; + } + + public @NotNull List getExcludeScheduledTasks() { + return excludeScheduledTasks; + } + + public void setExcludeScheduledTasks(@NotNull List excludeScheduledTasks) { + this.excludeScheduledTasks = excludeScheduledTasks; + } + @Open public static class Clients { /** Enable automatic instrumentation of Spring-managed OkHttp clients. Disabled by default. */ diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index 31876882c4d..1217bbfea40 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -20,6 +20,7 @@ import io.sentry.NoOpTransportFactory import io.sentry.ProfileLifecycle import io.sentry.SamplingContext import io.sentry.Sentry +import io.sentry.SentryEnvelope import io.sentry.SentryEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel @@ -51,6 +52,7 @@ import io.sentry.transport.apache.ApacheHttpClientTransportFactory import jakarta.servlet.Filter import java.io.File import java.lang.RuntimeException +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -58,9 +60,12 @@ import kotlin.test.assertTrue import org.aspectj.lang.ProceedingJoinPoint import org.assertj.core.api.Assertions.assertThat import org.mockito.internal.util.MockUtil.isMock +import org.mockito.kotlin.after import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock +import org.mockito.kotlin.timeout import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.quartz.JobExecutionContext @@ -71,6 +76,7 @@ import org.quartz.core.QuartzScheduler import org.slf4j.MDC import org.springframework.aop.support.NameMatchMethodPointcut import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration @@ -85,6 +91,8 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.Ordered import org.springframework.core.annotation.Order +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.quartz.SchedulerFactoryBean import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.client.RestClient @@ -1089,6 +1097,44 @@ class SentryAutoConfigurationTest { } } + @Test + fun `does not monitor @Scheduled methods by default`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).doesNotHaveBean("sentryScheduledCheckInAdvisor") + } + } + + @Test + fun `monitors @Scheduled methods when monitor-scheduled-tasks is enabled`() { + baseContextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.monitor-scheduled-tasks=true", + "sentry.exclude-scheduled-tasks[0]=SentryAutoConfigurationTest.ScheduledJobs.excluded", + ) + .withConfiguration(AutoConfigurations.of(AopAutoConfiguration::class.java)) + .withUserConfiguration( + MockTransportConfiguration::class.java, + ScheduledJobsConfiguration::class.java, + ) + .run { + assertThat(it).hasBean("sentryScheduledCheckInAdvisor") + val transport = it.getBean(ITransport::class.java) + val captor = argumentCaptor() + verify(transport, timeout(5000).times(2)).send(any(), anyOrNull()) + verify(transport, after(500).times(2)).send(captor.capture(), anyOrNull()) + val payloads = + captor.allValues.map { envelope -> String(envelope.items.first().data, Charsets.UTF_8) } + assertThat(payloads).allMatch { payload -> + payload.contains( + "\"monitor_slug\":\"sentry-auto-configuration-test-scheduled-jobs-sync\"" + ) + } + assertThat(payloads[0]).contains("\"value\":1,\"unit\":\"hour\"") + assertThat(payloads[1]).contains("\"status\":\"ok\"") + } + } + @Test fun `Sentry quartz job listener is added`() { contextRunner @@ -1308,6 +1354,18 @@ class SentryAutoConfigurationTest { @Bean open fun sentryTransport() = transport } + @Configuration(proxyBeanMethods = false) + @EnableScheduling + open class ScheduledJobsConfiguration { + @Bean open fun scheduledJobs() = ScheduledJobs() + } + + open class ScheduledJobs { + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun sync() {} + + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun excluded() {} + } + @Configuration(proxyBeanMethods = false) open class NoOpTransportConfiguration { diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index 90a746da6c2..0d8eb895104 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -148,6 +148,7 @@ public abstract interface annotation class io/sentry/spring/jakarta/checkin/Sent public class io/sentry/spring/jakarta/checkin/SentryCheckInAdvice : org/aopalliance/intercept/MethodInterceptor, org/springframework/context/EmbeddedValueResolverAware { public fun ()V public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Z)V public fun invoke (Lorg/aopalliance/intercept/MethodInvocation;)Ljava/lang/Object; public fun setEmbeddedValueResolver (Lorg/springframework/util/StringValueResolver;)V } @@ -168,6 +169,11 @@ public class io/sentry/spring/jakarta/checkin/SentryQuartzConfiguration { public fun schedulerFactoryBeanCustomizer ()Lorg/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer; } +public final class io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut : org/springframework/aop/support/StaticMethodMatcherPointcut { + public fun (Ljava/util/List;)V + public fun matches (Ljava/lang/reflect/Method;Ljava/lang/Class;)Z +} + public final class io/sentry/spring/jakarta/checkin/SentrySchedulerFactoryBeanCustomizer : org/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer { public fun ()V public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java index 76b03a917bc..a544c401966 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java @@ -15,12 +15,16 @@ import io.sentry.util.Objects; import io.sentry.util.TracingUtils; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.format.DateTimeParseException; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.zip.CRC32; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.jetbrains.annotations.ApiStatus; @@ -49,19 +53,38 @@ public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueReso "org.springframework.scheduling.support.CronExpression", SentryCheckInAdvice.class.getClassLoader()); + private static final @NotNull Pattern WORD_BOUNDARY = + Pattern.compile("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])"); + private static final int MAX_SLUG_LENGTH = 50; + private final @NotNull IScopes scopes; + private final boolean monitorScheduledTasks; + private @Nullable StringValueResolver resolver; private final @NotNull Map monitorConfigs = new ConcurrentHashMap<>(); + private final @NotNull Map derivedSlugOwners = new ConcurrentHashMap<>(); + + private final @NotNull Set collidingSlugs = ConcurrentHashMap.newKeySet(); + public SentryCheckInAdvice() { this(ScopesAdapter.getInstance()); } public SentryCheckInAdvice(final @NotNull IScopes scopes) { + this(scopes, false); + } + + /** + * @param monitorScheduledTasks whether to also monitor {@code @Scheduled} methods without {@link + * SentryCheckIn}, using a slug derived from the class and method name + */ + public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorScheduledTasks) { this.scopes = Objects.requireNonNull(scopes, "scopes are required"); + this.monitorScheduledTasks = monitorScheduledTasks; } @Override @@ -73,6 +96,24 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl SentryCheckIn checkInAnnotation = AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { + if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { + final @NotNull Class targetClass = invocation.getThis().getClass(); + final @NotNull String taskName = scheduledTaskName(targetClass, mostSpecificMethod); + final @Nullable MonitorConfig monitorConfig = monitorConfig(mostSpecificMethod); + if (monitorConfig == null) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not monitoring %s because Sentry Crons can't express its @Scheduled schedule.", + taskName); + return invocation.proceed(); + } + final @NotNull String monitorSlug = scheduledTaskSlug(taskName); + warnOnSlugCollision(monitorSlug, targetClass, mostSpecificMethod); + return captureCheckIns(invocation, monitorSlug, false, monitorConfig); + } return invocation.proceed(); } @@ -112,7 +153,15 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig() ? monitorConfig(mostSpecificMethod) : null; + return captureCheckIns(invocation, monitorSlug, isHeartbeatOnly, monitorConfig); + } + private Object captureCheckIns( + final @NotNull MethodInvocation invocation, + final @NotNull String monitorSlug, + final boolean isHeartbeatOnly, + final @Nullable MonitorConfig monitorConfig) + throws Throwable { try (final @NotNull ISentryLifecycleToken ignored = scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) { TracingUtils.startNewTrace(scopes); @@ -141,6 +190,58 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } } + private void warnOnSlugCollision( + final @NotNull String monitorSlug, + final @NotNull Class targetClass, + final @NotNull Method method) { + final @NotNull String identity = + ClassUtils.getUserClass(targetClass).getName() + "." + method.getName(); + final @Nullable String owner = derivedSlugOwners.putIfAbsent(monitorSlug, identity); + if (owner != null && !owner.equals(identity) && collidingSlugs.add(monitorSlug)) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them.", + monitorSlug, + owner, + identity); + } + } + + static boolean isScheduled(final @NotNull Method method) { + return !AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class) + .isEmpty(); + } + + /** {@code ClassName.methodName}, with nested classes as {@code Outer.Inner}. */ + static @NotNull String scheduledTaskName( + final @NotNull Class targetClass, final @NotNull Method method) { + return ClassUtils.getShortName(ClassUtils.getUserClass(targetClass)) + "." + method.getName(); + } + + static @NotNull String scheduledTaskSlug(final @NotNull String taskName) { + final @NotNull String slug = + WORD_BOUNDARY + .matcher(taskName) + .replaceAll("-") + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+|-+$", ""); + if (slug.length() <= MAX_SLUG_LENGTH) { + return slug; + } + // keep truncated slugs unique + final @NotNull CRC32 crc = new CRC32(); + crc.update(slug.getBytes(StandardCharsets.UTF_8)); + final @NotNull String hash = String.format(Locale.ROOT, "%08x", crc.getValue()); + return slug.substring(0, MAX_SLUG_LENGTH - hash.length() - 1).replaceAll("-+$", "") + + "-" + + hash; + } + private @Nullable MonitorConfig monitorConfig(final @NotNull Method method) { return monitorConfigs.computeIfAbsent( method, key -> new CachedMonitorConfig(monitorConfigFromScheduled(key))) @@ -175,10 +276,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl scopes .getOptions() .getLogger() - .log( - SentryLevel.WARNING, - "Could not derive a monitor config from @Scheduled for method annotated with @SentryCheckIn.", - e); + .log(SentryLevel.WARNING, "Could not derive a monitor config from @Scheduled.", e); return null; } } @@ -209,7 +307,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl .getLogger() .log( SentryLevel.DEBUG, - "Not sending a monitor config for @SentryCheckIn because the @Scheduled period '%s' could not be parsed.", + "Not sending a monitor config because the @Scheduled period '%s' could not be parsed.", trimmed); } return millis; diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut.java new file mode 100644 index 00000000000..96b4098c2aa --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut.java @@ -0,0 +1,66 @@ +package io.sentry.spring.jakarta.checkin; + +import io.sentry.FilterString; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.aop.support.AopUtils; +import org.springframework.aop.support.StaticMethodMatcherPointcut; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.ClassUtils; + +/** + * Matches {@code @Scheduled} methods without {@link SentryCheckIn}, except excluded ones. Excludes + * match the task name ({@code ClassName.methodName}) exactly or as a regex. + */ +@ApiStatus.Internal +public final class SentryScheduledCheckInPointcut extends StaticMethodMatcherPointcut { + private final @NotNull List excludedScheduledTasks = new ArrayList<>(); + + public SentryScheduledCheckInPointcut(final @Nullable List excludedScheduledTasks) { + if (excludedScheduledTasks != null) { + for (final @NotNull String task : excludedScheduledTasks) { + if (!task.trim().isEmpty()) { + this.excludedScheduledTasks.add(new FilterString(task.trim())); + } + } + } + } + + @Override + public boolean matches(final @NotNull Method method, final @NotNull Class targetClass) { + final @NotNull Class userClass = ClassUtils.getUserClass(targetClass); + // CGLIB can't intercept these, and Spring calls private and static methods on the target + final int modifiers = method.getModifiers(); + if (Modifier.isFinal(userClass.getModifiers()) + || Modifier.isFinal(modifiers) + || Modifier.isPrivate(modifiers) + || Modifier.isStatic(modifiers)) { + return false; + } + final @NotNull Method specificMethod = AopUtils.getMostSpecificMethod(method, userClass); + if (!SentryCheckInAdvice.isScheduled(specificMethod) + || AnnotationUtils.findAnnotation(specificMethod, SentryCheckIn.class) != null) { + return false; + } + // Spring calls reactive and suspend methods once and subscribes to the result on each run + if (specificMethod.getParameterCount() > 0 + || ReactiveAdapterRegistry.getSharedInstance().getAdapter(specificMethod.getReturnType()) + != null) { + return false; + } + final @NotNull String taskName = + SentryCheckInAdvice.scheduledTaskName(userClass, specificMethod); + for (final @NotNull FilterString excluded : excludedScheduledTasks) { + if (excluded.getFilterString().equals(taskName) || excluded.matches(taskName)) { + return false; + } + } + return true; + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt new file mode 100644 index 00000000000..00a2864b3f3 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt @@ -0,0 +1,365 @@ +package io.sentry.spring.jakarta.checkin + +import io.sentry.CheckIn +import io.sentry.CheckInStatus +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import java.lang.reflect.Modifier +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.jupiter.api.assertThrows +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeast +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.aop.Advisor +import org.springframework.aop.Pointcut +import org.springframework.aop.framework.ProxyFactory +import org.springframework.aop.support.AopUtils +import org.springframework.aop.support.DefaultPointcutAdvisor +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.context.annotation.Import +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import org.springframework.test.context.junit4.SpringRunner +import reactor.core.publisher.Mono + +@RunWith(SpringRunner::class) +@SpringJUnitConfig(SentryScheduledCheckInTest.Config::class) +@TestPropertySource(properties = ["my.cron.schedule = 0 30 2 * * *"]) +class SentryScheduledCheckInTest { + private val SLUG = "sentry-scheduled-check-in-test-jobs-sync-users" + private val COLLISION_WARNING = + "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them." + + @Autowired lateinit var jobs: Jobs + + @Autowired lateinit var excludedJobs: ExcludedJobs + + @Autowired lateinit var finalJobs: FinalJobs + + @Autowired lateinit var overridingJobs: OverridingJobs + + @Autowired lateinit var scopes: IScopes + + @BeforeTest + fun setup() { + reset(scopes) + whenever(scopes.options).thenReturn(SentryOptions()) + whenever(scopes.forkedScopes(any())).thenReturn(scopes) + whenever(scopes.makeCurrent()).thenReturn(mock()) + whenever(scopes.captureCheckIn(any())).thenReturn(SentryId()) + } + + @Test + fun `scheduled method without @SentryCheckIn is monitored with a derived slug and config`() { + val checkIns = capture { jobs.syncUsers() } + assertEquals(2, checkIns.size) + assertEquals(SLUG, checkIns[0].monitorSlug) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkIns[0].status) + val config = assertNotNull(checkIns[0].monitorConfig) + assertEquals("0 2 * * *", config.schedule.value) + assertEquals("Europe/Vienna", config.timezone) + assertEquals(SLUG, checkIns[1].monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), checkIns[1].status) + } + + @Test + fun `cron placeholder is resolved`() { + val checkIns = capture { jobs.fromProperty() } + assertEquals("30 2 * * *", checkIns[0].monitorConfig!!.schedule.value) + } + + @Test + fun `tasks with unsupported schedules are not monitored`() { + jobs.fixedDelay() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `@SentryCheckIn with an unsupported schedule checks in without config`() { + val checkIns = capture { jobs.explicitFixedDelay() } + assertEquals(listOf("explicit-delay", "explicit-delay"), checkIns.map { it.monitorSlug }) + assertNull(checkIns[0].monitorConfig) + } + + @Test + fun `failing job sends an error check-in and rethrows`() { + val checkIns = capture { assertThrows { jobs.oops() } } + assertEquals(CheckInStatus.ERROR.apiName(), checkIns[1].status) + } + + @Test + fun `@SentryCheckIn keeps its slug and is only checked in once`() { + val checkIns = capture { jobs.explicit() } + assertEquals(listOf("explicit", "explicit"), checkIns.map { it.monitorSlug }) + assertNull(checkIns[0].monitorConfig) + } + + @Test + fun `methods without @Scheduled are not monitored`() { + jobs.notScheduled() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `overriding methods without @Scheduled are not monitored`() { + overridingJobs.run() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `reactive methods are not monitored`() { + jobs.reactive().block() + verify(scopes, never()).captureCheckIn(any()) + } + + @Test + fun `excluded tasks are not monitored`() { + jobs.excluded() + excludedJobs.run() + verify(scopes, never()).captureCheckIn(any()) + assertFalse(AopUtils.isAopProxy(excludedJobs)) + } + + @Test + fun `final classes are not proxied`() { + finalJobs.run() + verify(scopes, never()).captureCheckIn(any()) + assertFalse(AopUtils.isAopProxy(finalJobs)) + assertTrue(AopUtils.isAopProxy(jobs)) + } + + @Test + fun `private and static methods are not matched`() { + val pointcut = SentryScheduledCheckInPointcut(null) + val privateMethod = HiddenJobs::class.java.getDeclaredMethod("hidden") + val staticMethod = HiddenJobs::class.java.getDeclaredMethod("static") + assertTrue(Modifier.isPrivate(privateMethod.modifiers)) + assertTrue(Modifier.isStatic(staticMethod.modifiers)) + assertFalse(pointcut.matches(privateMethod, HiddenJobs::class.java)) + assertFalse(pointcut.matches(staticMethod, HiddenJobs::class.java)) + assertTrue(pointcut.matches(Jobs::class.java.getMethod("syncUsers"), Jobs::class.java)) + } + + @Test + fun `slugs are kebab case and at most 50 characters`() { + assertEquals( + "report-jobs-send-daily-report", + SentryCheckInAdvice.scheduledTaskSlug("ReportJobs.sendDailyReport"), + ) + assertEquals( + "http-client-jobs-run", + SentryCheckInAdvice.scheduledTaskSlug("HTTPClientJobs.run"), + ) + assertEquals("outer-inner-run2-fa", SentryCheckInAdvice.scheduledTaskSlug("Outer.Inner.run2FA")) + val first = + SentryCheckInAdvice.scheduledTaskSlug( + "AVeryLongScheduledJobClassNameForReporting.firstMethod" + ) + val second = + SentryCheckInAdvice.scheduledTaskSlug( + "AVeryLongScheduledJobClassNameForReporting.secondMethod" + ) + assertTrue(first.length <= 50) + assertTrue(first.startsWith("a-very-long-scheduled-job-class-name-for-")) + assertTrue(first != second) + assertEquals( + first, + SentryCheckInAdvice.scheduledTaskSlug( + "AVeryLongScheduledJobClassNameForReporting.firstMethod" + ), + ) + } + + @Test + fun `task name uses the user class`() { + assertEquals( + "SentryScheduledCheckInTest.Jobs.syncUsers", + SentryCheckInAdvice.scheduledTaskName( + jobs.javaClass, + Jobs::class.java.getMethod("syncUsers"), + ), + ) + } + + @Test + fun `warns once when two methods derive the same slug`() { + val logger = mockLogger() + val advice = SentryCheckInAdvice(scopes, true) + val cleanupJob = proxy(CleanupJob(), advice) + val cleanup = proxy(Cleanup(), advice) + + cleanupJob.run() + cleanup.jobRun() + cleanup.jobRun() + cleanupJob.run() + + verify(logger, times(1)) + .log( + eq(SentryLevel.WARNING), + eq(COLLISION_WARNING), + eq("sentry-scheduled-check-in-test-cleanup-job-run"), + eq("${CleanupJob::class.java.name}.run"), + eq("${Cleanup::class.java.name}.jobRun"), + ) + } + + @Test + fun `does not warn when derived slugs differ or the same method runs again`() { + val logger = mockLogger() + val advice = SentryCheckInAdvice(scopes, true) + val cleanupJob = proxy(CleanupJob(), advice) + val otherJobs = proxy(Jobs(), advice) + + cleanupJob.run() + cleanupJob.run() + otherJobs.syncUsers() + + verify(logger, never()).log(eq(SentryLevel.WARNING), eq(COLLISION_WARNING), any(), any(), any()) + } + + private fun mockLogger(): ILogger { + val logger = mock() + val options = + SentryOptions().apply { + isDebug = true + setLogger(logger) + } + whenever(scopes.options).thenReturn(options) + return logger + } + + private fun proxy(target: T, advice: SentryCheckInAdvice): T { + val factory = ProxyFactory(target) + factory.isProxyTargetClass = true + factory.addAdvice(advice) + @Suppress("UNCHECKED_CAST") + return factory.proxy as T + } + + private fun capture(block: () -> Unit): List { + val captor = argumentCaptor() + block() + verify(scopes, atLeast(0)).captureCheckIn(captor.capture()) + return captor.allValues + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(SentryCheckInPointcutConfiguration::class) + open class Config { + + @Bean open fun jobs() = Jobs() + + @Bean open fun excludedJobs() = ExcludedJobs() + + @Bean open fun finalJobs() = FinalJobs() + + @Bean open fun overridingJobs() = OverridingJobs() + + @Bean open fun scopes(): IScopes = mock() + + @Bean + open fun sentryCheckInAdvisor( + @Qualifier("sentryCheckInPointcut") pointcut: Pointcut, + scopes: IScopes, + ): Advisor = DefaultPointcutAdvisor(pointcut, SentryCheckInAdvice(scopes)) + + @Bean open fun sentryScheduledCheckInAdvice(scopes: IScopes) = SentryCheckInAdvice(scopes, true) + + @Bean + open fun sentryScheduledCheckInAdvisor( + @Qualifier("sentryScheduledCheckInAdvice") advice: SentryCheckInAdvice + ): Advisor = + DefaultPointcutAdvisor( + SentryScheduledCheckInPointcut( + listOf("SentryScheduledCheckInTest.Jobs.excluded", ".*\\.ExcludedJobs\\..*", " ") + ), + advice, + ) + + companion object { + @Bean + @JvmStatic + fun propertySourcesPlaceholderConfigurer() = PropertySourcesPlaceholderConfigurer() + } + } + + open class Jobs { + @Scheduled(cron = "0 0 2 * * *", zone = "Europe/Vienna") open fun syncUsers() {} + + @Scheduled(cron = "\${my.cron.schedule}") open fun fromProperty() {} + + @Scheduled(fixedDelay = 1000) open fun fixedDelay() {} + + @Scheduled(cron = "0 0 2 * * *") + open fun oops() { + throw IllegalStateException("thrown on purpose") + } + + @SentryCheckIn("explicit") @Scheduled(cron = "0 0 2 * * *") open fun explicit() {} + + @SentryCheckIn("explicit-delay") @Scheduled(fixedDelay = 1000) open fun explicitFixedDelay() {} + + @Scheduled(cron = "0 0 2 * * *") open fun excluded() {} + + open fun notScheduled() {} + + @Scheduled(cron = "0 0 2 * * *") open fun reactive(): Mono = Mono.empty() + } + + open class CleanupJob { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + open class Cleanup { + @Scheduled(cron = "0 0 2 * * *") open fun jobRun() {} + } + + open class HiddenJobs { + @Scheduled(cron = "0 0 2 * * *") private fun hidden() {} + + companion object { + @JvmStatic @Scheduled(cron = "0 0 2 * * *") fun static() {} + } + } + + open class ExcludedJobs { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + class FinalJobs { + @Scheduled(cron = "0 0 2 * * *") fun run() {} + } + + open class ParentJobs { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + open class OverridingJobs : ParentJobs() { + override fun run() {} + } +} From f5e69b74de20d8e617ebdfbc1ecdd97ee7dbeb0b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:54:06 +0100 Subject: [PATCH 40/40] chore(deps): bump com.github.ben-manes.caffeine:jcache (#6463) Bumps [com.github.ben-manes.caffeine:jcache](https://github.com/ben-manes/caffeine) from 3.2.0 to 3.3.1. - [Release notes](https://github.com/ben-manes/caffeine/releases) - [Commits](https://github.com/ben-manes/caffeine/compare/v3.2.0...v3.3.1) --- updated-dependencies: - dependency-name: com.github.ben-manes.caffeine:jcache dependency-version: 3.3.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 867609e0aa6..b5d5e27cb29 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -131,7 +131,7 @@ androidx-browser = { module = "androidx.browser:browser", version = "1.8.0" } async-profiler = { module = "tools.profiler:async-profiler", version.ref = "asyncProfiler" } async-profiler-jfr-converter = { module = "tools.profiler:jfr-converter", version.ref = "asyncProfiler" } caffeine = { module = "com.github.ben-manes.caffeine:caffeine" } -caffeine-jcache = { module = "com.github.ben-manes.caffeine:jcache", version = "3.2.0" } +caffeine-jcache = { module = "com.github.ben-manes.caffeine:jcache", version = "3.3.1" } coil-compose = { module = "io.coil-kt:coil-compose", version = "2.7.0" } commons-compress = {module = "org.apache.commons:commons-compress", version = "1.28.0"} context-propagation = { module = "io.micrometer:context-propagation", version = "1.1.0" }