From 5b475d1cf20f4d0d6030b68a87e0a1383391af00 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 16 Oct 2025 12:58:08 +0200 Subject: [PATCH 01/30] Add scope based feature flags --- .../java/io/sentry/samples/console/Main.java | 2 + .../ConsoleApplicationSystemTest.kt | 2 +- .../java/io/sentry/samples/console/Main.java | 2 + .../ConsoleApplicationSystemTest.kt | 2 +- .../main/java/io/sentry/samples/jul/Main.java | 5 + .../ConsoleApplicationSystemTest.kt | 5 + .../java/io/sentry/samples/log4j2/Main.java | 3 + .../ConsoleApplicationSystemTest.kt | 5 + .../java/io/sentry/samples/logback/Main.java | 4 + .../ConsoleApplicationSystemTest.kt | 5 + .../samples/spring7/web/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 5 + .../spring/boot4/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 10 + .../spring/boot4/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 10 + .../spring/boot4/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 5 + .../spring/boot4/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 10 + .../spring/boot/jakarta/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 10 + .../spring/boot/jakarta/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 10 + .../spring/boot/jakarta/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 10 + .../samples/spring/boot/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 10 + .../samples/spring/boot/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 10 + .../spring/boot/jakarta/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 5 + .../samples/spring/boot/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 5 + .../samples/spring/boot/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 5 + .../spring/jakarta/web/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 5 + .../samples/spring/web/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 5 + .../api/sentry-system-test-support.api | 2 + .../io/sentry/systemtest/util/TestHelper.kt | 25 +++ sentry/api/sentry.api | 99 +++++++++ .../java/io/sentry/CombinedContextsView.java | 23 ++ .../java/io/sentry/CombinedScopeView.java | 22 ++ .../src/main/java/io/sentry/HubAdapter.java | 5 + .../main/java/io/sentry/HubScopesWrapper.java | 5 + sentry/src/main/java/io/sentry/IScope.java | 12 + sentry/src/main/java/io/sentry/IScopes.java | 2 + sentry/src/main/java/io/sentry/NoOpHub.java | 3 + sentry/src/main/java/io/sentry/NoOpScope.java | 16 ++ .../src/main/java/io/sentry/NoOpScopes.java | 3 + sentry/src/main/java/io/sentry/Scope.java | 23 ++ sentry/src/main/java/io/sentry/Scopes.java | 5 + .../main/java/io/sentry/ScopesAdapter.java | 5 + sentry/src/main/java/io/sentry/Sentry.java | 4 + .../src/main/java/io/sentry/SentryClient.java | 8 + .../main/java/io/sentry/SentryOptions.java | 23 ++ .../featureflags/FeatureFlagBuffer.java | 207 ++++++++++++++++++ .../featureflags/IFeatureFlagBuffer.java | 17 ++ .../featureflags/NoOpFeatureFlagBuffer.java | 28 +++ .../java/io/sentry/protocol/Contexts.java | 11 + .../java/io/sentry/protocol/FeatureFlag.java | 142 ++++++++++++ .../java/io/sentry/protocol/FeatureFlags.java | 127 +++++++++++ sentry/src/test/java/io/sentry/ScopeTest.kt | 16 ++ sentry/src/test/java/io/sentry/ScopesTest.kt | 38 ++++ .../test/java/io/sentry/SentryOptionsTest.kt | 13 ++ .../featureflags/FeatureFlagBufferTest.kt | 182 +++++++++++++++ 68 files changed, 1229 insertions(+), 2 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java create mode 100644 sentry/src/main/java/io/sentry/featureflags/IFeatureFlagBuffer.java create mode 100644 sentry/src/main/java/io/sentry/featureflags/NoOpFeatureFlagBuffer.java create mode 100644 sentry/src/main/java/io/sentry/protocol/FeatureFlag.java create mode 100644 sentry/src/main/java/io/sentry/protocol/FeatureFlags.java create mode 100644 sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/main/java/io/sentry/samples/console/Main.java b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/main/java/io/sentry/samples/console/Main.java index 8af939c32bf..a41e853e156 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/main/java/io/sentry/samples/console/Main.java +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/main/java/io/sentry/samples/console/Main.java @@ -61,6 +61,8 @@ public static void main(String[] args) throws InterruptedException { // Only data added to the scope on `configureScope` above is included. Sentry.captureMessage("Some warning!", SentryLevel.WARNING); + Sentry.addFeatureFlag("my-feature-flag", true); + // Sending exception: Exception exception = new RuntimeException("Some error!"); Sentry.captureException(exception); diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt index 427c930653b..29d144355a7 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt @@ -55,7 +55,7 @@ class ConsoleApplicationSystemTest { // Verify we received the RuntimeException testHelper.ensureErrorReceived { event -> event.exceptions?.any { ex -> ex.type == "RuntimeException" && ex.value == "Some error!" } == - true + true && testHelper.doesEventHaveFlag(event, "my-feature-flag", true) } // Verify we received the detailed event with fingerprint diff --git a/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java b/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java index 9d6a8fd7a9e..5f557b7ad2a 100644 --- a/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java +++ b/sentry-samples/sentry-samples-console/src/main/java/io/sentry/samples/console/Main.java @@ -126,6 +126,8 @@ public static void main(String[] args) throws InterruptedException { // Only data added to the scope on `configureScope` above is included. Sentry.captureMessage("Some warning!", SentryLevel.WARNING); + Sentry.addFeatureFlag("my-feature-flag", true); + // Sending exception: Exception exception = new RuntimeException("Some error!"); Sentry.captureException(exception); diff --git a/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt index 18409c5b4e6..cf09728047d 100644 --- a/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ b/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt @@ -51,7 +51,7 @@ class ConsoleApplicationSystemTest { // Verify we received the RuntimeException testHelper.ensureErrorReceived { event -> event.exceptions?.any { ex -> ex.type == "RuntimeException" && ex.value == "Some error!" } == - true + true && testHelper.doesEventHaveFlag(event, "my-feature-flag", true) } // Verify we received the detailed event with fingerprint diff --git a/sentry-samples/sentry-samples-jul/src/main/java/io/sentry/samples/jul/Main.java b/sentry-samples/sentry-samples-jul/src/main/java/io/sentry/samples/jul/Main.java index 86030003f62..9f245470af4 100644 --- a/sentry-samples/sentry-samples-jul/src/main/java/io/sentry/samples/jul/Main.java +++ b/sentry-samples/sentry-samples-jul/src/main/java/io/sentry/samples/jul/Main.java @@ -1,5 +1,6 @@ package io.sentry.samples.jul; +import io.sentry.Sentry; import java.util.UUID; import java.util.logging.Level; import java.util.logging.LogManager; @@ -22,6 +23,10 @@ public static void main(String[] args) throws Exception { MDC.put("userId", UUID.randomUUID().toString()); MDC.put("requestId", UUID.randomUUID().toString()); + Sentry.addFeatureFlag("my-feature-flag", true); + + LOGGER.warning("important warning"); + // logging arguments are converted to Sentry Event parameters LOGGER.log(Level.INFO, "User has made a purchase of product: %d", 445); diff --git a/sentry-samples/sentry-samples-jul/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-jul/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt index 3c89c5a7e20..d23428da943 100644 --- a/sentry-samples/sentry-samples-jul/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ b/sentry-samples/sentry-samples-jul/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt @@ -57,6 +57,11 @@ class ConsoleApplicationSystemTest { } != null } + testHelper.ensureErrorReceived { event -> + event.message?.message == "important warning" && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureLogsReceived { logs, _ -> testHelper.doesContainLogWithBody(logs, "User has made a purchase of product: 445") && testHelper.doesContainLogWithBody(logs, "Something went wrong") diff --git a/sentry-samples/sentry-samples-log4j2/src/main/java/io/sentry/samples/log4j2/Main.java b/sentry-samples/sentry-samples-log4j2/src/main/java/io/sentry/samples/log4j2/Main.java index 9a7612354a6..5703fff5d44 100644 --- a/sentry-samples/sentry-samples-log4j2/src/main/java/io/sentry/samples/log4j2/Main.java +++ b/sentry-samples/sentry-samples-log4j2/src/main/java/io/sentry/samples/log4j2/Main.java @@ -1,5 +1,6 @@ package io.sentry.samples.log4j2; +import io.sentry.Sentry; import java.util.UUID; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -19,6 +20,8 @@ public static void main(String[] args) { // ThreadContext tag not listed in log4j2.xml ThreadContext.put("context-tag", "context-tag-value"); + Sentry.addFeatureFlag("my-feature-flag", true); + // logging arguments are converted to Sentry Event parameters LOGGER.info("User has made a purchase of product: {}", 445); // because minimumEventLevel is set to WARN this raises an event diff --git a/sentry-samples/sentry-samples-log4j2/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-log4j2/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt index eed0354863f..5d3266c6ff8 100644 --- a/sentry-samples/sentry-samples-log4j2/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ b/sentry-samples/sentry-samples-log4j2/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt @@ -47,6 +47,11 @@ class ConsoleApplicationSystemTest { event.level?.name == "ERROR" } + testHelper.ensureErrorReceived { event -> + event.message?.message == "Important warning" && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureErrorReceived { event -> event.breadcrumbs?.firstOrNull { it.message == "Hello Sentry!" && it.level == SentryLevel.DEBUG diff --git a/sentry-samples/sentry-samples-logback/src/main/java/io/sentry/samples/logback/Main.java b/sentry-samples/sentry-samples-logback/src/main/java/io/sentry/samples/logback/Main.java index 4aeae0038f0..ec3928998a4 100644 --- a/sentry-samples/sentry-samples-logback/src/main/java/io/sentry/samples/logback/Main.java +++ b/sentry-samples/sentry-samples-logback/src/main/java/io/sentry/samples/logback/Main.java @@ -1,5 +1,6 @@ package io.sentry.samples.logback; +import io.sentry.Sentry; import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,6 +18,9 @@ public static void main(String[] args) { // MDC tag not listed in logback.xml MDC.put("context-tag", "context-tag-value"); + Sentry.addFeatureFlag("my-feature-flag", true); + LOGGER.warn("important warning"); + // logging arguments are converted to Sentry Event parameters LOGGER.info("User has made a purchase of product: {}", 445); diff --git a/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt index 72744c0ec31..40169882224 100644 --- a/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ b/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt @@ -53,6 +53,11 @@ class ConsoleApplicationSystemTest { } != null } + testHelper.ensureErrorReceived { event -> + event.message?.message == "important warning" && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureErrorReceived { event -> event.breadcrumbs?.firstOrNull { it.message == "User has made a purchase of product: 445" && it.level == SentryLevel.INFO diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonController.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonController.java index a9a413fd5f7..d66cf747c1f 100644 --- a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonController.java +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonController.java @@ -26,6 +26,7 @@ Person person(@PathVariable("id") Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); LOGGER.info("Loading person with id={}", id); if (id > 10L) { throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); diff --git a/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 628c27f4c6f..3ad118d52f6 100644 --- a/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-7/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,11 @@ class PersonSystemTest { restClient.getPerson(11L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=11]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonController.java index f3f03b39e1f..70159b8aef0 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonController.java @@ -36,6 +36,7 @@ Person person(@PathVariable Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); ISpan currentSpan = Sentry.getSpan(); ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); try { diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 707b5025dcf..2488cc87d11 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,16 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Trying person with id=1" && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonController.java index 9b727447ffd..2861168fc79 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonController.java @@ -34,6 +34,7 @@ Person person(@PathVariable Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); ISpan currentSpan = Sentry.getSpan(); ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); try { diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 707b5025dcf..2488cc87d11 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,16 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Trying person with id=1" && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/PersonController.java index b2563200c83..0db43f5ab71 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/PersonController.java @@ -26,6 +26,7 @@ Person person(@PathVariable Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); LOGGER.info("Loading person with id={}", id); throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); } diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 26c3282e7a8..ac74d5e4953 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,11 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonController.java index 305850ec18b..ff545349591 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonController.java @@ -29,6 +29,7 @@ Person person(@PathVariable Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); LOGGER.error("Trying person with id={}", id, new RuntimeException("error while loading")); throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); } finally { diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 7d6e0182530..03a0abbdf2f 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,16 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Trying person with id=1" && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java index b3f22fd7fd4..26784880a75 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java @@ -36,6 +36,7 @@ Person person(@PathVariable Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); ISpan currentSpan = Sentry.getSpan(); ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); try { diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 707b5025dcf..2488cc87d11 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,16 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Trying person with id=1" && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java index 1584a9e823b..1880799c28e 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java @@ -34,6 +34,7 @@ Person person(@PathVariable Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); ISpan currentSpan = Sentry.getSpan(); ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); try { diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 707b5025dcf..2488cc87d11 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,16 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Trying person with id=1" && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java index 94a4b9b8520..2e24833b80f 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java @@ -29,6 +29,7 @@ Person person(@PathVariable Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); LOGGER.error("Trying person with id={}", id, new RuntimeException("error while loading")); throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); } finally { diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 7d6e0182530..03a0abbdf2f 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,16 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Trying person with id=1" && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/PersonController.java b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/PersonController.java index 97c5aa2e6f3..e2574e788c4 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/PersonController.java @@ -36,6 +36,7 @@ Person person(@PathVariable Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); ISpan currentSpan = Sentry.getSpan(); ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); try { diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 707b5025dcf..2488cc87d11 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,16 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Trying person with id=1" && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/PersonController.java b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/PersonController.java index 04816e34630..6c6209403b2 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/PersonController.java @@ -34,6 +34,7 @@ Person person(@PathVariable Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); ISpan currentSpan = Sentry.getSpan(); ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); try { diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 707b5025dcf..2488cc87d11 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,16 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Trying person with id=1" && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java index a7b7752806d..d1a505d0e59 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java @@ -26,6 +26,7 @@ Person person(@PathVariable Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); LOGGER.info("Loading person with id={}", id); throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 26c3282e7a8..ac74d5e4953 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,11 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/PersonController.java b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/PersonController.java index 010e42f3026..d0b5435efc1 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/main/java/io/sentry/samples/spring/boot/PersonController.java @@ -26,6 +26,7 @@ Person person(@PathVariable Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); LOGGER.info("Loading person with id={}", id); throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 26c3282e7a8..ac74d5e4953 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,11 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/PersonController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/PersonController.java index 45e3b96f888..3bf03cb785f 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/PersonController.java @@ -25,6 +25,7 @@ Person person(@PathVariable Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); LOGGER.info("Loading person with id={}", id); throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); } diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 7d6e0182530..8119e5ab4e9 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,11 @@ class PersonSystemTest { restClient.getPerson(1L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } diff --git a/sentry-samples/sentry-samples-spring-jakarta/src/main/java/io/sentry/samples/spring/jakarta/web/PersonController.java b/sentry-samples/sentry-samples-spring-jakarta/src/main/java/io/sentry/samples/spring/jakarta/web/PersonController.java index dab805281e1..ec33f360967 100644 --- a/sentry-samples/sentry-samples-spring-jakarta/src/main/java/io/sentry/samples/spring/jakarta/web/PersonController.java +++ b/sentry-samples/sentry-samples-spring-jakarta/src/main/java/io/sentry/samples/spring/jakarta/web/PersonController.java @@ -26,6 +26,7 @@ Person person(@PathVariable("id") Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); LOGGER.info("Loading person with id={}", id); if (id > 10L) { throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); diff --git a/sentry-samples/sentry-samples-spring-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 80d21b1d934..1215b819127 100644 --- a/sentry-samples/sentry-samples-spring-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,11 @@ class PersonSystemTest { restClient.getPerson(11L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=11]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } diff --git a/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/web/PersonController.java b/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/web/PersonController.java index 37da24d5812..ee4020e0324 100644 --- a/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/web/PersonController.java +++ b/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/web/PersonController.java @@ -26,6 +26,7 @@ Person person(@PathVariable("id") Long id) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); LOGGER.info("Loading person with id={}", id); if (id > 10L) { throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); diff --git a/sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 97b826e10d0..aecc6ce24e1 100644 --- a/sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -20,6 +20,11 @@ class PersonSystemTest { restClient.getPerson(11L) assertEquals(500, restClient.lastKnownStatusCode) + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=11]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> testHelper.doesTransactionHaveOp(transaction, "http.server") } diff --git a/sentry-system-test-support/api/sentry-system-test-support.api b/sentry-system-test-support/api/sentry-system-test-support.api index b32583074d4..b2a159f92eb 100644 --- a/sentry-system-test-support/api/sentry-system-test-support.api +++ b/sentry-system-test-support/api/sentry-system-test-support.api @@ -552,6 +552,8 @@ public final class io/sentry/systemtest/util/SentryMockServerClient : io/sentry/ public final class io/sentry/systemtest/util/TestHelper { public fun (Ljava/lang/String;)V public final fun doesContainLogWithBody (Lio/sentry/SentryLogEvents;Ljava/lang/String;)Z + public final fun doesEventHaveExceptionMessage (Lio/sentry/SentryEvent;Ljava/lang/String;)Z + public final fun doesEventHaveFlag (Lio/sentry/SentryEvent;Ljava/lang/String;Z)Z public final fun doesTransactionContainSpanWithDescription (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;)Z public final fun doesTransactionContainSpanWithOp (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;)Z public final fun doesTransactionContainSpanWithOpAndDescription (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;Ljava/lang/String;)Z diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt index 73b2acd2ae1..00bfa743a39 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -275,6 +275,31 @@ class TestHelper(backendUrl: String) { return true } + fun doesEventHaveExceptionMessage(event: SentryEvent, expectedMessage: String): Boolean { + val exceptions = event.exceptions + if (exceptions == null) { + println("Unable to find exceptions in event") + return false + } + + val foundException = exceptions.firstOrNull { expectedMessage == it.value } + return foundException != null + } + + fun doesEventHaveFlag(event: SentryEvent, flag: String, result: Boolean): Boolean { + val featureFlags = event.contexts.featureFlags + if (featureFlags == null) { + println("Unable to find feature flags in event:") + return false + } + val foundFlag = + featureFlags.values.firstOrNull { featureFlag -> + println("checking flag ${featureFlag.flag}:${featureFlag.result}") + featureFlag.flag == flag && featureFlag.result == result + } + return foundFlag != null + } + fun findJar(prefix: String, inDir: String = "build/libs"): File { val buildDir = File(inDir) val jarFiles = diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 4bfa96f1207..88de3232de2 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -228,6 +228,7 @@ public final class io/sentry/CombinedContextsView : io/sentry/protocol/Contexts public fun getApp ()Lio/sentry/protocol/App; public fun getBrowser ()Lio/sentry/protocol/Browser; public fun getDevice ()Lio/sentry/protocol/Device; + public fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; public fun getGpu ()Lio/sentry/protocol/Gpu; public fun getOperatingSystem ()Lio/sentry/protocol/OperatingSystem; public fun getResponse ()Lio/sentry/protocol/Response; @@ -246,6 +247,7 @@ public final class io/sentry/CombinedContextsView : io/sentry/protocol/Contexts public fun setApp (Lio/sentry/protocol/App;)V public fun setBrowser (Lio/sentry/protocol/Browser;)V public fun setDevice (Lio/sentry/protocol/Device;)V + public fun setFeatureFlags (Lio/sentry/protocol/FeatureFlags;)V public fun setGpu (Lio/sentry/protocol/Gpu;)V public fun setOperatingSystem (Lio/sentry/protocol/OperatingSystem;)V public fun setResponse (Lio/sentry/protocol/Response;)V @@ -262,6 +264,7 @@ public final class io/sentry/CombinedScopeView : io/sentry/IScope { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public fun addEventProcessor (Lio/sentry/EventProcessor;)V + public fun addFeatureFlag (Ljava/lang/String;Z)V public fun assignTraceContext (Lio/sentry/SentryEvent;)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun clear ()V @@ -279,6 +282,8 @@ public final class io/sentry/CombinedScopeView : io/sentry/IScope { public fun getEventProcessors ()Ljava/util/List; public fun getEventProcessorsWithOrder ()Ljava/util/List; public fun getExtras ()Ljava/util/Map; + public fun getFeatureFlagBuffer ()Lio/sentry/featureflags/IFeatureFlagBuffer; + public fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; public fun getFingerprint ()Ljava/util/List; public fun getLastEventId ()Lio/sentry/protocol/SentryId; public fun getLevel ()Lio/sentry/SentryLevel; @@ -607,6 +612,7 @@ public final class io/sentry/HttpStatusCodeRange { public final class io/sentry/HubAdapter : io/sentry/IHub { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V + public fun addFeatureFlag (Ljava/lang/String;Z)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -679,6 +685,7 @@ public final class io/sentry/HubScopesWrapper : io/sentry/IHub { public fun (Lio/sentry/IScopes;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V + public fun addFeatureFlag (Ljava/lang/String;Z)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -841,6 +848,7 @@ public abstract interface class io/sentry/IScope { public abstract fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public abstract fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public abstract fun addEventProcessor (Lio/sentry/EventProcessor;)V + public abstract fun addFeatureFlag (Ljava/lang/String;Z)V public abstract fun assignTraceContext (Lio/sentry/SentryEvent;)V public abstract fun bindClient (Lio/sentry/ISentryClient;)V public abstract fun clear ()V @@ -857,6 +865,8 @@ public abstract interface class io/sentry/IScope { public abstract fun getEventProcessors ()Ljava/util/List; public abstract fun getEventProcessorsWithOrder ()Ljava/util/List; public abstract fun getExtras ()Ljava/util/Map; + public abstract fun getFeatureFlagBuffer ()Lio/sentry/featureflags/IFeatureFlagBuffer; + public abstract fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; public abstract fun getFingerprint ()Ljava/util/List; public abstract fun getLastEventId ()Lio/sentry/protocol/SentryId; public abstract fun getLevel ()Lio/sentry/SentryLevel; @@ -926,6 +936,7 @@ public abstract interface class io/sentry/IScopes { public abstract fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public fun addBreadcrumb (Ljava/lang/String;)V public fun addBreadcrumb (Ljava/lang/String;Ljava/lang/String;)V + public abstract fun addFeatureFlag (Ljava/lang/String;Z)V public abstract fun bindClient (Lio/sentry/ISentryClient;)V public abstract fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;)Lio/sentry/protocol/SentryId; @@ -1507,6 +1518,7 @@ public final class io/sentry/NoOpEnvelopeReader : io/sentry/IEnvelopeReader { public final class io/sentry/NoOpHub : io/sentry/IHub { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V + public fun addFeatureFlag (Ljava/lang/String;Z)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -1608,6 +1620,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public fun addEventProcessor (Lio/sentry/EventProcessor;)V + public fun addFeatureFlag (Ljava/lang/String;Z)V public fun assignTraceContext (Lio/sentry/SentryEvent;)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun clear ()V @@ -1625,6 +1638,8 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun getEventProcessors ()Ljava/util/List; public fun getEventProcessorsWithOrder ()Ljava/util/List; public fun getExtras ()Ljava/util/Map; + public fun getFeatureFlagBuffer ()Lio/sentry/featureflags/IFeatureFlagBuffer; + public fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; public fun getFingerprint ()Ljava/util/List; public static fun getInstance ()Lio/sentry/NoOpScope; public fun getLastEventId ()Lio/sentry/protocol/SentryId; @@ -1674,6 +1689,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public final class io/sentry/NoOpScopes : io/sentry/IScopes { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V + public fun addFeatureFlag (Ljava/lang/String;Z)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -2267,6 +2283,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public fun addEventProcessor (Lio/sentry/EventProcessor;)V + public fun addFeatureFlag (Ljava/lang/String;Z)V public fun assignTraceContext (Lio/sentry/SentryEvent;)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun clear ()V @@ -2284,6 +2301,8 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun getEventProcessors ()Ljava/util/List; public fun getEventProcessorsWithOrder ()Ljava/util/List; public fun getExtras ()Ljava/util/Map; + public fun getFeatureFlagBuffer ()Lio/sentry/featureflags/IFeatureFlagBuffer; + public fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; public fun getFingerprint ()Ljava/util/List; public fun getLastEventId ()Lio/sentry/protocol/SentryId; public fun getLevel ()Lio/sentry/SentryLevel; @@ -2382,6 +2401,7 @@ public final class io/sentry/Scopes : io/sentry/IScopes { public fun (Lio/sentry/IScope;Lio/sentry/IScope;Lio/sentry/IScope;Ljava/lang/String;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V + public fun addFeatureFlag (Ljava/lang/String;Z)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -2453,6 +2473,7 @@ public final class io/sentry/Scopes : io/sentry/IScopes { public final class io/sentry/ScopesAdapter : io/sentry/IScopes { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V + public fun addFeatureFlag (Ljava/lang/String;Z)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -2563,6 +2584,7 @@ public final class io/sentry/Sentry { public static fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public static fun addBreadcrumb (Ljava/lang/String;)V public static fun addBreadcrumb (Ljava/lang/String;Ljava/lang/String;)V + public static fun addFeatureFlag (Ljava/lang/String;Z)V public static fun bindClient (Lio/sentry/ISentryClient;)V public static fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public static fun captureEvent (Lio/sentry/SentryEvent;)Lio/sentry/protocol/SentryId; @@ -3373,6 +3395,7 @@ public class io/sentry/SentryOptions { public fun getMaxBreadcrumbs ()I public fun getMaxCacheItems ()I public fun getMaxDepth ()I + public fun getMaxFeatureFlags ()I public fun getMaxQueueSize ()I public fun getMaxRequestBodySize ()Lio/sentry/SentryOptions$RequestSize; public fun getMaxSpans ()I @@ -3519,6 +3542,7 @@ public class io/sentry/SentryOptions { public fun setMaxBreadcrumbs (I)V public fun setMaxCacheItems (I)V public fun setMaxDepth (I)V + public fun setMaxFeatureFlags (I)V public fun setMaxQueueSize (I)V public fun setMaxRequestBodySize (Lio/sentry/SentryOptions$RequestSize;)V public fun setMaxSpans (I)V @@ -4693,6 +4717,30 @@ public final class io/sentry/exception/SentryHttpClientException : java/lang/Exc public fun (Ljava/lang/String;)V } +public final class io/sentry/featureflags/FeatureFlagBuffer : io/sentry/featureflags/IFeatureFlagBuffer { + public fun add (Ljava/lang/String;Z)V + public fun clone ()Lio/sentry/featureflags/IFeatureFlagBuffer; + public synthetic fun clone ()Ljava/lang/Object; + public static fun create (Lio/sentry/SentryOptions;)Lio/sentry/featureflags/IFeatureFlagBuffer; + public fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; + public static fun merged (Lio/sentry/SentryOptions;Lio/sentry/featureflags/IFeatureFlagBuffer;Lio/sentry/featureflags/IFeatureFlagBuffer;Lio/sentry/featureflags/IFeatureFlagBuffer;)Lio/sentry/featureflags/IFeatureFlagBuffer; +} + +public abstract interface class io/sentry/featureflags/IFeatureFlagBuffer { + public abstract fun add (Ljava/lang/String;Z)V + public abstract fun clone ()Lio/sentry/featureflags/IFeatureFlagBuffer; + public abstract fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; +} + +public final class io/sentry/featureflags/NoOpFeatureFlagBuffer : io/sentry/featureflags/IFeatureFlagBuffer { + public fun ()V + public fun add (Ljava/lang/String;Z)V + public fun clone ()Lio/sentry/featureflags/IFeatureFlagBuffer; + public synthetic fun clone ()Ljava/lang/Object; + public fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; + public static fun getInstance ()Lio/sentry/featureflags/NoOpFeatureFlagBuffer; +} + public abstract interface class io/sentry/hints/AbnormalExit { public abstract fun ignoreCurrentThread ()Z public abstract fun mechanism ()Ljava/lang/String; @@ -5164,6 +5212,7 @@ public class io/sentry/protocol/Contexts : io/sentry/JsonSerializable { public fun getApp ()Lio/sentry/protocol/App; public fun getBrowser ()Lio/sentry/protocol/Browser; public fun getDevice ()Lio/sentry/protocol/Device; + public fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; public fun getFeedback ()Lio/sentry/protocol/Feedback; public fun getGpu ()Lio/sentry/protocol/Gpu; public fun getOperatingSystem ()Lio/sentry/protocol/OperatingSystem; @@ -5185,6 +5234,7 @@ public class io/sentry/protocol/Contexts : io/sentry/JsonSerializable { public fun setApp (Lio/sentry/protocol/App;)V public fun setBrowser (Lio/sentry/protocol/Browser;)V public fun setDevice (Lio/sentry/protocol/Device;)V + public fun setFeatureFlags (Lio/sentry/protocol/FeatureFlags;)V public fun setFeedback (Lio/sentry/protocol/Feedback;)V public fun setGpu (Lio/sentry/protocol/Gpu;)V public fun setOperatingSystem (Lio/sentry/protocol/OperatingSystem;)V @@ -5410,6 +5460,55 @@ public final class io/sentry/protocol/Device$JsonKeys { public fun ()V } +public final class io/sentry/protocol/FeatureFlag : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun (Ljava/lang/String;Z)V + public fun equals (Ljava/lang/Object;)Z + public fun getFlag ()Ljava/lang/String; + public fun getResult ()Ljava/lang/Boolean; + public fun getUnknown ()Ljava/util/Map; + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setFlag (Ljava/lang/String;)V + public fun setResult (Ljava/lang/Boolean;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/protocol/FeatureFlag$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/FeatureFlag; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/protocol/FeatureFlag$JsonKeys { + public static final field FLAG Ljava/lang/String; + public static final field RESULT Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/protocol/FeatureFlags : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field TYPE Ljava/lang/String; + public fun ()V + public fun (Ljava/util/List;)V + public fun equals (Ljava/lang/Object;)Z + public fun getUnknown ()Ljava/util/Map; + public fun getValues ()Ljava/util/List; + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setUnknown (Ljava/util/Map;)V + public fun setValues (Ljava/util/List;)V +} + +public final class io/sentry/protocol/FeatureFlags$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/FeatureFlags; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/protocol/FeatureFlags$JsonKeys { + public static final field VALUES Ljava/lang/String; + public fun ()V +} + public final class io/sentry/protocol/Feedback : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field TYPE Ljava/lang/String; public fun (Lio/sentry/protocol/Feedback;)V diff --git a/sentry/src/main/java/io/sentry/CombinedContextsView.java b/sentry/src/main/java/io/sentry/CombinedContextsView.java index 31b5c060620..3cb10e88b81 100644 --- a/sentry/src/main/java/io/sentry/CombinedContextsView.java +++ b/sentry/src/main/java/io/sentry/CombinedContextsView.java @@ -4,6 +4,7 @@ import io.sentry.protocol.Browser; import io.sentry.protocol.Contexts; import io.sentry.protocol.Device; +import io.sentry.protocol.FeatureFlags; import io.sentry.protocol.Gpu; import io.sentry.protocol.OperatingSystem; import io.sentry.protocol.Response; @@ -14,6 +15,7 @@ import java.util.Enumeration; import java.util.Map; import java.util.Set; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -225,6 +227,27 @@ public void setSpring(@NotNull Spring spring) { getDefaultContexts().setSpring(spring); } + @Override + public @Nullable FeatureFlags getFeatureFlags() { + // these are not intended to be set on a scopes Context directly + final @Nullable FeatureFlags current = currentContexts.getFeatureFlags(); + if (current != null) { + return current; + } + final @Nullable FeatureFlags isolation = isolationContexts.getFeatureFlags(); + if (isolation != null) { + return isolation; + } + return globalContexts.getFeatureFlags(); + } + + @ApiStatus.Internal + @Override + /** Not intended to be set on a scopes Context directly */ + public void setFeatureFlags(@NotNull FeatureFlags spring) { + getDefaultContexts().setFeatureFlags(spring); + } + @Override public int size() { return mergeContexts().size(); diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java index d6ac5b824a9..4b1faddb56d 100644 --- a/sentry/src/main/java/io/sentry/CombinedScopeView.java +++ b/sentry/src/main/java/io/sentry/CombinedScopeView.java @@ -2,8 +2,11 @@ import static io.sentry.Scope.createBreadcrumbsList; +import io.sentry.featureflags.FeatureFlagBuffer; +import io.sentry.featureflags.IFeatureFlagBuffer; import io.sentry.internal.eventprocessor.EventProcessorAndOrder; import io.sentry.protocol.Contexts; +import io.sentry.protocol.FeatureFlags; import io.sentry.protocol.Request; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; @@ -507,4 +510,23 @@ public void replaceOptions(@NotNull SentryOptions options) { public void setReplayId(@NotNull SentryId replayId) { getDefaultWriteScope().setReplayId(replayId); } + + @Override + public void addFeatureFlag(final @NotNull String flag, final boolean result) { + getDefaultWriteScope().addFeatureFlag(flag, result); + } + + @Override + public @Nullable FeatureFlags getFeatureFlags() { + return getFeatureFlagBuffer().getFeatureFlags(); + } + + @Override + public @NotNull IFeatureFlagBuffer getFeatureFlagBuffer() { + return FeatureFlagBuffer.merged( + getOptions(), + globalScope.getFeatureFlagBuffer(), + isolationScope.getFeatureFlagBuffer(), + scope.getFeatureFlagBuffer()); + } } diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index f9065dd64c1..f92b477cbe3 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -388,4 +388,9 @@ public void reportFullyDisplayed() { public @NotNull ILoggerApi logger() { return Sentry.getCurrentScopes().logger(); } + + @Override + public void addFeatureFlag(final @NotNull String flag, final boolean result) { + Sentry.addFeatureFlag(flag, result); + } } diff --git a/sentry/src/main/java/io/sentry/HubScopesWrapper.java b/sentry/src/main/java/io/sentry/HubScopesWrapper.java index 4430402af06..818d62f6b95 100644 --- a/sentry/src/main/java/io/sentry/HubScopesWrapper.java +++ b/sentry/src/main/java/io/sentry/HubScopesWrapper.java @@ -373,4 +373,9 @@ public void reportFullyDisplayed() { public @NotNull ILoggerApi logger() { return scopes.logger(); } + + @Override + public void addFeatureFlag(final @NotNull String flag, final boolean result) { + scopes.addFeatureFlag(flag, result); + } } diff --git a/sentry/src/main/java/io/sentry/IScope.java b/sentry/src/main/java/io/sentry/IScope.java index ddabd00569e..4617b4cf31e 100644 --- a/sentry/src/main/java/io/sentry/IScope.java +++ b/sentry/src/main/java/io/sentry/IScope.java @@ -1,7 +1,9 @@ package io.sentry; +import io.sentry.featureflags.IFeatureFlagBuffer; import io.sentry.internal.eventprocessor.EventProcessorAndOrder; import io.sentry.protocol.Contexts; +import io.sentry.protocol.FeatureFlags; import io.sentry.protocol.Request; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; @@ -422,4 +424,14 @@ void setSpanContext( @ApiStatus.Internal void replaceOptions(final @NotNull SentryOptions options); + + void addFeatureFlag(final @NotNull String flag, final boolean result); + + @ApiStatus.Internal + @Nullable + FeatureFlags getFeatureFlags(); + + @ApiStatus.Internal + @NotNull + IFeatureFlagBuffer getFeatureFlagBuffer(); } diff --git a/sentry/src/main/java/io/sentry/IScopes.java b/sentry/src/main/java/io/sentry/IScopes.java index 0fbc1008596..465fbc5eb3e 100644 --- a/sentry/src/main/java/io/sentry/IScopes.java +++ b/sentry/src/main/java/io/sentry/IScopes.java @@ -743,4 +743,6 @@ default boolean isNoOp() { @NotNull ILoggerApi logger(); + + void addFeatureFlag(final @NotNull String flag, final boolean result); } diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index c6b31c1a5ce..0292d1a82d5 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -330,4 +330,7 @@ public boolean isNoOp() { public @NotNull ILoggerApi logger() { return NoOpLoggerApi.getInstance(); } + + @Override + public void addFeatureFlag(final @NotNull String flag, final boolean result) {} } diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index dd1a202b548..617269d3a2e 100644 --- a/sentry/src/main/java/io/sentry/NoOpScope.java +++ b/sentry/src/main/java/io/sentry/NoOpScope.java @@ -1,7 +1,10 @@ package io.sentry; +import io.sentry.featureflags.IFeatureFlagBuffer; +import io.sentry.featureflags.NoOpFeatureFlagBuffer; import io.sentry.internal.eventprocessor.EventProcessorAndOrder; import io.sentry.protocol.Contexts; +import io.sentry.protocol.FeatureFlags; import io.sentry.protocol.Request; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; @@ -296,4 +299,17 @@ public void setSpanContext( @Override public void replaceOptions(@NotNull SentryOptions options) {} + + @Override + public void addFeatureFlag(final @NotNull String flag, final boolean result) {} + + @Override + public @Nullable FeatureFlags getFeatureFlags() { + return null; + } + + @Override + public @NotNull IFeatureFlagBuffer getFeatureFlagBuffer() { + return NoOpFeatureFlagBuffer.getInstance(); + } } diff --git a/sentry/src/main/java/io/sentry/NoOpScopes.java b/sentry/src/main/java/io/sentry/NoOpScopes.java index 4e039a7b508..5d3447dd7c6 100644 --- a/sentry/src/main/java/io/sentry/NoOpScopes.java +++ b/sentry/src/main/java/io/sentry/NoOpScopes.java @@ -328,4 +328,7 @@ public boolean isNoOp() { public @NotNull ILoggerApi logger() { return NoOpLoggerApi.getInstance(); } + + @Override + public void addFeatureFlag(final @NotNull String flag, final boolean result) {} } diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 7a54c4c755a..6f3f28399ae 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -1,8 +1,11 @@ package io.sentry; +import io.sentry.featureflags.FeatureFlagBuffer; +import io.sentry.featureflags.IFeatureFlagBuffer; import io.sentry.internal.eventprocessor.EventProcessorAndOrder; import io.sentry.protocol.App; import io.sentry.protocol.Contexts; +import io.sentry.protocol.FeatureFlags; import io.sentry.protocol.Request; import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; @@ -103,6 +106,8 @@ public final class Scope implements IScope { private final @NotNull Map, String>> throwableToSpan = Collections.synchronizedMap(new WeakHashMap<>()); + private final @NotNull IFeatureFlagBuffer featureFlags; + /** * Scope's ctor * @@ -111,6 +116,7 @@ public final class Scope implements IScope { public Scope(final @NotNull SentryOptions options) { this.options = Objects.requireNonNull(options, "SentryOptions is required."); this.breadcrumbs = createBreadcrumbsList(this.options.getMaxBreadcrumbs()); + this.featureFlags = FeatureFlagBuffer.create(options); this.propagationContext = new PropagationContext(); this.lastEventId = SentryId.EMPTY_ID; } @@ -173,6 +179,8 @@ private Scope(final @NotNull Scope scope) { this.attachments = new CopyOnWriteArrayList<>(scope.attachments); + this.featureFlags = scope.featureFlags.clone(); + this.propagationContext = new PropagationContext(scope.propagationContext); } @@ -1119,6 +1127,21 @@ public void bindClient(@NotNull ISentryClient client) { return client; } + @Override + public void addFeatureFlag(final @NotNull String flag, final boolean result) { + featureFlags.add(flag, result); + } + + @Override + public @Nullable FeatureFlags getFeatureFlags() { + return featureFlags.getFeatureFlags(); + } + + @Override + public @NotNull IFeatureFlagBuffer getFeatureFlagBuffer() { + return featureFlags; + } + @Override @ApiStatus.Internal public void assignTraceContext(final @NotNull SentryEvent event) { diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 12309e355c5..7bf3bb0ca26 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -1220,6 +1220,11 @@ public void reportFullyDisplayed() { return logger; } + @Override + public void addFeatureFlag(final @NotNull String flag, final boolean result) { + combinedScope.addFeatureFlag(flag, result); + } + private static void validateOptions(final @NotNull SentryOptions options) { Objects.requireNonNull(options, "SentryOptions is required."); if (options.getDsn() == null || options.getDsn().isEmpty()) { diff --git a/sentry/src/main/java/io/sentry/ScopesAdapter.java b/sentry/src/main/java/io/sentry/ScopesAdapter.java index 86d316b967f..8e6c8d65813 100644 --- a/sentry/src/main/java/io/sentry/ScopesAdapter.java +++ b/sentry/src/main/java/io/sentry/ScopesAdapter.java @@ -385,4 +385,9 @@ public void reportFullyDisplayed() { public @NotNull ILoggerApi logger() { return Sentry.getCurrentScopes().logger(); } + + @Override + public void addFeatureFlag(final @NotNull String flag, final boolean result) { + Sentry.addFeatureFlag(flag, result); + } } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 4aee71715d7..5bf2063d73c 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1372,4 +1372,8 @@ public static void showUserFeedbackDialog( final @NotNull SentryOptions options = getCurrentScopes().getOptions(); options.getFeedbackOptions().getDialogHandler().showDialog(associatedEventId, configurator); } + + public static void addFeatureFlag(final @NotNull String flag, final boolean result) { + getCurrentScopes().addFeatureFlag(flag, result); + } } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index bfcf4e780be..19529c550fa 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -13,6 +13,7 @@ import io.sentry.logger.NoOpLoggerBatchProcessor; import io.sentry.protocol.Contexts; import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.FeatureFlags; import io.sentry.protocol.Feedback; import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; @@ -1250,6 +1251,13 @@ public void captureBatchedLogEvents(final @NotNull SentryLogEvents logEvents) { } } + if (event.getContexts().getFeatureFlags() == null) { + final @Nullable FeatureFlags featureFlags = scope.getFeatureFlags(); + if (featureFlags != null) { + event.getContexts().setFeatureFlags(featureFlags); + } + } + event = processEvent(event, hint, scope.getEventProcessors()); } return event; diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index f868ecacad2..6a488473b06 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -196,6 +196,11 @@ public class SentryOptions { */ private int maxBreadcrumbs = 100; + /** + * This variable controls the total amount of feature flags that should be captured Default is 100 + */ + private int maxFeatureFlags = 100; + /** Sets the release. SDK will try to automatically configure a release out of the box */ private @Nullable String release; @@ -1025,6 +1030,24 @@ public void setMaxBreadcrumbs(int maxBreadcrumbs) { this.maxBreadcrumbs = maxBreadcrumbs; } + /** + * Returns the max feature flags Default is 100 + * + * @return the max feature flags + */ + public int getMaxFeatureFlags() { + return maxFeatureFlags; + } + + /** + * Sets the max feature flags Default is 100 + * + * @param maxFeatureFlags the max feature flags + */ + public void setMaxFeatureFlags(int maxFeatureFlags) { + this.maxFeatureFlags = maxFeatureFlags; + } + /** * Returns the release * diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java new file mode 100644 index 00000000000..338e6c0e8a9 --- /dev/null +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -0,0 +1,207 @@ +package io.sentry.featureflags; + +import io.sentry.ISentryLifecycleToken; +import io.sentry.ScopeType; +import io.sentry.SentryOptions; +import io.sentry.protocol.FeatureFlag; +import io.sentry.protocol.FeatureFlags; +import io.sentry.util.AutoClosableReentrantLock; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class FeatureFlagBuffer implements IFeatureFlagBuffer { + + private volatile @NotNull CopyOnWriteArrayList flags; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + private int maxSize; + + private FeatureFlagBuffer(int maxSize) { + this.maxSize = maxSize; + this.flags = new CopyOnWriteArrayList<>(); + } + + private FeatureFlagBuffer( + int maxSize, final @NotNull CopyOnWriteArrayList flags) { + this.maxSize = maxSize; + this.flags = flags; + } + + private FeatureFlagBuffer(@NotNull FeatureFlagBuffer other) { + this.maxSize = other.maxSize; + this.flags = other.flags; + } + + @Override + public void add(@NotNull String flag, boolean result) { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + final int size = flags.size(); + final @NotNull ArrayList tmpList = new ArrayList<>(size + 1); + for (FeatureFlagEntry entry : flags) { + if (!entry.flag.equals(flag)) { + tmpList.add(entry); + } + } + tmpList.add(new FeatureFlagEntry(flag, result, System.nanoTime())); + + if (tmpList.size() > maxSize) { + tmpList.remove(0); + } + + flags = new CopyOnWriteArrayList<>(tmpList); + } + } + + @Override + public @NotNull FeatureFlags getFeatureFlags() { + List featureFlags = new ArrayList<>(); + for (FeatureFlagEntry entry : flags) { + featureFlags.add(entry.toFeatureFlag()); + } + return new FeatureFlags(featureFlags); + } + + @Override + public IFeatureFlagBuffer clone() { + return new FeatureFlagBuffer(this); + } + + public static @NotNull IFeatureFlagBuffer create(final @NotNull SentryOptions options) { + final int maxFeatureFlags = options.getMaxFeatureFlags(); + if (maxFeatureFlags > 0) { + return new FeatureFlagBuffer(maxFeatureFlags); + } else { + return NoOpFeatureFlagBuffer.getInstance(); + } + } + + public static @NotNull IFeatureFlagBuffer merged( + final @NotNull SentryOptions options, + final @Nullable IFeatureFlagBuffer globalBuffer, + final @Nullable IFeatureFlagBuffer isolationBuffer, + final @Nullable IFeatureFlagBuffer currentBuffer) { + final int maxSize = options.getMaxFeatureFlags(); + if (maxSize <= 0) { + return NoOpFeatureFlagBuffer.getInstance(); + } + + return merged( + maxSize, + globalBuffer instanceof FeatureFlagBuffer ? (FeatureFlagBuffer) globalBuffer : null, + isolationBuffer instanceof FeatureFlagBuffer ? (FeatureFlagBuffer) isolationBuffer : null, + currentBuffer instanceof FeatureFlagBuffer ? (FeatureFlagBuffer) currentBuffer : null); + } + + private static @NotNull IFeatureFlagBuffer merged( + final int maxSize, + final @Nullable FeatureFlagBuffer globalBuffer, + final @Nullable FeatureFlagBuffer isolationBuffer, + final @Nullable FeatureFlagBuffer currentBuffer) { + + // Capture references to avoid inconsistencies from concurrent modifications + final @Nullable CopyOnWriteArrayList globalFlags = + globalBuffer == null ? null : globalBuffer.flags; + final @Nullable CopyOnWriteArrayList isolationFlags = + isolationBuffer == null ? null : isolationBuffer.flags; + final @Nullable CopyOnWriteArrayList currentFlags = + currentBuffer == null ? null : currentBuffer.flags; + + final int globalSize = globalFlags == null ? 0 : globalFlags.size(); + final int isolationSize = isolationFlags == null ? 0 : isolationFlags.size(); + final int currentSize = currentFlags == null ? 0 : currentFlags.size(); + + // Early exit if all buffers are empty + if (globalSize == 0 && isolationSize == 0 && currentSize == 0) { + return NoOpFeatureFlagBuffer.getInstance(); + } + + int globalIndex = globalSize - 1; + int isolationIndex = isolationSize - 1; + int currentIndex = currentSize - 1; + + final @NotNull java.util.Map uniqueFlags = + new java.util.LinkedHashMap<>(maxSize); + + // check if there is still room and remaining items to check + while (uniqueFlags.size() < maxSize + && (globalIndex >= 0 || isolationIndex >= 0 || currentIndex >= 0)) { + final FeatureFlagEntry globalEntry = + (globalFlags != null && globalIndex >= 0) ? globalFlags.get(globalIndex) : null; + final FeatureFlagEntry isolationEntry = + (isolationFlags != null && isolationIndex >= 0) + ? isolationFlags.get(isolationIndex) + : null; + final FeatureFlagEntry currentEntry = + (currentFlags != null && currentIndex >= 0) ? currentFlags.get(currentIndex) : null; + + @Nullable FeatureFlagEntry entryToAdd = null; + @Nullable ScopeType selectedBuffer = null; + + // choose newest entry across all buffers + if (globalEntry != null && (entryToAdd == null || globalEntry.nanos > entryToAdd.nanos)) { + entryToAdd = globalEntry; + selectedBuffer = ScopeType.GLOBAL; + } + if (isolationEntry != null + && (entryToAdd == null || isolationEntry.nanos > entryToAdd.nanos)) { + entryToAdd = isolationEntry; + selectedBuffer = ScopeType.ISOLATION; + } + if (currentEntry != null && (entryToAdd == null || currentEntry.nanos > entryToAdd.nanos)) { + entryToAdd = currentEntry; + selectedBuffer = ScopeType.CURRENT; + } + + if (entryToAdd != null) { + // no need to update existing entries since we already have the latest + if (!uniqueFlags.containsKey(entryToAdd.flag)) { + uniqueFlags.put(entryToAdd.flag, entryToAdd); + } + + // decrement only index of buffer that was selected + if (ScopeType.CURRENT.equals(selectedBuffer)) { + currentIndex--; + } else if (ScopeType.ISOLATION.equals(selectedBuffer)) { + isolationIndex--; + } else if (ScopeType.GLOBAL.equals(selectedBuffer)) { + globalIndex--; + } + } else { + // no need to look any further since lists are sorted and we could not find any newer + // entries anymore + break; + } + } + + // Convert to list in reverse order (oldest first, newest last) + final @NotNull List resultList = new ArrayList<>(uniqueFlags.values()); + Collections.reverse(resultList); + return new FeatureFlagBuffer(maxSize, new CopyOnWriteArrayList<>(resultList)); + } + + private static class FeatureFlagEntry { + + private final @NotNull String flag; + private final boolean result; + + @SuppressWarnings("UnusedVariable") + @NotNull + private final Long nanos; + + public FeatureFlagEntry( + final @NotNull String flag, final boolean result, final @NotNull Long nanos) { + this.flag = flag; + this.result = result; + this.nanos = nanos; + } + + public @NotNull FeatureFlag toFeatureFlag() { + return new FeatureFlag(flag, result); + } + } +} diff --git a/sentry/src/main/java/io/sentry/featureflags/IFeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/IFeatureFlagBuffer.java new file mode 100644 index 00000000000..39878759e52 --- /dev/null +++ b/sentry/src/main/java/io/sentry/featureflags/IFeatureFlagBuffer.java @@ -0,0 +1,17 @@ +package io.sentry.featureflags; + +import io.sentry.protocol.FeatureFlags; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public interface IFeatureFlagBuffer { + void add(@NotNull String flag, boolean result); + + @Nullable + FeatureFlags getFeatureFlags(); + + @NotNull + IFeatureFlagBuffer clone(); +} diff --git a/sentry/src/main/java/io/sentry/featureflags/NoOpFeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/NoOpFeatureFlagBuffer.java new file mode 100644 index 00000000000..82f5709c0bb --- /dev/null +++ b/sentry/src/main/java/io/sentry/featureflags/NoOpFeatureFlagBuffer.java @@ -0,0 +1,28 @@ +package io.sentry.featureflags; + +import io.sentry.protocol.FeatureFlags; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class NoOpFeatureFlagBuffer implements IFeatureFlagBuffer { + private static final NoOpFeatureFlagBuffer instance = new NoOpFeatureFlagBuffer(); + + public static NoOpFeatureFlagBuffer getInstance() { + return instance; + } + + @Override + public void add(@NotNull String flag, boolean result) {} + + @Override + public @Nullable FeatureFlags getFeatureFlags() { + return null; + } + + @Override + public @NotNull IFeatureFlagBuffer clone() { + return instance; + } +} diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index e97431db4da..553f4ddbd30 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -181,6 +181,14 @@ public void setSpring(final @NotNull Spring spring) { this.put(Spring.TYPE, spring); } + public @Nullable FeatureFlags getFeatureFlags() { + return toContextType(FeatureFlags.TYPE, FeatureFlags.class); + } + + public void setFeatureFlags(final @NotNull FeatureFlags featureFlags) { + this.put(FeatureFlags.TYPE, featureFlags); + } + public int size() { // since this used to extend map return internalStorage.size(); @@ -339,6 +347,9 @@ public static final class Deserializer implements JsonDeserializer { case Spring.TYPE: contexts.setSpring(new Spring.Deserializer().deserialize(reader, logger)); break; + case FeatureFlags.TYPE: + contexts.setFeatureFlags(new FeatureFlags.Deserializer().deserialize(reader, logger)); + break; default: Object object = reader.nextObjectOrNull(); if (object != null) { diff --git a/sentry/src/main/java/io/sentry/protocol/FeatureFlag.java b/sentry/src/main/java/io/sentry/protocol/FeatureFlag.java new file mode 100644 index 00000000000..ee849b2fa84 --- /dev/null +++ b/sentry/src/main/java/io/sentry/protocol/FeatureFlag.java @@ -0,0 +1,142 @@ +package io.sentry.protocol; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.SentryLevel; +import io.sentry.util.Objects; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class FeatureFlag implements JsonUnknown, JsonSerializable { + + /** Name of the feature flag. */ + private @NotNull String flag; + + /** Evaluation result of the feature flag. */ + private boolean result; + + @SuppressWarnings("unused") + private @Nullable Map unknown; + + public FeatureFlag(@NotNull String flag, boolean result) { + this.flag = flag; + this.result = result; + } + + public @NotNull String getFlag() { + return flag; + } + + public void setFlag(final @NotNull String flag) { + this.flag = flag; + } + + @NotNull + public Boolean getResult() { + return result; + } + + public void setResult(final @NotNull Boolean result) { + this.result = result; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureFlag flag = (FeatureFlag) o; + return Objects.equals(flag, flag.flag) && Objects.equals(result, flag.result); + } + + @Override + public int hashCode() { + return Objects.hash(flag, result); + } + + // region json + + @Nullable + @Override + public Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class JsonKeys { + public static final String FLAG = "flag"; + public static final String RESULT = "result"; + } + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + + writer.name(JsonKeys.FLAG).value(flag); + writer.name(JsonKeys.RESULT).value(result); + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + @SuppressWarnings("unchecked") + @Override + public @NotNull FeatureFlag deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { + reader.beginObject(); + @Nullable String flag = null; + @Nullable Boolean result = null; + Map unknown = null; + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.FLAG: + flag = reader.nextStringOrNull(); + break; + case JsonKeys.RESULT: + result = reader.nextBooleanOrNull(); + break; + default: + if (unknown == null) { + unknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + if (flag == null) { + String message = "Missing required field \"" + JsonKeys.FLAG + "\""; + Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + if (result == null) { + String message = "Missing required field \"" + JsonKeys.RESULT + "\""; + Exception exception = new IllegalStateException(message); + logger.log(SentryLevel.ERROR, message, exception); + throw exception; + } + FeatureFlag app = new FeatureFlag(flag, result); + app.setUnknown(unknown); + reader.endObject(); + return app; + } + } +} diff --git a/sentry/src/main/java/io/sentry/protocol/FeatureFlags.java b/sentry/src/main/java/io/sentry/protocol/FeatureFlags.java new file mode 100644 index 00000000000..9e84a21de99 --- /dev/null +++ b/sentry/src/main/java/io/sentry/protocol/FeatureFlags.java @@ -0,0 +1,127 @@ +package io.sentry.protocol; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.util.CollectionUtils; +import io.sentry.util.Objects; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class FeatureFlags implements JsonUnknown, JsonSerializable { + public static final String TYPE = "flags"; + + private @NotNull List values; + + public FeatureFlags() { + this.values = new ArrayList<>(); + } + + FeatureFlags(final @NotNull FeatureFlags featureFlags) { + this.values = featureFlags.values; + this.unknown = CollectionUtils.newConcurrentHashMap(featureFlags.unknown); + } + + public FeatureFlags(final @NotNull List values) { + this.values = values; + } + + @SuppressWarnings("unused") + private @Nullable Map unknown; + + @NotNull + public List getValues() { + return values; + } + + public void setValues(final @NotNull List values) { + this.values = values; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + FeatureFlags flags = (FeatureFlags) o; + return Objects.equals(values, flags.values); + } + + @Override + public int hashCode() { + return Objects.hash(values); + } + + // region json + + @Nullable + @Override + public Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class JsonKeys { + public static final String VALUES = "values"; + } + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + + writer.name(JsonKeys.VALUES).value(logger, values); + + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + @SuppressWarnings("unchecked") + @Override + public @NotNull FeatureFlags deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { + reader.beginObject(); + @Nullable List values = null; + Map unknown = null; + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.VALUES: + values = reader.nextListOrNull(logger, new FeatureFlag.Deserializer()); + break; + default: + if (unknown == null) { + unknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + if (values == null) { + values = new ArrayList<>(); + } + FeatureFlags flags = new FeatureFlags(values); + flags.setUnknown(unknown); + reader.endObject(); + return flags; + } + } +} diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 8671017eae5..e80793f54cb 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -1107,6 +1107,22 @@ class ScopeTest { assertTrue(scope.contexts.isEmpty) } + @Test + fun `feature flags can be added and are deduplicated`() { + val scope = Scope(SentryOptions.empty()) + + scope.addFeatureFlag("flag1", true) + scope.addFeatureFlag("flag1", false) + + val flags = scope.featureFlags + assertNotNull(flags) + assertEquals(1, flags.values.size) + + val flag0 = flags.values.first() + assertEquals("flag1", flag0.flag) + assertFalse(flag0.result) + } + private fun eventProcessor(): EventProcessor = object : EventProcessor { override fun process(event: SentryEvent, hint: Hint): SentryEvent? = event diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index 4f0ee526dc5..6b1ac51c7e1 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -3083,6 +3083,44 @@ class ScopesTest { assertTrue(scopes.globalScope.extras.isEmpty()) } + @Test + fun `feature flags can be added to scopes`() { + val (sut, mockClient) = getEnabledScopes() + + sut.addFeatureFlag("test-feature-flag", true) + sut.scope.addFeatureFlag("current-feature-flag", true) + sut.isolationScope.addFeatureFlag("isolation-feature-flag", false) + sut.globalScope.addFeatureFlag("global-feature-flag", true) + + sut.captureException(RuntimeException("test exception")) + + verify(mockClient) + .captureEvent( + any(), + check { + val featureFlags = it.featureFlags + assertNotNull(featureFlags) + + val flag0 = featureFlags.values[0] + assertEquals("test-feature-flag", flag0.flag) + assertTrue(flag0.result) + + val flag1 = featureFlags.values[1] + assertEquals("current-feature-flag", flag1.flag) + assertTrue(flag1.result) + + val flag2 = featureFlags.values[2] + assertEquals("isolation-feature-flag", flag2.flag) + assertFalse(flag2.result) + + val flag3 = featureFlags.values[3] + assertEquals("global-feature-flag", flag3.flag) + assertTrue(flag3.result) + }, + anyOrNull(), + ) + } + private val dsnTest = "https://key@sentry.io/proj" private fun generateScopes( diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 6d0bcd5790b..bf54db9228d 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -895,4 +895,17 @@ class SentryOptionsTest { options.isPropagateTraceparent = true assertTrue(options.isPropagateTraceparent) } + + @Test + fun `maxFeatureFlags defaults to 100`() { + val options = SentryOptions() + assertEquals(100, options.maxFeatureFlags) + } + + @Test + fun `maxFeatureFlags can be changed`() { + val options = SentryOptions() + options.maxFeatureFlags = 50 + assertEquals(50, options.maxFeatureFlags) + } } diff --git a/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt b/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt new file mode 100644 index 00000000000..ca996ef91a8 --- /dev/null +++ b/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt @@ -0,0 +1,182 @@ +package io.sentry.featureflags + +import io.sentry.SentryOptions +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class FeatureFlagBufferTest { + @Test + fun `creates noop if limit is 0`() { + val buffer = FeatureFlagBuffer.create(SentryOptions().also { it.maxFeatureFlags = 0 }) + assertTrue(buffer is NoOpFeatureFlagBuffer) + } + + @Test + fun `stores value`() { + val buffer = FeatureFlagBuffer.create(SentryOptions().also { it.maxFeatureFlags = 2 }) + buffer.add("a", true) + buffer.add("b", false) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("a", featureFlagValues[0]!!.flag) + assertTrue(featureFlagValues[0]!!.result) + + assertEquals("b", featureFlagValues[1]!!.flag) + assertFalse(featureFlagValues[1]!!.result) + } + + @Test + fun `drops oldest entry when limit is reached`() { + val buffer = FeatureFlagBuffer.create(SentryOptions().also { it.maxFeatureFlags = 2 }) + buffer.add("a", true) + buffer.add("b", true) + buffer.add("c", true) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("b", featureFlagValues[0]!!.flag) + assertEquals("c", featureFlagValues[1]!!.flag) + } + + @Test + fun `drops oldest entries when merging multiple buffers`() { + val options = SentryOptions().also { it.maxFeatureFlags = 2 } + val globalBuffer = FeatureFlagBuffer.create(options) + val isolationBuffer = FeatureFlagBuffer.create(options) + val currentBuffer = FeatureFlagBuffer.create(options) + globalBuffer.add("globalA", true) + isolationBuffer.add("isolationA", true) + currentBuffer.add("currentA", true) + globalBuffer.add("globalB", true) + isolationBuffer.add("isolationB", true) + currentBuffer.add("currentB", true) + globalBuffer.add("globalC", true) + isolationBuffer.add("isolationC", true) + currentBuffer.add("currentC", true) + + val buffer = FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("isolationC", featureFlagValues[0]!!.flag) + assertEquals("currentC", featureFlagValues[1]!!.flag) + } + + @Test + fun `drops oldest entries when merging multiple buffers even if assymetrically sized`() { + val options = SentryOptions().also { it.maxFeatureFlags = 2 } + val globalBuffer = FeatureFlagBuffer.create(options) + val isolationBuffer = FeatureFlagBuffer.create(options) + val currentBuffer = FeatureFlagBuffer.create(options) + globalBuffer.add("globalA", true) + isolationBuffer.add("isolationA", true) + currentBuffer.add("currentA", true) + globalBuffer.add("globalB", true) + isolationBuffer.add("isolationB", true) + currentBuffer.add("currentB", true) + globalBuffer.add("globalC", true) + + val buffer = FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("currentB", featureFlagValues[0]!!.flag) + assertEquals("globalC", featureFlagValues[1]!!.flag) + } + + @Test + fun `drops oldest entries when merging multiple buffers all from same source`() { + val options = SentryOptions().also { it.maxFeatureFlags = 2 } + val globalBuffer = FeatureFlagBuffer.create(options) + val isolationBuffer = FeatureFlagBuffer.create(options) + val currentBuffer = FeatureFlagBuffer.create(options) + globalBuffer.add("globalA", true) + globalBuffer.add("globalB", true) + globalBuffer.add("globalC", true) + + isolationBuffer.add("isolationA", true) + isolationBuffer.add("isolationB", true) + isolationBuffer.add("isolationC", true) + + currentBuffer.add("currentA", true) + currentBuffer.add("currentB", true) + currentBuffer.add("currentC", true) + + val buffer = FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("currentB", featureFlagValues[0]!!.flag) + assertEquals("currentC", featureFlagValues[1]!!.flag) + } + + @Test + fun `updates same flags value`() { + val options = SentryOptions().also { it.maxFeatureFlags = 3 } + val globalBuffer = FeatureFlagBuffer.create(options) + val isolationBuffer = FeatureFlagBuffer.create(options) + val currentBuffer = FeatureFlagBuffer.create(options) + globalBuffer.add("a", true) + globalBuffer.add("b", false) + + isolationBuffer.add("a", true) + isolationBuffer.add("b", false) + + currentBuffer.add("a", false) + currentBuffer.add("b", true) + + val buffer = FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("a", featureFlagValues[0]!!.flag) + assertFalse(featureFlagValues[0]!!.result) + assertEquals("b", featureFlagValues[1]!!.flag) + assertTrue(featureFlagValues[1]!!.result) + } + + @Test + fun `merges empty buffers`() { + val options = SentryOptions().also { it.maxFeatureFlags = 2 } + val globalBuffer = FeatureFlagBuffer.create(options) + val isolationBuffer = FeatureFlagBuffer.create(options) + val currentBuffer = FeatureFlagBuffer.create(options) + val buffer = FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer) + + assertTrue(buffer is NoOpFeatureFlagBuffer) + } + + @Test + fun `merges noop buffers`() { + val options = SentryOptions().also { it.maxFeatureFlags = 0 } + val globalBuffer = NoOpFeatureFlagBuffer.getInstance() + val isolationBuffer = NoOpFeatureFlagBuffer.getInstance() + val currentBuffer = NoOpFeatureFlagBuffer.getInstance() + val buffer = FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer) + + assertTrue(buffer is NoOpFeatureFlagBuffer) + } +} From 74cc427dfc099d77a122cd6c80935e28d45d0a20 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 16 Oct 2025 13:10:00 +0200 Subject: [PATCH 02/30] fix equals --- sentry/src/main/java/io/sentry/protocol/FeatureFlag.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/protocol/FeatureFlag.java b/sentry/src/main/java/io/sentry/protocol/FeatureFlag.java index ee849b2fa84..ebb2e58cd3d 100644 --- a/sentry/src/main/java/io/sentry/protocol/FeatureFlag.java +++ b/sentry/src/main/java/io/sentry/protocol/FeatureFlag.java @@ -52,8 +52,8 @@ public void setResult(final @NotNull Boolean result) { public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - FeatureFlag flag = (FeatureFlag) o; - return Objects.equals(flag, flag.flag) && Objects.equals(result, flag.result); + final @NotNull FeatureFlag otherFlag = (FeatureFlag) o; + return Objects.equals(flag, otherFlag.flag) && Objects.equals(result, otherFlag.result); } @Override From fb4214087b912cf6d5cd45723723fd7be101daaa Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 17 Oct 2025 13:51:19 +0200 Subject: [PATCH 03/30] add serialization tests --- .../CombinedContextsViewSerializationTest.kt | 1 + .../protocol/ContextsSerializationTest.kt | 1 + .../protocol/FeatureFlagsSerializationTest.kt | 38 +++++++++++++++++++ .../protocol/SentryEventSerializationTest.kt | 5 ++- sentry/src/test/resources/json/contexts.json | 12 ++++++ .../test/resources/json/feature_flags.json | 12 ++++++ .../src/test/resources/json/sentry_event.json | 12 ++++++ 7 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 sentry/src/test/java/io/sentry/protocol/FeatureFlagsSerializationTest.kt create mode 100644 sentry/src/test/resources/json/feature_flags.json diff --git a/sentry/src/test/java/io/sentry/protocol/CombinedContextsViewSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/CombinedContextsViewSerializationTest.kt index fd7fae9108c..d7fd3cf9f7f 100644 --- a/sentry/src/test/java/io/sentry/protocol/CombinedContextsViewSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/CombinedContextsViewSerializationTest.kt @@ -30,6 +30,7 @@ class CombinedContextsViewSerializationTest { isolation.setOperatingSystem(OperatingSystemSerializationTest.Fixture().getSut()) isolation.setResponse(ResponseSerializationTest.Fixture().getSut()) isolation.setSpring(SpringSerializationTest.Fixture().getSut()) + isolation.setFeatureFlags(FeatureFlagsSerializationTest.Fixture().getSut()) global.setRuntime(SentryRuntimeSerializationTest.Fixture().getSut()) global.setGpu(GpuSerializationTest.Fixture().getSut()) diff --git a/sentry/src/test/java/io/sentry/protocol/ContextsSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ContextsSerializationTest.kt index c02bb4240b2..1a5e252a76d 100644 --- a/sentry/src/test/java/io/sentry/protocol/ContextsSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/ContextsSerializationTest.kt @@ -25,6 +25,7 @@ class ContextsSerializationTest { setResponse(ResponseSerializationTest.Fixture().getSut()) setTrace(SpanContextSerializationTest.Fixture().getSut()) setSpring(SpringSerializationTest.Fixture().getSut()) + setFeatureFlags(FeatureFlagsSerializationTest.Fixture().getSut()) } } diff --git a/sentry/src/test/java/io/sentry/protocol/FeatureFlagsSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/FeatureFlagsSerializationTest.kt new file mode 100644 index 00000000000..8c401ddcc63 --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/FeatureFlagsSerializationTest.kt @@ -0,0 +1,38 @@ +package io.sentry.protocol + +import io.sentry.ILogger +import kotlin.test.assertEquals +import org.junit.Test +import org.mockito.kotlin.mock + +class FeatureFlagsSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = FeatureFlags(listOf(FeatureFlag("flag-1", true), FeatureFlag("flag-2", false))) + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/feature_flags.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/feature_flags.json") + val actual = + SerializationUtils.deserializeJson( + expectedJson, + FeatureFlags.Deserializer(), + fixture.logger, + ) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/protocol/SentryEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryEventSerializationTest.kt index 389cd121c26..f2cfd1ce077 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryEventSerializationTest.kt @@ -32,7 +32,10 @@ class SentryEventSerializationTest { level = SentryLevel.ERROR transaction = "e7aea178-e3a6-46bc-be17-38a3ea8920b6" setModule("01c8a4f6-8861-4575-a10e-5ed3fba7c794", "b4083431-47e9-433a-b58f-58796f63e27c") - contexts.apply { setSpring(SpringSerializationTest.Fixture().getSut()) } + contexts.apply { + setSpring(SpringSerializationTest.Fixture().getSut()) + setFeatureFlags(FeatureFlagsSerializationTest.Fixture().getSut()) + } SentryBaseEventSerializationTest.Fixture().update(this) } } diff --git a/sentry/src/test/resources/json/contexts.json b/sentry/src/test/resources/json/contexts.json index 894d73e2dc6..7f4c0c16bc2 100644 --- a/sentry/src/test/resources/json/contexts.json +++ b/sentry/src/test/resources/json/contexts.json @@ -76,6 +76,18 @@ "url": "url", "unknown": "unknown" }, + "flags": { + "values": [ + { + "flag": "flag-1", + "result": true + }, + { + "flag": "flag-2", + "result": false + } + ] + }, "gpu": { "name": "d623a6b5-e1ab-4402-931b-c06f5a43a5ae", diff --git a/sentry/src/test/resources/json/feature_flags.json b/sentry/src/test/resources/json/feature_flags.json new file mode 100644 index 00000000000..ff4569fb57d --- /dev/null +++ b/sentry/src/test/resources/json/feature_flags.json @@ -0,0 +1,12 @@ +{ + "values": [ + { + "flag": "flag-1", + "result": true + }, + { + "flag": "flag-2", + "result": false + } + ] +} diff --git a/sentry/src/test/resources/json/sentry_event.json b/sentry/src/test/resources/json/sentry_event.json index c96d3bed453..817ae1ff7be 100644 --- a/sentry/src/test/resources/json/sentry_event.json +++ b/sentry/src/test/resources/json/sentry_event.json @@ -204,6 +204,18 @@ "cpu_description": "cpu0", "chipset": "unisoc" }, + "flags": { + "values": [ + { + "flag": "flag-1", + "result": true + }, + { + "flag": "flag-2", + "result": false + } + ] + }, "gpu": { "name": "d623a6b5-e1ab-4402-931b-c06f5a43a5ae", From 045337ab168570a602e6d69a2d85b17a9dca4d97 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 17 Oct 2025 13:51:37 +0200 Subject: [PATCH 04/30] Create new reference on buffer clone --- .../src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index 338e6c0e8a9..205143f2932 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -34,7 +34,7 @@ private FeatureFlagBuffer( private FeatureFlagBuffer(@NotNull FeatureFlagBuffer other) { this.maxSize = other.maxSize; - this.flags = other.flags; + this.flags = new CopyOnWriteArrayList<>(other.flags); } @Override From c1061ca5b1d04fb09ee8d60123cf918b3e3ce6e4 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 17 Oct 2025 13:52:12 +0200 Subject: [PATCH 05/30] add comment explaining the merge method --- .../io/sentry/featureflags/FeatureFlagBuffer.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index 205143f2932..a63db864c89 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -97,6 +97,19 @@ public IFeatureFlagBuffer clone() { currentBuffer instanceof FeatureFlagBuffer ? (FeatureFlagBuffer) currentBuffer : null); } + /** + * Iterates all incoming buffers from the end, always taking the latest item across all buffers, + * until maxSize has been reached or no more items are available. + * + *

If a duplicate is found we skip it since we're iterating in reverse order and we already + * have the latest entry. + * + * @param maxSize max number of feature flags + * @param globalBuffer buffer from global scope + * @param isolationBuffer buffer from isolation scope + * @param currentBuffer buffer from current scope + * @return merged buffer containing at most maxSize latest items from incoming buffers + */ private static @NotNull IFeatureFlagBuffer merged( final int maxSize, final @Nullable FeatureFlagBuffer globalBuffer, From 74acb1049138c97eaefba88aa6253f14eb9e4e61 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 17 Oct 2025 13:52:26 +0200 Subject: [PATCH 06/30] optimize merge method --- .../featureflags/FeatureFlagBuffer.java | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index a63db864c89..53789edd801 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -137,20 +137,20 @@ public IFeatureFlagBuffer clone() { int isolationIndex = isolationSize - 1; int currentIndex = currentSize - 1; + @Nullable + FeatureFlagEntry globalEntry = globalFlags == null ? null : globalFlags.get(globalIndex); + @Nullable + FeatureFlagEntry isolationEntry = + isolationFlags == null ? null : isolationFlags.get(isolationIndex); + @Nullable + FeatureFlagEntry currentEntry = currentFlags == null ? null : currentFlags.get(currentIndex); + final @NotNull java.util.Map uniqueFlags = new java.util.LinkedHashMap<>(maxSize); // check if there is still room and remaining items to check while (uniqueFlags.size() < maxSize - && (globalIndex >= 0 || isolationIndex >= 0 || currentIndex >= 0)) { - final FeatureFlagEntry globalEntry = - (globalFlags != null && globalIndex >= 0) ? globalFlags.get(globalIndex) : null; - final FeatureFlagEntry isolationEntry = - (isolationFlags != null && isolationIndex >= 0) - ? isolationFlags.get(isolationIndex) - : null; - final FeatureFlagEntry currentEntry = - (currentFlags != null && currentIndex >= 0) ? currentFlags.get(currentIndex) : null; + && (globalEntry != null || isolationEntry != null || currentEntry != null)) { @Nullable FeatureFlagEntry entryToAdd = null; @Nullable ScopeType selectedBuffer = null; @@ -179,10 +179,18 @@ public IFeatureFlagBuffer clone() { // decrement only index of buffer that was selected if (ScopeType.CURRENT.equals(selectedBuffer)) { currentIndex--; + currentEntry = + currentFlags != null && currentIndex >= 0 ? currentFlags.get(currentIndex) : null; } else if (ScopeType.ISOLATION.equals(selectedBuffer)) { isolationIndex--; + isolationEntry = + isolationFlags != null && isolationIndex >= 0 + ? isolationFlags.get(isolationIndex) + : null; } else if (ScopeType.GLOBAL.equals(selectedBuffer)) { globalIndex--; + globalEntry = + globalFlags != null && globalIndex >= 0 ? globalFlags.get(globalIndex) : null; } } else { // no need to look any further since lists are sorted and we could not find any newer From eeae5c654086acff0d5ff7d60bb1c0b7a8d59fad Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 17 Oct 2025 14:18:41 +0200 Subject: [PATCH 07/30] make flag and result nullable params --- sentry/api/sentry.api | 30 ++++++++-------- .../java/io/sentry/CombinedScopeView.java | 2 +- .../src/main/java/io/sentry/HubAdapter.java | 2 +- .../main/java/io/sentry/HubScopesWrapper.java | 2 +- sentry/src/main/java/io/sentry/IScope.java | 2 +- sentry/src/main/java/io/sentry/IScopes.java | 2 +- sentry/src/main/java/io/sentry/NoOpHub.java | 2 +- sentry/src/main/java/io/sentry/NoOpScope.java | 2 +- .../src/main/java/io/sentry/NoOpScopes.java | 2 +- sentry/src/main/java/io/sentry/Scope.java | 2 +- sentry/src/main/java/io/sentry/Scopes.java | 2 +- .../main/java/io/sentry/ScopesAdapter.java | 2 +- sentry/src/main/java/io/sentry/Sentry.java | 2 +- .../featureflags/FeatureFlagBuffer.java | 5 ++- .../featureflags/IFeatureFlagBuffer.java | 2 +- .../featureflags/NoOpFeatureFlagBuffer.java | 2 +- sentry/src/test/java/io/sentry/ScopeTest.kt | 14 ++++++++ sentry/src/test/java/io/sentry/ScopesTest.kt | 35 +++++++++++++++++++ 18 files changed, 82 insertions(+), 30 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 88de3232de2..07cf57b4ff2 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -264,7 +264,7 @@ public final class io/sentry/CombinedScopeView : io/sentry/IScope { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public fun addEventProcessor (Lio/sentry/EventProcessor;)V - public fun addFeatureFlag (Ljava/lang/String;Z)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun assignTraceContext (Lio/sentry/SentryEvent;)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun clear ()V @@ -612,7 +612,7 @@ public final class io/sentry/HttpStatusCodeRange { public final class io/sentry/HubAdapter : io/sentry/IHub { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V - public fun addFeatureFlag (Ljava/lang/String;Z)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -685,7 +685,7 @@ public final class io/sentry/HubScopesWrapper : io/sentry/IHub { public fun (Lio/sentry/IScopes;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V - public fun addFeatureFlag (Ljava/lang/String;Z)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -848,7 +848,7 @@ public abstract interface class io/sentry/IScope { public abstract fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public abstract fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public abstract fun addEventProcessor (Lio/sentry/EventProcessor;)V - public abstract fun addFeatureFlag (Ljava/lang/String;Z)V + public abstract fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public abstract fun assignTraceContext (Lio/sentry/SentryEvent;)V public abstract fun bindClient (Lio/sentry/ISentryClient;)V public abstract fun clear ()V @@ -936,7 +936,7 @@ public abstract interface class io/sentry/IScopes { public abstract fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public fun addBreadcrumb (Ljava/lang/String;)V public fun addBreadcrumb (Ljava/lang/String;Ljava/lang/String;)V - public abstract fun addFeatureFlag (Ljava/lang/String;Z)V + public abstract fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public abstract fun bindClient (Lio/sentry/ISentryClient;)V public abstract fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;)Lio/sentry/protocol/SentryId; @@ -1518,7 +1518,7 @@ public final class io/sentry/NoOpEnvelopeReader : io/sentry/IEnvelopeReader { public final class io/sentry/NoOpHub : io/sentry/IHub { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V - public fun addFeatureFlag (Ljava/lang/String;Z)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -1620,7 +1620,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public fun addEventProcessor (Lio/sentry/EventProcessor;)V - public fun addFeatureFlag (Ljava/lang/String;Z)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun assignTraceContext (Lio/sentry/SentryEvent;)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun clear ()V @@ -1689,7 +1689,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public final class io/sentry/NoOpScopes : io/sentry/IScopes { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V - public fun addFeatureFlag (Ljava/lang/String;Z)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -2283,7 +2283,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public fun addEventProcessor (Lio/sentry/EventProcessor;)V - public fun addFeatureFlag (Ljava/lang/String;Z)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun assignTraceContext (Lio/sentry/SentryEvent;)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun clear ()V @@ -2401,7 +2401,7 @@ public final class io/sentry/Scopes : io/sentry/IScopes { public fun (Lio/sentry/IScope;Lio/sentry/IScope;Lio/sentry/IScope;Ljava/lang/String;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V - public fun addFeatureFlag (Ljava/lang/String;Z)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -2473,7 +2473,7 @@ public final class io/sentry/Scopes : io/sentry/IScopes { public final class io/sentry/ScopesAdapter : io/sentry/IScopes { public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V - public fun addFeatureFlag (Ljava/lang/String;Z)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun bindClient (Lio/sentry/ISentryClient;)V public fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -2584,7 +2584,7 @@ public final class io/sentry/Sentry { public static fun addBreadcrumb (Lio/sentry/Breadcrumb;Lio/sentry/Hint;)V public static fun addBreadcrumb (Ljava/lang/String;)V public static fun addBreadcrumb (Ljava/lang/String;Ljava/lang/String;)V - public static fun addFeatureFlag (Ljava/lang/String;Z)V + public static fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public static fun bindClient (Lio/sentry/ISentryClient;)V public static fun captureCheckIn (Lio/sentry/CheckIn;)Lio/sentry/protocol/SentryId; public static fun captureEvent (Lio/sentry/SentryEvent;)Lio/sentry/protocol/SentryId; @@ -4718,7 +4718,7 @@ public final class io/sentry/exception/SentryHttpClientException : java/lang/Exc } public final class io/sentry/featureflags/FeatureFlagBuffer : io/sentry/featureflags/IFeatureFlagBuffer { - public fun add (Ljava/lang/String;Z)V + public fun add (Ljava/lang/String;Ljava/lang/Boolean;)V public fun clone ()Lio/sentry/featureflags/IFeatureFlagBuffer; public synthetic fun clone ()Ljava/lang/Object; public static fun create (Lio/sentry/SentryOptions;)Lio/sentry/featureflags/IFeatureFlagBuffer; @@ -4727,14 +4727,14 @@ public final class io/sentry/featureflags/FeatureFlagBuffer : io/sentry/featuref } public abstract interface class io/sentry/featureflags/IFeatureFlagBuffer { - public abstract fun add (Ljava/lang/String;Z)V + public abstract fun add (Ljava/lang/String;Ljava/lang/Boolean;)V public abstract fun clone ()Lio/sentry/featureflags/IFeatureFlagBuffer; public abstract fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; } public final class io/sentry/featureflags/NoOpFeatureFlagBuffer : io/sentry/featureflags/IFeatureFlagBuffer { public fun ()V - public fun add (Ljava/lang/String;Z)V + public fun add (Ljava/lang/String;Ljava/lang/Boolean;)V public fun clone ()Lio/sentry/featureflags/IFeatureFlagBuffer; public synthetic fun clone ()Ljava/lang/Object; public fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java index 4b1faddb56d..0d7c8460e9d 100644 --- a/sentry/src/main/java/io/sentry/CombinedScopeView.java +++ b/sentry/src/main/java/io/sentry/CombinedScopeView.java @@ -512,7 +512,7 @@ public void setReplayId(@NotNull SentryId replayId) { } @Override - public void addFeatureFlag(final @NotNull String flag, final boolean result) { + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { getDefaultWriteScope().addFeatureFlag(flag, result); } diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index f92b477cbe3..31a2e219cd0 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -390,7 +390,7 @@ public void reportFullyDisplayed() { } @Override - public void addFeatureFlag(final @NotNull String flag, final boolean result) { + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { Sentry.addFeatureFlag(flag, result); } } diff --git a/sentry/src/main/java/io/sentry/HubScopesWrapper.java b/sentry/src/main/java/io/sentry/HubScopesWrapper.java index 818d62f6b95..d15ed72ee4b 100644 --- a/sentry/src/main/java/io/sentry/HubScopesWrapper.java +++ b/sentry/src/main/java/io/sentry/HubScopesWrapper.java @@ -375,7 +375,7 @@ public void reportFullyDisplayed() { } @Override - public void addFeatureFlag(final @NotNull String flag, final boolean result) { + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { scopes.addFeatureFlag(flag, result); } } diff --git a/sentry/src/main/java/io/sentry/IScope.java b/sentry/src/main/java/io/sentry/IScope.java index 4617b4cf31e..f41ea1cbbe1 100644 --- a/sentry/src/main/java/io/sentry/IScope.java +++ b/sentry/src/main/java/io/sentry/IScope.java @@ -425,7 +425,7 @@ void setSpanContext( @ApiStatus.Internal void replaceOptions(final @NotNull SentryOptions options); - void addFeatureFlag(final @NotNull String flag, final boolean result); + void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result); @ApiStatus.Internal @Nullable diff --git a/sentry/src/main/java/io/sentry/IScopes.java b/sentry/src/main/java/io/sentry/IScopes.java index 465fbc5eb3e..bf78d28ecd3 100644 --- a/sentry/src/main/java/io/sentry/IScopes.java +++ b/sentry/src/main/java/io/sentry/IScopes.java @@ -744,5 +744,5 @@ default boolean isNoOp() { @NotNull ILoggerApi logger(); - void addFeatureFlag(final @NotNull String flag, final boolean result); + void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result); } diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index 0292d1a82d5..811e1d297a3 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -332,5 +332,5 @@ public boolean isNoOp() { } @Override - public void addFeatureFlag(final @NotNull String flag, final boolean result) {} + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) {} } diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index 617269d3a2e..c04c5af87bd 100644 --- a/sentry/src/main/java/io/sentry/NoOpScope.java +++ b/sentry/src/main/java/io/sentry/NoOpScope.java @@ -301,7 +301,7 @@ public void setSpanContext( public void replaceOptions(@NotNull SentryOptions options) {} @Override - public void addFeatureFlag(final @NotNull String flag, final boolean result) {} + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) {} @Override public @Nullable FeatureFlags getFeatureFlags() { diff --git a/sentry/src/main/java/io/sentry/NoOpScopes.java b/sentry/src/main/java/io/sentry/NoOpScopes.java index 5d3447dd7c6..40777da892f 100644 --- a/sentry/src/main/java/io/sentry/NoOpScopes.java +++ b/sentry/src/main/java/io/sentry/NoOpScopes.java @@ -330,5 +330,5 @@ public boolean isNoOp() { } @Override - public void addFeatureFlag(final @NotNull String flag, final boolean result) {} + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) {} } diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 6f3f28399ae..eb17420dd24 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -1128,7 +1128,7 @@ public void bindClient(@NotNull ISentryClient client) { } @Override - public void addFeatureFlag(final @NotNull String flag, final boolean result) { + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { featureFlags.add(flag, result); } diff --git a/sentry/src/main/java/io/sentry/Scopes.java b/sentry/src/main/java/io/sentry/Scopes.java index 7bf3bb0ca26..c8afde59cc1 100644 --- a/sentry/src/main/java/io/sentry/Scopes.java +++ b/sentry/src/main/java/io/sentry/Scopes.java @@ -1221,7 +1221,7 @@ public void reportFullyDisplayed() { } @Override - public void addFeatureFlag(final @NotNull String flag, final boolean result) { + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { combinedScope.addFeatureFlag(flag, result); } diff --git a/sentry/src/main/java/io/sentry/ScopesAdapter.java b/sentry/src/main/java/io/sentry/ScopesAdapter.java index 8e6c8d65813..99e70694ee0 100644 --- a/sentry/src/main/java/io/sentry/ScopesAdapter.java +++ b/sentry/src/main/java/io/sentry/ScopesAdapter.java @@ -387,7 +387,7 @@ public void reportFullyDisplayed() { } @Override - public void addFeatureFlag(final @NotNull String flag, final boolean result) { + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { Sentry.addFeatureFlag(flag, result); } } diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 5bf2063d73c..dbc7e5b1003 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1373,7 +1373,7 @@ public static void showUserFeedbackDialog( options.getFeedbackOptions().getDialogHandler().showDialog(associatedEventId, configurator); } - public static void addFeatureFlag(final @NotNull String flag, final boolean result) { + public static void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { getCurrentScopes().addFeatureFlag(flag, result); } } diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index 53789edd801..363524f3a39 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -38,7 +38,10 @@ private FeatureFlagBuffer(@NotNull FeatureFlagBuffer other) { } @Override - public void add(@NotNull String flag, boolean result) { + public void add(final @Nullable String flag, final @Nullable Boolean result) { + if (flag == null || result == null) { + return; + } try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final int size = flags.size(); final @NotNull ArrayList tmpList = new ArrayList<>(size + 1); diff --git a/sentry/src/main/java/io/sentry/featureflags/IFeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/IFeatureFlagBuffer.java index 39878759e52..7f12026a590 100644 --- a/sentry/src/main/java/io/sentry/featureflags/IFeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/IFeatureFlagBuffer.java @@ -7,7 +7,7 @@ @ApiStatus.Internal public interface IFeatureFlagBuffer { - void add(@NotNull String flag, boolean result); + void add(final @Nullable String flag, final @Nullable Boolean result); @Nullable FeatureFlags getFeatureFlags(); diff --git a/sentry/src/main/java/io/sentry/featureflags/NoOpFeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/NoOpFeatureFlagBuffer.java index 82f5709c0bb..3bfc8f8fd2a 100644 --- a/sentry/src/main/java/io/sentry/featureflags/NoOpFeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/NoOpFeatureFlagBuffer.java @@ -14,7 +14,7 @@ public static NoOpFeatureFlagBuffer getInstance() { } @Override - public void add(@NotNull String flag, boolean result) {} + public void add(final @Nullable String flag, final @Nullable Boolean result) {} @Override public @Nullable FeatureFlags getFeatureFlags() { diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index e80793f54cb..42c18049e04 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -1123,6 +1123,20 @@ class ScopeTest { assertFalse(flag0.result) } + @Test + fun `null feature flags are ignored`() { + val scope = Scope(SentryOptions.empty()) + + scope.addFeatureFlag(null, true) + scope.addFeatureFlag("flag1", null) + scope.addFeatureFlag(null, null) + + val flags = scope.featureFlags + assertNotNull(flags) + + assertEquals(0, flags.values.size) + } + private fun eventProcessor(): EventProcessor = object : EventProcessor { override fun process(event: SentryEvent, hint: Hint): SentryEvent? = event diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index 6b1ac51c7e1..93368601d94 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -3121,6 +3121,41 @@ class ScopesTest { ) } + @Test + fun `null feature flags are ignored`() { + val (sut, mockClient) = getEnabledScopes() + + sut.addFeatureFlag(null, true) + sut.addFeatureFlag("flag-1", true) + sut.addFeatureFlag(null, null) + + sut.scope.addFeatureFlag(null, true) + sut.scope.addFeatureFlag("current-feature-flag", null) + sut.scope.addFeatureFlag(null, null) + + sut.isolationScope.addFeatureFlag(null, false) + sut.isolationScope.addFeatureFlag("isolation-feature-flag", null) + sut.isolationScope.addFeatureFlag(null, null) + + sut.globalScope.addFeatureFlag(null, true) + sut.globalScope.addFeatureFlag("global-feature-flag", null) + sut.globalScope.addFeatureFlag(null, null) + + sut.captureException(RuntimeException("test exception")) + + verify(mockClient) + .captureEvent( + any(), + check { + val featureFlags = it.featureFlags + assertNotNull(featureFlags) + + assertEquals(0, featureFlags.values.size) + }, + anyOrNull(), + ) + } + private val dsnTest = "https://key@sentry.io/proj" private fun generateScopes( From 21806f2bee8115beeadc761e2d36aab80e92e15d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 17 Oct 2025 14:19:03 +0200 Subject: [PATCH 08/30] handle empty/noop buffer on merge and add tests for it --- .../featureflags/FeatureFlagBuffer.java | 6 +- .../featureflags/FeatureFlagBufferTest.kt | 155 ++++++++++++++++++ 2 files changed, 158 insertions(+), 3 deletions(-) diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index 363524f3a39..d17bcbbb80f 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -141,12 +141,12 @@ public IFeatureFlagBuffer clone() { int currentIndex = currentSize - 1; @Nullable - FeatureFlagEntry globalEntry = globalFlags == null ? null : globalFlags.get(globalIndex); + FeatureFlagEntry globalEntry = globalFlags == null || globalIndex < 0 ? null : globalFlags.get(globalIndex); @Nullable FeatureFlagEntry isolationEntry = - isolationFlags == null ? null : isolationFlags.get(isolationIndex); + isolationFlags == null || isolationIndex < 0 ? null : isolationFlags.get(isolationIndex); @Nullable - FeatureFlagEntry currentEntry = currentFlags == null ? null : currentFlags.get(currentIndex); + FeatureFlagEntry currentEntry = currentFlags == null || currentIndex < 0 ? null : currentFlags.get(currentIndex); final @NotNull java.util.Map uniqueFlags = new java.util.LinkedHashMap<>(maxSize); diff --git a/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt b/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt index ca996ef91a8..471ba880eb4 100644 --- a/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt +++ b/sentry/src/test/java/io/sentry/featureflags/FeatureFlagBufferTest.kt @@ -101,6 +101,150 @@ class FeatureFlagBufferTest { assertEquals("globalC", featureFlagValues[1]!!.flag) } + @Test + fun `when merging global buffer can be empty`() { + val options = SentryOptions().also { it.maxFeatureFlags = 2 } + val globalBuffer = FeatureFlagBuffer.create(options) + val isolationBuffer = FeatureFlagBuffer.create(options) + val currentBuffer = FeatureFlagBuffer.create(options) + isolationBuffer.add("isolationA", true) + currentBuffer.add("currentA", true) + isolationBuffer.add("isolationB", true) + currentBuffer.add("currentB", true) + isolationBuffer.add("isolationC", true) + currentBuffer.add("currentC", true) + + val buffer = FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("isolationC", featureFlagValues[0]!!.flag) + assertEquals("currentC", featureFlagValues[1]!!.flag) + } + + @Test + fun `when merging isolation buffer can be empty`() { + val options = SentryOptions().also { it.maxFeatureFlags = 2 } + val globalBuffer = FeatureFlagBuffer.create(options) + val isolationBuffer = FeatureFlagBuffer.create(options) + val currentBuffer = FeatureFlagBuffer.create(options) + globalBuffer.add("globalA", true) + currentBuffer.add("currentA", true) + globalBuffer.add("globalB", true) + currentBuffer.add("currentB", true) + globalBuffer.add("globalC", true) + currentBuffer.add("currentC", true) + + val buffer = FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("globalC", featureFlagValues[0]!!.flag) + assertEquals("currentC", featureFlagValues[1]!!.flag) + } + + @Test + fun `when merging current buffer can be empty`() { + val options = SentryOptions().also { it.maxFeatureFlags = 2 } + val globalBuffer = FeatureFlagBuffer.create(options) + val isolationBuffer = FeatureFlagBuffer.create(options) + val currentBuffer = FeatureFlagBuffer.create(options) + globalBuffer.add("globalA", true) + isolationBuffer.add("isolationA", true) + globalBuffer.add("globalB", true) + isolationBuffer.add("isolationB", true) + globalBuffer.add("globalC", true) + isolationBuffer.add("isolationC", true) + + val buffer = FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("globalC", featureFlagValues[0]!!.flag) + assertEquals("isolationC", featureFlagValues[1]!!.flag) + } + + @Test + fun `when merging global buffer can be noop`() { + val options = SentryOptions().also { it.maxFeatureFlags = 2 } + val globalBuffer = NoOpFeatureFlagBuffer.getInstance() + val isolationBuffer = FeatureFlagBuffer.create(options) + val currentBuffer = FeatureFlagBuffer.create(options) + isolationBuffer.add("isolationA", true) + currentBuffer.add("currentA", true) + isolationBuffer.add("isolationB", true) + currentBuffer.add("currentB", true) + isolationBuffer.add("isolationC", true) + currentBuffer.add("currentC", true) + + val buffer = FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("isolationC", featureFlagValues[0]!!.flag) + assertEquals("currentC", featureFlagValues[1]!!.flag) + } + + @Test + fun `when merging isolation buffer can be noop`() { + val options = SentryOptions().also { it.maxFeatureFlags = 2 } + val globalBuffer = FeatureFlagBuffer.create(options) + val isolationBuffer = NoOpFeatureFlagBuffer.getInstance() + val currentBuffer = FeatureFlagBuffer.create(options) + globalBuffer.add("globalA", true) + currentBuffer.add("currentA", true) + globalBuffer.add("globalB", true) + currentBuffer.add("currentB", true) + globalBuffer.add("globalC", true) + currentBuffer.add("currentC", true) + + val buffer = FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("globalC", featureFlagValues[0]!!.flag) + assertEquals("currentC", featureFlagValues[1]!!.flag) + } + + @Test + fun `when merging current buffer can be noop`() { + val options = SentryOptions().also { it.maxFeatureFlags = 2 } + val globalBuffer = FeatureFlagBuffer.create(options) + val isolationBuffer = FeatureFlagBuffer.create(options) + val currentBuffer = NoOpFeatureFlagBuffer.getInstance() + globalBuffer.add("globalA", true) + isolationBuffer.add("isolationA", true) + globalBuffer.add("globalB", true) + isolationBuffer.add("isolationB", true) + globalBuffer.add("globalC", true) + isolationBuffer.add("isolationC", true) + + val buffer = FeatureFlagBuffer.merged(options, globalBuffer, isolationBuffer, currentBuffer) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("globalC", featureFlagValues[0]!!.flag) + assertEquals("isolationC", featureFlagValues[1]!!.flag) + } + @Test fun `drops oldest entries when merging multiple buffers all from same source`() { val options = SentryOptions().also { it.maxFeatureFlags = 2 } @@ -179,4 +323,15 @@ class FeatureFlagBufferTest { assertTrue(buffer is NoOpFeatureFlagBuffer) } + + @Test + fun `null values are ignored`() { + val buffer = FeatureFlagBuffer.create(SentryOptions().also { it.maxFeatureFlags = 2 }) + buffer.add(null, true) + buffer.add("b", null) + buffer.add(null, null) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + } } From 95550ecb3c19270b498d664562400d217e0e6a0e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 21 Oct 2025 08:10:35 +0200 Subject: [PATCH 09/30] optimize add method --- .../sentry/featureflags/FeatureFlagBuffer.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index d17bcbbb80f..1da7d8e6e3b 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -44,19 +44,17 @@ public void add(final @Nullable String flag, final @Nullable Boolean result) { } try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final int size = flags.size(); - final @NotNull ArrayList tmpList = new ArrayList<>(size + 1); - for (FeatureFlagEntry entry : flags) { - if (!entry.flag.equals(flag)) { - tmpList.add(entry); + for (int i = 0; i < size; i++) { + if (flags.get(i).equals(flag)) { + flags.remove(i); + break; } } - tmpList.add(new FeatureFlagEntry(flag, result, System.nanoTime())); + flags.add(new FeatureFlagEntry(flag, result, System.nanoTime())); - if (tmpList.size() > maxSize) { - tmpList.remove(0); + if (flags.size() > maxSize) { + flags.remove(0); } - - flags = new CopyOnWriteArrayList<>(tmpList); } } From 865ad5d1c9302d9e8e23e06daf07a7efa91cf68a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 21 Oct 2025 08:13:19 +0200 Subject: [PATCH 10/30] format; fix test --- .../io/sentry/featureflags/FeatureFlagBuffer.java | 6 ++++-- sentry/src/test/java/io/sentry/ScopesTest.kt | 14 ++------------ 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index 1da7d8e6e3b..f140dd92e4c 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -139,12 +139,14 @@ public IFeatureFlagBuffer clone() { int currentIndex = currentSize - 1; @Nullable - FeatureFlagEntry globalEntry = globalFlags == null || globalIndex < 0 ? null : globalFlags.get(globalIndex); + FeatureFlagEntry globalEntry = + globalFlags == null || globalIndex < 0 ? null : globalFlags.get(globalIndex); @Nullable FeatureFlagEntry isolationEntry = isolationFlags == null || isolationIndex < 0 ? null : isolationFlags.get(isolationIndex); @Nullable - FeatureFlagEntry currentEntry = currentFlags == null || currentIndex < 0 ? null : currentFlags.get(currentIndex); + FeatureFlagEntry currentEntry = + currentFlags == null || currentIndex < 0 ? null : currentFlags.get(currentIndex); final @NotNull java.util.Map uniqueFlags = new java.util.LinkedHashMap<>(maxSize); diff --git a/sentry/src/test/java/io/sentry/ScopesTest.kt b/sentry/src/test/java/io/sentry/ScopesTest.kt index 93368601d94..fa82c53fbe9 100644 --- a/sentry/src/test/java/io/sentry/ScopesTest.kt +++ b/sentry/src/test/java/io/sentry/ScopesTest.kt @@ -3126,7 +3126,7 @@ class ScopesTest { val (sut, mockClient) = getEnabledScopes() sut.addFeatureFlag(null, true) - sut.addFeatureFlag("flag-1", true) + sut.addFeatureFlag("flag-1", null) sut.addFeatureFlag(null, null) sut.scope.addFeatureFlag(null, true) @@ -3143,17 +3143,7 @@ class ScopesTest { sut.captureException(RuntimeException("test exception")) - verify(mockClient) - .captureEvent( - any(), - check { - val featureFlags = it.featureFlags - assertNotNull(featureFlags) - - assertEquals(0, featureFlags.values.size) - }, - anyOrNull(), - ) + verify(mockClient).captureEvent(any(), check { assertNull(it.featureFlags) }, anyOrNull()) } private val dsnTest = "https://key@sentry.io/proj" From fa81bf816ee4f4b8e54abc3a2571851f6a38cc7a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 21 Oct 2025 08:13:58 +0200 Subject: [PATCH 11/30] Add rules for feature flags and mention in overview_dev --- .cursor/rules/feature_flags.mdc | 24 ++++++++++++++++++++++++ .cursor/rules/overview_dev.mdc | 9 +++++++++ 2 files changed, 33 insertions(+) create mode 100644 .cursor/rules/feature_flags.mdc diff --git a/.cursor/rules/feature_flags.mdc b/.cursor/rules/feature_flags.mdc new file mode 100644 index 00000000000..6ced6064295 --- /dev/null +++ b/.cursor/rules/feature_flags.mdc @@ -0,0 +1,24 @@ +--- +alwaysApply: false +description: Feature Flags +--- +# Java SDK Feature Flags + +There is a scope based and a span based API for tracking feature flag evaluations. + +## Scope Based API + +The `addFeatureFlag` method can be used to track feature flag evaluations. It exists on `Sentry` static API as well as `IScopes` and `IScope`. + +The `maxFeatureFlags` option controls how many flags are tracked per scope and also how many are sent to Sentry as part of events. +Scope based feature flags can also be disabled by setting the value to 0. Defaults to 100 feature flag evaluations. + +Order of feature flag evaluations is important as we only keep track of the last {maxFeatureFlag} items. + +When a feature flag evluation with the same name is added, the previous one is removed and the new one is stored so that it'll be dropped last. + +When sending out an error event, feature flag buffers from all three scope types (global, isolation and current scope) are merged, chosing the newest {maxFeatureFlag} entries across all scope types. Feature flags are sent as part of the `flags` context. + +## Span Based API + +tbd diff --git a/.cursor/rules/overview_dev.mdc b/.cursor/rules/overview_dev.mdc index 89c70e2c158..12ac73a8448 100644 --- a/.cursor/rules/overview_dev.mdc +++ b/.cursor/rules/overview_dev.mdc @@ -34,6 +34,14 @@ Use the `fetch_rules` tool to include these rules when working on specific areas - Rate limiting, cache rotation - Android vs JVM caching differences +- **`feature_flags`**: Use when working with: + - Feature flag tracking and evaluation + - `addFeatureFlag()`, `getFeatureFlags()` methods + - `FeatureFlagBuffer`, `FeatureFlag` protocol + - `maxFeatureFlags` option and buffer management + - Feature flag merging across scope types + - Scope-based vs span-based feature flag APIs + ### Integration & Infrastructure - **`opentelemetry`**: Use when working with: - OpenTelemetry modules (`sentry-opentelemetry-*`) @@ -63,3 +71,4 @@ Use the `fetch_rules` tool to include these rules when working on specific areas - new module/integration/sample → `new_module` - Cache/offline/network → `offline` - System test/e2e/sample → `e2e_tests` + - Feature flag/addFeatureFlag/flag evaluation → `feature_flags` From a5c7aef033fbc478546756f04e9e0aa7acaa1772 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 21 Oct 2025 09:42:56 +0200 Subject: [PATCH 12/30] Fix duplicate check --- .../main/java/io/sentry/featureflags/FeatureFlagBuffer.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index f140dd92e4c..5baa83fda07 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -45,7 +45,8 @@ public void add(final @Nullable String flag, final @Nullable Boolean result) { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { final int size = flags.size(); for (int i = 0; i < size; i++) { - if (flags.get(i).equals(flag)) { + final @NotNull FeatureFlagEntry entry = flags.get(i); + if (entry.flag.equals(flag)) { flags.remove(i); break; } From 0ab241eb2b6db1c5742b93cc234e139094d35020 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 21 Oct 2025 09:43:14 +0200 Subject: [PATCH 13/30] Update sentry/src/main/java/io/sentry/SentryOptions.java Co-authored-by: Lorenzo Cian <17258265+lcian@users.noreply.github.com> --- sentry/src/main/java/io/sentry/SentryOptions.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 6a488473b06..b12c0ad050e 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -197,7 +197,9 @@ public class SentryOptions { private int maxBreadcrumbs = 100; /** - * This variable controls the total amount of feature flags that should be captured Default is 100 + * This variable controls the total amount of feature flag evaluations that should be stored on the scope. + * The most recent `maxFeatureFlags` evaluations are stored on each scope. + * Default is 100 */ private int maxFeatureFlags = 100; From ab1a1469c79a6eb4e546535a045de894e088ef89 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 21 Oct 2025 07:46:28 +0000 Subject: [PATCH 14/30] Format code --- sentry/src/main/java/io/sentry/SentryOptions.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 2ad368e3459..6fa1c0c94e5 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -199,9 +199,9 @@ public class SentryOptions { private int maxBreadcrumbs = 100; /** - * This variable controls the total amount of feature flag evaluations that should be stored on the scope. - * The most recent `maxFeatureFlags` evaluations are stored on each scope. - * Default is 100 + * This variable controls the total amount of feature flag evaluations that should be stored on + * the scope. The most recent `maxFeatureFlags` evaluations are stored on each scope. Default is + * 100 */ private int maxFeatureFlags = 100; From 196b3b43229c9ca82e0dd705ade603968bcb3f70 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 23 Oct 2025 12:52:32 +0200 Subject: [PATCH 15/30] Add feature flags to spans --- .../api/sentry-opentelemetry-bootstrap.api | 2 + .../OtelStrongRefSpanWrapper.java | 5 + .../OtelTransactionSpanForwarder.java | 5 + .../api/sentry-opentelemetry-core.api | 1 + .../sentry/opentelemetry/OtelSpanWrapper.java | 9 ++ sentry/api/sentry.api | 14 ++ .../java/io/sentry/CombinedScopeView.java | 4 + sentry/src/main/java/io/sentry/ISpan.java | 2 + sentry/src/main/java/io/sentry/NoOpSpan.java | 3 + .../main/java/io/sentry/NoOpTransaction.java | 3 + .../src/main/java/io/sentry/SentryTracer.java | 5 + sentry/src/main/java/io/sentry/Span.java | 14 ++ .../featureflags/FeatureFlagBuffer.java | 10 ++ .../featureflags/SpanFeatureFlagBuffer.java | 74 +++++++++++ .../java/io/sentry/protocol/SentrySpan.java | 8 ++ .../featureflags/SpanFeatureFlagBufferTest.kt | 123 ++++++++++++++++++ 16 files changed, 282 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java create mode 100644 sentry/src/test/java/io/sentry/featureflags/SpanFeatureFlagBufferTest.kt diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api index 3a63bf04d98..ba43abdbec7 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api @@ -43,6 +43,7 @@ public final class io/sentry/opentelemetry/OtelSpanFactory : io/sentry/ISpanFact public final class io/sentry/opentelemetry/OtelStrongRefSpanWrapper : io/sentry/opentelemetry/IOtelSpanWrapper { public fun (Lio/opentelemetry/api/trace/Span;Lio/sentry/opentelemetry/IOtelSpanWrapper;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -96,6 +97,7 @@ public final class io/sentry/opentelemetry/OtelStrongRefSpanWrapper : io/sentry/ public final class io/sentry/opentelemetry/OtelTransactionSpanForwarder : io/sentry/ITransaction { public fun (Lio/sentry/opentelemetry/IOtelSpanWrapper;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java index a4008a01283..995fe7f787b 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java @@ -310,4 +310,9 @@ public void setContext(@Nullable String key, @Nullable Object context) { public @Nullable Attributes getOpenTelemetrySpanAttributes() { return delegate.getOpenTelemetrySpanAttributes(); } + + @Override + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { + delegate.addFeatureFlag(flag, result); + } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java index 7d0618af040..e3cdfc4be3b 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelTransactionSpanForwarder.java @@ -309,4 +309,9 @@ public void setName(@NotNull String name, @NotNull TransactionNameSource nameSou } return name; } + + @Override + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { + rootSpan.addFeatureFlag(flag, result); + } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api index 4c231317af3..b51c8cc39bc 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api +++ b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api @@ -57,6 +57,7 @@ public final class io/sentry/opentelemetry/OtelSpanUtils { public final class io/sentry/opentelemetry/OtelSpanWrapper : io/sentry/opentelemetry/IOtelSpanWrapper { public fun (Lio/opentelemetry/sdk/trace/ReadWriteSpan;Lio/sentry/IScopes;Lio/sentry/SentryDate;Lio/sentry/TracesSamplingDecision;Lio/sentry/opentelemetry/IOtelSpanWrapper;Lio/sentry/SpanId;Lio/sentry/Baggage;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V 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 bc78643fb9b..1b919576110 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 @@ -24,6 +24,8 @@ import io.sentry.SpanStatus; import io.sentry.TraceContext; import io.sentry.TracesSamplingDecision; +import io.sentry.featureflags.IFeatureFlagBuffer; +import io.sentry.featureflags.SpanFeatureFlagBuffer; import io.sentry.protocol.Contexts; import io.sentry.protocol.MeasurementValue; import io.sentry.protocol.SentryId; @@ -75,6 +77,8 @@ public final class OtelSpanWrapper implements IOtelSpanWrapper { private @NotNull Deque tokensToCleanup = new ArrayDeque<>(1); + private final @NotNull IFeatureFlagBuffer featureFlags = SpanFeatureFlagBuffer.create(); + public OtelSpanWrapper( final @NotNull ReadWriteSpan span, final @NotNull IScopes scopes, @@ -505,6 +509,11 @@ public Map getMeasurements() { return scopes; } + @Override + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { + featureFlags.add(flag, result); + } + @Override public @NotNull Context storeInContext(Context context) { final @Nullable ReadWriteSpan otelSpan = getSpan(); diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 4779d08a055..fbb44ec1f4c 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1089,6 +1089,7 @@ public abstract interface class io/sentry/ISocketTagger { } public abstract interface class io/sentry/ISpan { + public abstract fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public abstract fun finish ()V public abstract fun finish (Lio/sentry/SpanStatus;)V public abstract fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -1779,6 +1780,7 @@ public final class io/sentry/NoOpSocketTagger : io/sentry/ISocketTagger { } public final class io/sentry/NoOpSpan : io/sentry/ISpan { + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -1826,6 +1828,7 @@ public final class io/sentry/NoOpSpanFactory : io/sentry/ISpanFactory { } public final class io/sentry/NoOpTransaction : io/sentry/ITransaction { + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -3853,6 +3856,7 @@ public final class io/sentry/SentryTraceHeader { public final class io/sentry/SentryTracer : io/sentry/ITransaction { public fun (Lio/sentry/TransactionContext;Lio/sentry/IScopes;)V public fun (Lio/sentry/TransactionContext;Lio/sentry/IScopes;Lio/sentry/TransactionOptions;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -3993,6 +3997,7 @@ public final class io/sentry/ShutdownHookIntegration : io/sentry/Integration, ja public final class io/sentry/Span : io/sentry/ISpan { public fun (Lio/sentry/TransactionContext;Lio/sentry/SentryTracer;Lio/sentry/IScopes;Lio/sentry/SpanOptions;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -4000,6 +4005,7 @@ public final class io/sentry/Span : io/sentry/ISpan { public fun getData ()Ljava/util/Map; public fun getData (Ljava/lang/String;)Ljava/lang/Object; public fun getDescription ()Ljava/lang/String; + public fun getFeatureFlagBuffer ()Lio/sentry/featureflags/IFeatureFlagBuffer; public fun getFinishDate ()Lio/sentry/SentryDate; public fun getMeasurements ()Ljava/util/Map; public fun getOperation ()Ljava/lang/String; @@ -4759,6 +4765,14 @@ public final class io/sentry/featureflags/NoOpFeatureFlagBuffer : io/sentry/feat public static fun getInstance ()Lio/sentry/featureflags/NoOpFeatureFlagBuffer; } +public final class io/sentry/featureflags/SpanFeatureFlagBuffer : io/sentry/featureflags/IFeatureFlagBuffer { + public fun add (Ljava/lang/String;Ljava/lang/Boolean;)V + public fun clone ()Lio/sentry/featureflags/IFeatureFlagBuffer; + public synthetic fun clone ()Ljava/lang/Object; + public static fun create ()Lio/sentry/featureflags/IFeatureFlagBuffer; + public fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; +} + public abstract interface class io/sentry/hints/AbnormalExit { public abstract fun ignoreCurrentThread ()Z public abstract fun mechanism ()Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java index 0d7c8460e9d..fc90e6255bd 100644 --- a/sentry/src/main/java/io/sentry/CombinedScopeView.java +++ b/sentry/src/main/java/io/sentry/CombinedScopeView.java @@ -514,6 +514,10 @@ public void setReplayId(@NotNull SentryId replayId) { @Override public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { getDefaultWriteScope().addFeatureFlag(flag, result); + final @Nullable ISpan span = getSpan(); + if (span != null) { + span.addFeatureFlag(flag, result); + } } @Override diff --git a/sentry/src/main/java/io/sentry/ISpan.java b/sentry/src/main/java/io/sentry/ISpan.java index 0765f5127f9..ee8fbe1a895 100644 --- a/sentry/src/main/java/io/sentry/ISpan.java +++ b/sentry/src/main/java/io/sentry/ISpan.java @@ -274,4 +274,6 @@ ISpan startChild( @ApiStatus.Internal @NotNull ISentryLifecycleToken makeCurrent(); + + void addFeatureFlag(@Nullable String flag, @Nullable Boolean result); } diff --git a/sentry/src/main/java/io/sentry/NoOpSpan.java b/sentry/src/main/java/io/sentry/NoOpSpan.java index 669e28cfcfa..eff9f4f875c 100644 --- a/sentry/src/main/java/io/sentry/NoOpSpan.java +++ b/sentry/src/main/java/io/sentry/NoOpSpan.java @@ -189,4 +189,7 @@ public void setContext(@Nullable String key, @Nullable Object context) {} public @NotNull ISentryLifecycleToken makeCurrent() { return NoOpScopesLifecycleToken.getInstance(); } + + @Override + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) {} } diff --git a/sentry/src/main/java/io/sentry/NoOpTransaction.java b/sentry/src/main/java/io/sentry/NoOpTransaction.java index 4d266d1952a..1586eed45b7 100644 --- a/sentry/src/main/java/io/sentry/NoOpTransaction.java +++ b/sentry/src/main/java/io/sentry/NoOpTransaction.java @@ -251,4 +251,7 @@ public boolean updateEndDate(final @NotNull SentryDate date) { public boolean isNoOp() { return true; } + + @Override + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) {} } diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 21d5088a181..d902a9f8b05 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -1021,6 +1021,11 @@ public boolean isNoOp() { return false; } + @Override + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { + this.root.addFeatureFlag(flag, result); + } + private static final class FinishStatus { static final FinishStatus NOT_FINISHED = FinishStatus.notFinished(); diff --git a/sentry/src/main/java/io/sentry/Span.java b/sentry/src/main/java/io/sentry/Span.java index d3eb2c06551..319d0bf580c 100644 --- a/sentry/src/main/java/io/sentry/Span.java +++ b/sentry/src/main/java/io/sentry/Span.java @@ -1,5 +1,7 @@ package io.sentry; +import io.sentry.featureflags.IFeatureFlagBuffer; +import io.sentry.featureflags.SpanFeatureFlagBuffer; import io.sentry.protocol.Contexts; import io.sentry.protocol.MeasurementValue; import io.sentry.protocol.SentryId; @@ -49,6 +51,8 @@ public final class Span implements ISpan { private final @NotNull Contexts contexts = new Contexts(); + private final @NotNull IFeatureFlagBuffer featureFlags = SpanFeatureFlagBuffer.create(); + Span( final @NotNull SentryTracer transaction, final @NotNull IScopes scopes, @@ -458,4 +462,14 @@ private List getDirectChildren() { public @NotNull ISentryLifecycleToken makeCurrent() { return NoOpScopesLifecycleToken.getInstance(); } + + @Override + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { + featureFlags.add(flag, result); + } + + @ApiStatus.Internal + public @NotNull IFeatureFlagBuffer getFeatureFlagBuffer() { + return featureFlags; + } } diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index 5baa83fda07..1a27d089334 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -14,6 +14,16 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +/** + * Feature flag buffer implementation optimized for usage in scopes. + * + *

    + *
  • When full, the oldest entry is evicted + *
  • Updates to existing entries refresh the entry, meaning it'll be dropped last + *
  • Performance of scope cloning is optimized here + *
  • Supports merging across scope types (GLOBAL, ISOLATION, CURRENT) + *
+ */ @ApiStatus.Internal public final class FeatureFlagBuffer implements IFeatureFlagBuffer { diff --git a/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java new file mode 100644 index 00000000000..a9c8188869f --- /dev/null +++ b/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java @@ -0,0 +1,74 @@ +package io.sentry.featureflags; + +import io.sentry.ISentryLifecycleToken; +import io.sentry.protocol.FeatureFlag; +import io.sentry.protocol.FeatureFlags; +import io.sentry.util.AutoClosableReentrantLock; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Feature flag buffer implementation optimized for usage in spans. + * + *
    + *
  • When full, new entries are rejected. + *
  • Updates to existing entries are still allowed even if full. + *
  • Lazily initializes its storage to optimize memory consumption. + *
  • Since spans are not cloned, this implementation does not need to optimize for it. + *
+ */ +@ApiStatus.Internal +public final class SpanFeatureFlagBuffer implements IFeatureFlagBuffer { + private static final int MAX_SIZE = 10; + + // lazily initializing the internal storage to reduce memory consumption when not used + private @Nullable Map flags = null; + private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock(); + + private SpanFeatureFlagBuffer() {} + + @Override + public void add(final @Nullable String flag, final @Nullable Boolean result) { + if (flag == null || result == null) { + return; + } + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (flags == null) { + flags = new LinkedHashMap<>(MAX_SIZE); + } + + if (flags.size() < MAX_SIZE || flags.containsKey(flag)) { + flags.put(flag, result); + } + } + } + + @Override + public @Nullable FeatureFlags getFeatureFlags() { + try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { + if (flags == null || flags.isEmpty()) { + return null; + } + + final List featureFlags = new ArrayList<>(flags.size()); + for (Map.Entry entry : flags.entrySet()) { + featureFlags.add(new FeatureFlag(entry.getKey(), entry.getValue())); + } + return new FeatureFlags(featureFlags); + } + } + + @Override + public @NotNull IFeatureFlagBuffer clone() { + return create(); + } + + public static @NotNull IFeatureFlagBuffer create() { + return new SpanFeatureFlagBuffer(); + } +} diff --git a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java index 8ab31beb835..de2ade47b39 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java +++ b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java @@ -11,6 +11,7 @@ import io.sentry.Span; import io.sentry.SpanId; import io.sentry.SpanStatus; +import io.sentry.featureflags.IFeatureFlagBuffer; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; @@ -73,6 +74,13 @@ public SentrySpan(final @NotNull Span span, final @Nullable Map // we lose precision here, from potential nanosecond precision down to 10 microsecond precision this.startTimestamp = DateUtils.nanosToSeconds(span.getStartDate().nanoTimestamp()); this.data = data; + final @NotNull IFeatureFlagBuffer featureFlagBuffer = span.getFeatureFlagBuffer(); + final @Nullable FeatureFlags featureFlags = featureFlagBuffer.getFeatureFlags(); + if (featureFlags != null && data != null) { + for (FeatureFlag featureFlag : featureFlags.getValues()) { + data.put("flag.evaluation." + featureFlag.getFlag(), featureFlag.getResult()); + } + } } @ApiStatus.Internal diff --git a/sentry/src/test/java/io/sentry/featureflags/SpanFeatureFlagBufferTest.kt b/sentry/src/test/java/io/sentry/featureflags/SpanFeatureFlagBufferTest.kt new file mode 100644 index 00000000000..6b3f9c4d162 --- /dev/null +++ b/sentry/src/test/java/io/sentry/featureflags/SpanFeatureFlagBufferTest.kt @@ -0,0 +1,123 @@ +package io.sentry.featureflags + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SpanFeatureFlagBufferTest { + @Test + fun `stores value`() { + val buffer = SpanFeatureFlagBuffer.create() + buffer.add("a", true) + buffer.add("b", false) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + + val featureFlagValues = featureFlags.values + assertEquals(2, featureFlagValues.size) + + assertEquals("a", featureFlagValues[0]!!.flag) + assertTrue(featureFlagValues[0]!!.result) + + assertEquals("b", featureFlagValues[1]!!.flag) + assertFalse(featureFlagValues[1]!!.result) + } + + @Test + fun `rejects new entries when limit is reached`() { + val buffer = SpanFeatureFlagBuffer.create() + // Fill up to maxSize (10) + for (i in 1..10) { + buffer.add("flag$i", true) + } + buffer.add("rejected", true) // This should be rejected + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(10, featureFlagValues.size) + + // Verify "rejected" was not added + assertFalse(featureFlagValues.any { it.flag == "rejected" }) + } + + @Test + fun `allows updates to existing entries even when full`() { + val buffer = SpanFeatureFlagBuffer.create() + // Fill up to maxSize (10) + for (i in 1..10) { + buffer.add("flag$i", true) + } + + // Buffer is full, but updating existing entry should work + buffer.add("flag1", false) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(10, featureFlagValues.size) + + assertEquals("flag1", featureFlagValues[0]!!.flag) + assertFalse(featureFlagValues[0]!!.result) // Updated from true to false + } + + @Test + fun `clone returns new instance`() { + val buffer = SpanFeatureFlagBuffer.create() + val cloned = buffer.clone() + assertNotNull(cloned) + assertTrue(cloned is SpanFeatureFlagBuffer) + } + + @Test + fun `ignores null flag or result`() { + val buffer = SpanFeatureFlagBuffer.create() + buffer.add(null, true) + buffer.add("a", null) + + val featureFlags = buffer.featureFlags + assertEquals(null, featureFlags) + } + + @Test + fun `maintains insertion order`() { + val buffer = SpanFeatureFlagBuffer.create() + buffer.add("first", true) + buffer.add("second", false) + buffer.add("third", true) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(3, featureFlagValues.size) + + assertEquals("first", featureFlagValues[0]!!.flag) + assertEquals("second", featureFlagValues[1]!!.flag) + assertEquals("third", featureFlagValues[2]!!.flag) + } + + @Test + fun `updating existing flag maintains its position`() { + val buffer = SpanFeatureFlagBuffer.create() + buffer.add("first", true) + buffer.add("second", false) + buffer.add("third", true) + + // Update the first flag + buffer.add("first", false) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(3, featureFlagValues.size) + + // Order should remain the same + assertEquals("first", featureFlagValues[0]!!.flag) + assertFalse(featureFlagValues[0]!!.result) // Value updated + assertEquals("second", featureFlagValues[1]!!.flag) + assertEquals("third", featureFlagValues[2]!!.flag) + } +} From 9194849e09048c72ed7d8fffedc25bea98d4b9f1 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 23 Oct 2025 15:33:03 +0200 Subject: [PATCH 16/30] move feature flag buffer to span context; add e2e test --- .../sentry/opentelemetry/OtelSpanWrapper.java | 4 +- .../spring/boot/jakarta/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 4 +- .../io/sentry/systemtest/PersonSystemTest.kt | 3 +- .../io/sentry/systemtest/util/TestHelper.kt | 49 ++++++++++++++++++- sentry/src/main/java/io/sentry/Span.java | 9 +--- .../src/main/java/io/sentry/SpanContext.java | 15 ++++++ .../java/io/sentry/protocol/SentrySpan.java | 2 +- .../io/sentry/protocol/SentryTransaction.java | 9 ++++ 9 files changed, 81 insertions(+), 15 deletions(-) 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 1b919576110..959b732f0d8 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 @@ -77,8 +77,6 @@ public final class OtelSpanWrapper implements IOtelSpanWrapper { private @NotNull Deque tokensToCleanup = new ArrayDeque<>(1); - private final @NotNull IFeatureFlagBuffer featureFlags = SpanFeatureFlagBuffer.create(); - public OtelSpanWrapper( final @NotNull ReadWriteSpan span, final @NotNull IScopes scopes, @@ -511,7 +509,7 @@ public Map getMeasurements() { @Override public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { - featureFlags.add(flag, result); + context.addFeatureFlag(flag, result); } @Override diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java index 2e24833b80f..2b590e1188b 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java @@ -23,6 +23,7 @@ public PersonController(PersonService personService) { @GetMapping("{id}") Person person(@PathVariable Long id) { + Sentry.addFeatureFlag("transaction-feature-flag", true); ISpan currentSpan = Sentry.getSpan(); ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); try { diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 03a0abbdf2f..1a7df48ea8f 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,5 +1,6 @@ package io.sentry.systemtest +import io.sentry.protocol.FeatureFlag import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals @@ -31,7 +32,8 @@ class PersonSystemTest { } testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHaveOp(transaction, "http.server") + testHelper.doesTransactionHave(transaction, op = "http.server", featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true)) + && testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi", featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true)) } Thread.sleep(10000) diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 8119e5ab4e9..d349709b412 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,5 +1,6 @@ package io.sentry.systemtest +import io.sentry.protocol.FeatureFlag import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals @@ -26,7 +27,7 @@ class PersonSystemTest { } testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHaveOp(transaction, "http.server") + testHelper.doesTransactionHave(transaction, op = "http.server", featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true)) } Thread.sleep(10000) diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt index 00bfa743a39..3b83853b920 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -8,6 +8,7 @@ import io.sentry.SentryEvent import io.sentry.SentryItemType import io.sentry.SentryLogEvents import io.sentry.SentryOptions +import io.sentry.protocol.FeatureFlag import io.sentry.protocol.SentrySpan import io.sentry.protocol.SentryTransaction import io.sentry.systemtest.graphql.GraphqlTestClient @@ -154,7 +155,7 @@ class TestHelper(backendUrl: String) { } fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> + ensureEnvelopeReceived(retryCount = 3) { envelopeString -> val deserializeEnvelope = jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) if (deserializeEnvelope == null) { return@ensureEnvelopeReceived false @@ -275,6 +276,52 @@ class TestHelper(backendUrl: String) { return true } + fun doesTransactionHave(transaction: SentryTransaction, op: String, featureFlag: FeatureFlag? = null): Boolean { + val matches = transaction.contexts.trace?.operation == op + if (!matches) { + println("Unable to find transaction with op $op:") + logObject(transaction) + return false + } + + val foundFlag = transaction.contexts.trace?.data?.get(featureFlag?.flag) + if (featureFlag != null && foundFlag == null) { + println("Unable to find span with feature flag ${featureFlag?.flag}:") + logObject(transaction) + return false + } + if (featureFlag != null && foundFlag != featureFlag.result) { + println("Feature flag ${featureFlag?.flag} has unexpected result ${foundFlag}:") + logObject(transaction) + return false + } + + return true + } + + fun doesTransactionHaveSpanWith(transaction: SentryTransaction, op: String, featureFlag: FeatureFlag? = null): Boolean { + val foundSpan = transaction.spans.firstOrNull { span -> span.op == op } + if (foundSpan == null) { + println("Unable to find span with op $op:") + logObject(transaction) + return false + } + + val foundFlag = foundSpan.data?.get(featureFlag?.flag) + if (featureFlag != null && foundFlag == null) { + println("Unable to find span with feature flag ${featureFlag?.flag}:") + logObject(transaction) + return false + } + if (featureFlag != null && foundFlag != featureFlag.result) { + println("Feature flag ${featureFlag?.flag} has unexpected result ${foundFlag}:") + logObject(transaction) + return false + } + + return true + } + fun doesEventHaveExceptionMessage(event: SentryEvent, expectedMessage: String): Boolean { val exceptions = event.exceptions if (exceptions == null) { diff --git a/sentry/src/main/java/io/sentry/Span.java b/sentry/src/main/java/io/sentry/Span.java index 319d0bf580c..f6f773762e6 100644 --- a/sentry/src/main/java/io/sentry/Span.java +++ b/sentry/src/main/java/io/sentry/Span.java @@ -51,8 +51,6 @@ public final class Span implements ISpan { private final @NotNull Contexts contexts = new Contexts(); - private final @NotNull IFeatureFlagBuffer featureFlags = SpanFeatureFlagBuffer.create(); - Span( final @NotNull SentryTracer transaction, final @NotNull IScopes scopes, @@ -465,11 +463,6 @@ private List getDirectChildren() { @Override public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { - featureFlags.add(flag, result); - } - - @ApiStatus.Internal - public @NotNull IFeatureFlagBuffer getFeatureFlagBuffer() { - return featureFlags; + context.addFeatureFlag(flag, result); } } diff --git a/sentry/src/main/java/io/sentry/SpanContext.java b/sentry/src/main/java/io/sentry/SpanContext.java index af981a6d245..a08332f56f2 100644 --- a/sentry/src/main/java/io/sentry/SpanContext.java +++ b/sentry/src/main/java/io/sentry/SpanContext.java @@ -1,6 +1,9 @@ package io.sentry; import com.jakewharton.nopen.annotation.Open; + +import io.sentry.featureflags.IFeatureFlagBuffer; +import io.sentry.featureflags.SpanFeatureFlagBuffer; import io.sentry.protocol.SentryId; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -55,6 +58,8 @@ public class SpanContext implements JsonUnknown, JsonSerializable { protected @Nullable Baggage baggage; + protected @NotNull IFeatureFlagBuffer featureFlags = SpanFeatureFlagBuffer.create(); + /** * Set the profiler id associated with this transaction. If set to a non-empty id, this value will * be sent to sentry instead of {@link SentryOptions#getContinuousProfiler} @@ -320,6 +325,16 @@ public void setProfilerId(@NotNull SentryId profilerId) { this.profilerId = profilerId; } + @ApiStatus.Internal + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { + featureFlags.add(flag, result); + } + + @ApiStatus.Internal + public @NotNull IFeatureFlagBuffer getFeatureFlagBuffer() { + return featureFlags; + } + // region JsonSerializable public static final class JsonKeys { diff --git a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java index de2ade47b39..d53ed820558 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java +++ b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java @@ -74,7 +74,7 @@ public SentrySpan(final @NotNull Span span, final @Nullable Map // we lose precision here, from potential nanosecond precision down to 10 microsecond precision this.startTimestamp = DateUtils.nanosToSeconds(span.getStartDate().nanoTimestamp()); this.data = data; - final @NotNull IFeatureFlagBuffer featureFlagBuffer = span.getFeatureFlagBuffer(); + final @NotNull IFeatureFlagBuffer featureFlagBuffer = span.getSpanContext().getFeatureFlagBuffer(); final @Nullable FeatureFlags featureFlags = featureFlagBuffer.getFeatureFlags(); if (featureFlags != null && data != null) { for (FeatureFlag featureFlag : featureFlags.getValues()) { diff --git a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java index 2858fb3c27c..86bda259c31 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java @@ -15,6 +15,7 @@ import io.sentry.SpanContext; import io.sentry.SpanStatus; import io.sentry.TracesSamplingDecision; +import io.sentry.featureflags.IFeatureFlagBuffer; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -99,6 +100,14 @@ public SentryTransaction(final @NotNull SentryTracer sentryTracer) { } } + final @NotNull IFeatureFlagBuffer featureFlagBuffer = tracerContext.getFeatureFlagBuffer(); + final @Nullable FeatureFlags featureFlags = featureFlagBuffer.getFeatureFlags(); + if (featureFlags != null) { + for (FeatureFlag featureFlag : featureFlags.getValues()) { + tracerContextToSend.setData("flag.evaluation." + featureFlag.getFlag(), featureFlag.getResult()); + } + } + contexts.setTrace(tracerContextToSend); this.transactionInfo = new TransactionInfo(sentryTracer.getTransactionNameSource().apiName()); From a4b6ca3d042d5403aa545e74a6576814bba08eb3 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 24 Oct 2025 11:04:05 +0200 Subject: [PATCH 17/30] e2e tests; fix POTel feature flags --- .../sentry/opentelemetry/OtelSpanWrapper.java | 2 -- .../opentelemetry/SentrySpanExporter.java | 13 +++++++++++ .../spring/boot4/PersonController.java | 3 ++- .../io/sentry/systemtest/PersonSystemTest.kt | 23 +++++++++++++++---- .../spring/boot4/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 14 +++++++++-- .../spring/boot4/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 12 +++++++++- .../spring/boot/jakarta/PersonController.java | 3 ++- .../io/sentry/systemtest/PersonSystemTest.kt | 23 +++++++++++++++---- .../spring/boot/jakarta/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 14 +++++++++-- .../io/sentry/systemtest/PersonSystemTest.kt | 12 ++++++++-- .../samples/spring/boot/PersonController.java | 3 ++- .../io/sentry/systemtest/PersonSystemTest.kt | 23 +++++++++++++++---- .../samples/spring/boot/PersonController.java | 1 + .../io/sentry/systemtest/PersonSystemTest.kt | 14 +++++++++-- .../api/sentry-system-test-support.api | 4 ++++ .../io/sentry/systemtest/util/TestHelper.kt | 21 +++++++++++++++-- sentry/api/sentry.api | 5 +++- sentry/src/main/java/io/sentry/Span.java | 2 -- .../src/main/java/io/sentry/SpanContext.java | 1 - .../java/io/sentry/protocol/FeatureFlag.java | 2 ++ .../java/io/sentry/protocol/SentrySpan.java | 5 ++-- .../io/sentry/protocol/SentryTransaction.java | 3 ++- 25 files changed, 171 insertions(+), 35 deletions(-) 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 959b732f0d8..a72084ad67f 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 @@ -24,8 +24,6 @@ import io.sentry.SpanStatus; import io.sentry.TraceContext; import io.sentry.TracesSamplingDecision; -import io.sentry.featureflags.IFeatureFlagBuffer; -import io.sentry.featureflags.SpanFeatureFlagBuffer; import io.sentry.protocol.Contexts; import io.sentry.protocol.MeasurementValue; import io.sentry.protocol.SentryId; diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java index 268b8231a81..6de53815c7f 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java @@ -33,7 +33,10 @@ import io.sentry.SpanStatus; import io.sentry.TransactionContext; import io.sentry.TransactionOptions; +import io.sentry.featureflags.IFeatureFlagBuffer; import io.sentry.protocol.Contexts; +import io.sentry.protocol.FeatureFlag; +import io.sentry.protocol.FeatureFlags; import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import java.util.Arrays; @@ -260,6 +263,16 @@ private void transferSpanDetails( targetSpan.setData(entry.getKey(), entry.getValue()); } + final @NotNull SpanContext spanContext = sourceSpan.getSpanContext(); + final @NotNull IFeatureFlagBuffer featureFlagBuffer = spanContext.getFeatureFlagBuffer(); + final @Nullable FeatureFlags featureFlags = featureFlagBuffer.getFeatureFlags(); + if (featureFlags != null) { + for (FeatureFlag featureFlag : featureFlags.getValues()) { + targetSpan.setData( + FeatureFlag.DATA_PREFIX + featureFlag.getFlag(), featureFlag.getResult()); + } + } + final @NotNull Map tags = sourceSpan.getTags(); for (Map.Entry entry : tags.entrySet()) { targetSpan.setTag(entry.getKey(), entry.getValue()); diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonController.java index 70159b8aef0..b96c840aae8 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonController.java @@ -31,12 +31,13 @@ public PersonController(PersonService personService, Tracer tracer) { @GetMapping("{id}") @WithSpan("personSpanThroughOtelAnnotation") Person person(@PathVariable Long id) { + Sentry.addFeatureFlag("outer-feature-flag", true); Span span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan(); try (final @NotNull Scope spanScope = span.makeCurrent()) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); - Sentry.addFeatureFlag("my-feature-flag", true); + Sentry.addFeatureFlag("inner-feature-flag", true); ISpan currentSpan = Sentry.getSpan(); ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); try { diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 2488cc87d11..f277ff22025 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,5 +1,6 @@ package io.sentry.systemtest +import io.sentry.protocol.FeatureFlag import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals @@ -22,17 +23,31 @@ class PersonSystemTest { testHelper.ensureErrorReceived { event -> event.message?.formatted == "Trying person with id=1" && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + testHelper.doesEventHaveFlag(event, "inner-feature-flag", true) } testHelper.ensureErrorReceived { event -> testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + testHelper.doesEventHaveFlag(event, "inner-feature-flag", true) } testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") + testHelper.doesTransactionHave(transaction, op = "http.server") && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "personSpanThroughOtelAnnotation", + featureFlag = FeatureFlag("flag.evaluation.outer-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughOtelApi", + featureFlag = FeatureFlag("flag.evaluation.inner-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughSentryApi", + noFeatureFlags = true, + ) } Thread.sleep(10000) diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonController.java index 2861168fc79..bde91c83825 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonController.java @@ -29,6 +29,7 @@ public PersonController(PersonService personService, Tracer tracer) { @GetMapping("{id}") Person person(@PathVariable Long id) { + Sentry.addFeatureFlag("transaction-feature-flag", true); Span span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan(); try (final @NotNull Scope spanScope = span.makeCurrent()) { Sentry.logger().warn("warn Sentry logging"); diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 2488cc87d11..6b5104c5eec 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,5 +1,6 @@ package io.sentry.systemtest +import io.sentry.protocol.FeatureFlag import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals @@ -31,8 +32,17 @@ class PersonSystemTest { } testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") + testHelper.doesTransactionHave( + transaction, + op = "http.server", + featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughOtelApi", + featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") } Thread.sleep(10000) diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonController.java index ff545349591..c65c9040d5c 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonController.java @@ -23,6 +23,7 @@ public PersonController(PersonService personService) { @GetMapping("{id}") Person person(@PathVariable Long id) { + Sentry.addFeatureFlag("transaction-feature-flag", true); ISpan currentSpan = Sentry.getSpan(); ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); try { diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 03a0abbdf2f..dde1eaa9938 100644 --- a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,5 +1,6 @@ package io.sentry.systemtest +import io.sentry.protocol.FeatureFlag import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals @@ -31,7 +32,16 @@ class PersonSystemTest { } testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHaveOp(transaction, "http.server") + testHelper.doesTransactionHave( + transaction, + op = "http.server", + featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughSentryApi", + featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), + ) } Thread.sleep(10000) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java index 26784880a75..06e4bb963b0 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java @@ -31,12 +31,13 @@ public PersonController(PersonService personService, Tracer tracer) { @GetMapping("{id}") @WithSpan("personSpanThroughOtelAnnotation") Person person(@PathVariable Long id) { + Sentry.addFeatureFlag("outer-feature-flag", true); Span span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan(); try (final @NotNull Scope spanScope = span.makeCurrent()) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); - Sentry.addFeatureFlag("my-feature-flag", true); + Sentry.addFeatureFlag("inner-feature-flag", true); ISpan currentSpan = Sentry.getSpan(); ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); try { diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 2488cc87d11..f277ff22025 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,5 +1,6 @@ package io.sentry.systemtest +import io.sentry.protocol.FeatureFlag import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals @@ -22,17 +23,31 @@ class PersonSystemTest { testHelper.ensureErrorReceived { event -> event.message?.formatted == "Trying person with id=1" && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + testHelper.doesEventHaveFlag(event, "inner-feature-flag", true) } testHelper.ensureErrorReceived { event -> testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + testHelper.doesEventHaveFlag(event, "inner-feature-flag", true) } testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") + testHelper.doesTransactionHave(transaction, op = "http.server") && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "personSpanThroughOtelAnnotation", + featureFlag = FeatureFlag("flag.evaluation.outer-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughOtelApi", + featureFlag = FeatureFlag("flag.evaluation.inner-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughSentryApi", + noFeatureFlags = true, + ) } Thread.sleep(10000) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java index 1880799c28e..b17809eb68d 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java @@ -29,6 +29,7 @@ public PersonController(PersonService personService, Tracer tracer) { @GetMapping("{id}") Person person(@PathVariable Long id) { + Sentry.addFeatureFlag("transaction-feature-flag", true); Span span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan(); try (final @NotNull Scope spanScope = span.makeCurrent()) { Sentry.logger().warn("warn Sentry logging"); diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 2488cc87d11..6b5104c5eec 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,5 +1,6 @@ package io.sentry.systemtest +import io.sentry.protocol.FeatureFlag import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals @@ -31,8 +32,17 @@ class PersonSystemTest { } testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") + testHelper.doesTransactionHave( + transaction, + op = "http.server", + featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughOtelApi", + featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") } Thread.sleep(10000) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 1a7df48ea8f..dde1eaa9938 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -32,8 +32,16 @@ class PersonSystemTest { } testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHave(transaction, op = "http.server", featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true)) - && testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi", featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true)) + testHelper.doesTransactionHave( + transaction, + op = "http.server", + featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughSentryApi", + featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), + ) } Thread.sleep(10000) diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/PersonController.java b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/PersonController.java index e2574e788c4..2f7aa4e03c8 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot/PersonController.java @@ -31,12 +31,13 @@ public PersonController(PersonService personService, Tracer tracer) { @GetMapping("{id}") @WithSpan("personSpanThroughOtelAnnotation") Person person(@PathVariable Long id) { + Sentry.addFeatureFlag("outer-feature-flag", true); Span span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan(); try (final @NotNull Scope spanScope = span.makeCurrent()) { Sentry.logger().warn("warn Sentry logging"); Sentry.logger().error("error Sentry logging"); Sentry.logger().info("hello %s %s", "there", "world!"); - Sentry.addFeatureFlag("my-feature-flag", true); + Sentry.addFeatureFlag("inner-feature-flag", true); ISpan currentSpan = Sentry.getSpan(); ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); try { diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 2488cc87d11..f277ff22025 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,5 +1,6 @@ package io.sentry.systemtest +import io.sentry.protocol.FeatureFlag import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals @@ -22,17 +23,31 @@ class PersonSystemTest { testHelper.ensureErrorReceived { event -> event.message?.formatted == "Trying person with id=1" && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + testHelper.doesEventHaveFlag(event, "inner-feature-flag", true) } testHelper.ensureErrorReceived { event -> testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + testHelper.doesEventHaveFlag(event, "inner-feature-flag", true) } testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") + testHelper.doesTransactionHave(transaction, op = "http.server") && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "personSpanThroughOtelAnnotation", + featureFlag = FeatureFlag("flag.evaluation.outer-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughOtelApi", + featureFlag = FeatureFlag("flag.evaluation.inner-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughSentryApi", + noFeatureFlags = true, + ) } Thread.sleep(10000) diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/PersonController.java b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/PersonController.java index 6c6209403b2..a778339280c 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/PersonController.java +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/main/java/io/sentry/samples/spring/boot/PersonController.java @@ -29,6 +29,7 @@ public PersonController(PersonService personService, Tracer tracer) { @GetMapping("{id}") Person person(@PathVariable Long id) { + Sentry.addFeatureFlag("transaction-feature-flag", true); Span span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan(); try (final @NotNull Scope spanScope = span.makeCurrent()) { Sentry.logger().warn("warn Sentry logging"); diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 2488cc87d11..6b5104c5eec 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -1,5 +1,6 @@ package io.sentry.systemtest +import io.sentry.protocol.FeatureFlag import io.sentry.systemtest.util.TestHelper import kotlin.test.Test import kotlin.test.assertEquals @@ -31,8 +32,17 @@ class PersonSystemTest { } testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && - testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") + testHelper.doesTransactionHave( + transaction, + op = "http.server", + featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughOtelApi", + featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") } Thread.sleep(10000) diff --git a/sentry-system-test-support/api/sentry-system-test-support.api b/sentry-system-test-support/api/sentry-system-test-support.api index b2a159f92eb..d8fa7efe13f 100644 --- a/sentry-system-test-support/api/sentry-system-test-support.api +++ b/sentry-system-test-support/api/sentry-system-test-support.api @@ -557,7 +557,11 @@ public final class io/sentry/systemtest/util/TestHelper { public final fun doesTransactionContainSpanWithDescription (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;)Z public final fun doesTransactionContainSpanWithOp (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;)Z public final fun doesTransactionContainSpanWithOpAndDescription (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;Ljava/lang/String;)Z + public final fun doesTransactionHave (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;Lio/sentry/protocol/FeatureFlag;)Z + public static synthetic fun doesTransactionHave$default (Lio/sentry/systemtest/util/TestHelper;Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;Lio/sentry/protocol/FeatureFlag;ILjava/lang/Object;)Z public final fun doesTransactionHaveOp (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;)Z + public final fun doesTransactionHaveSpanWith (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;Lio/sentry/protocol/FeatureFlag;Z)Z + public static synthetic fun doesTransactionHaveSpanWith$default (Lio/sentry/systemtest/util/TestHelper;Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;Lio/sentry/protocol/FeatureFlag;ZILjava/lang/Object;)Z public final fun doesTransactionHaveTraceId (Lio/sentry/protocol/SentryTransaction;Ljava/lang/String;)Z public final fun ensureEnvelopeCountIncreased ()V public final fun ensureEnvelopeReceived (ILkotlin/jvm/functions/Function1;)V diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt index 3b83853b920..5a334ba2432 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -276,7 +276,11 @@ class TestHelper(backendUrl: String) { return true } - fun doesTransactionHave(transaction: SentryTransaction, op: String, featureFlag: FeatureFlag? = null): Boolean { + fun doesTransactionHave( + transaction: SentryTransaction, + op: String, + featureFlag: FeatureFlag? = null, + ): Boolean { val matches = transaction.contexts.trace?.operation == op if (!matches) { println("Unable to find transaction with op $op:") @@ -299,7 +303,12 @@ class TestHelper(backendUrl: String) { return true } - fun doesTransactionHaveSpanWith(transaction: SentryTransaction, op: String, featureFlag: FeatureFlag? = null): Boolean { + fun doesTransactionHaveSpanWith( + transaction: SentryTransaction, + op: String, + featureFlag: FeatureFlag? = null, + noFeatureFlags: Boolean = false, + ): Boolean { val foundSpan = transaction.spans.firstOrNull { span -> span.op == op } if (foundSpan == null) { println("Unable to find span with op $op:") @@ -307,6 +316,14 @@ class TestHelper(backendUrl: String) { return false } + val featureFlagNames = + foundSpan.data?.keys?.filter { it.startsWith("flag.evaluation.") } ?: emptyList() + if (noFeatureFlags && featureFlagNames.isNotEmpty()) { + println("Expected 0 feature flags but found ${featureFlagNames}:") + logObject(transaction) + return false + } + val foundFlag = foundSpan.data?.get(featureFlag?.flag) if (featureFlag != null && foundFlag == null) { println("Unable to find span with feature flag ${featureFlag?.flag}:") diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fbb44ec1f4c..db1bd7c0f4c 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4005,7 +4005,6 @@ public final class io/sentry/Span : io/sentry/ISpan { public fun getData ()Ljava/util/Map; public fun getData (Ljava/lang/String;)Ljava/lang/Object; public fun getDescription ()Ljava/lang/String; - public fun getFeatureFlagBuffer ()Lio/sentry/featureflags/IFeatureFlagBuffer; public fun getFinishDate ()Lio/sentry/SentryDate; public fun getMeasurements ()Ljava/util/Map; public fun getOperation ()Ljava/lang/String; @@ -4051,6 +4050,7 @@ public class io/sentry/SpanContext : io/sentry/JsonSerializable, io/sentry/JsonU protected field baggage Lio/sentry/Baggage; protected field data Ljava/util/Map; protected field description Ljava/lang/String; + protected field featureFlags Lio/sentry/featureflags/IFeatureFlagBuffer; protected field op Ljava/lang/String; protected field origin Ljava/lang/String; protected field status Lio/sentry/SpanStatus; @@ -4060,11 +4060,13 @@ public class io/sentry/SpanContext : io/sentry/JsonSerializable, io/sentry/JsonU public fun (Lio/sentry/protocol/SentryId;Lio/sentry/SpanId;Ljava/lang/String;Lio/sentry/SpanId;Lio/sentry/TracesSamplingDecision;)V public fun (Ljava/lang/String;)V public fun (Ljava/lang/String;Lio/sentry/TracesSamplingDecision;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun copyForChild (Ljava/lang/String;Lio/sentry/SpanId;Lio/sentry/SpanId;)Lio/sentry/SpanContext; public fun equals (Ljava/lang/Object;)Z public fun getBaggage ()Lio/sentry/Baggage; public fun getData ()Ljava/util/Map; public fun getDescription ()Ljava/lang/String; + public fun getFeatureFlagBuffer ()Lio/sentry/featureflags/IFeatureFlagBuffer; public fun getInstrumenter ()Lio/sentry/Instrumenter; public fun getOperation ()Ljava/lang/String; public fun getOrigin ()Ljava/lang/String; @@ -5493,6 +5495,7 @@ public final class io/sentry/protocol/Device$JsonKeys { } public final class io/sentry/protocol/FeatureFlag : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field DATA_PREFIX Ljava/lang/String; public fun (Ljava/lang/String;Z)V public fun equals (Ljava/lang/Object;)Z public fun getFlag ()Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/Span.java b/sentry/src/main/java/io/sentry/Span.java index f6f773762e6..59a925d7a0a 100644 --- a/sentry/src/main/java/io/sentry/Span.java +++ b/sentry/src/main/java/io/sentry/Span.java @@ -1,7 +1,5 @@ package io.sentry; -import io.sentry.featureflags.IFeatureFlagBuffer; -import io.sentry.featureflags.SpanFeatureFlagBuffer; import io.sentry.protocol.Contexts; import io.sentry.protocol.MeasurementValue; import io.sentry.protocol.SentryId; diff --git a/sentry/src/main/java/io/sentry/SpanContext.java b/sentry/src/main/java/io/sentry/SpanContext.java index a08332f56f2..19ab2c3ad87 100644 --- a/sentry/src/main/java/io/sentry/SpanContext.java +++ b/sentry/src/main/java/io/sentry/SpanContext.java @@ -1,7 +1,6 @@ package io.sentry; import com.jakewharton.nopen.annotation.Open; - import io.sentry.featureflags.IFeatureFlagBuffer; import io.sentry.featureflags.SpanFeatureFlagBuffer; import io.sentry.protocol.SentryId; diff --git a/sentry/src/main/java/io/sentry/protocol/FeatureFlag.java b/sentry/src/main/java/io/sentry/protocol/FeatureFlag.java index ebb2e58cd3d..dc9148042c5 100644 --- a/sentry/src/main/java/io/sentry/protocol/FeatureFlag.java +++ b/sentry/src/main/java/io/sentry/protocol/FeatureFlag.java @@ -17,6 +17,8 @@ public final class FeatureFlag implements JsonUnknown, JsonSerializable { + public static final @NotNull String DATA_PREFIX = "flag.evaluation."; + /** Name of the feature flag. */ private @NotNull String flag; diff --git a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java index d53ed820558..bd0b5c7470b 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java +++ b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java @@ -74,11 +74,12 @@ public SentrySpan(final @NotNull Span span, final @Nullable Map // we lose precision here, from potential nanosecond precision down to 10 microsecond precision this.startTimestamp = DateUtils.nanosToSeconds(span.getStartDate().nanoTimestamp()); this.data = data; - final @NotNull IFeatureFlagBuffer featureFlagBuffer = span.getSpanContext().getFeatureFlagBuffer(); + final @NotNull IFeatureFlagBuffer featureFlagBuffer = + span.getSpanContext().getFeatureFlagBuffer(); final @Nullable FeatureFlags featureFlags = featureFlagBuffer.getFeatureFlags(); if (featureFlags != null && data != null) { for (FeatureFlag featureFlag : featureFlags.getValues()) { - data.put("flag.evaluation." + featureFlag.getFlag(), featureFlag.getResult()); + data.put(FeatureFlag.DATA_PREFIX + featureFlag.getFlag(), featureFlag.getResult()); } } } diff --git a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java index 86bda259c31..ab6ff10a4d8 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java @@ -104,7 +104,8 @@ public SentryTransaction(final @NotNull SentryTracer sentryTracer) { final @Nullable FeatureFlags featureFlags = featureFlagBuffer.getFeatureFlags(); if (featureFlags != null) { for (FeatureFlag featureFlag : featureFlags.getValues()) { - tracerContextToSend.setData("flag.evaluation." + featureFlag.getFlag(), featureFlag.getResult()); + tracerContextToSend.setData( + FeatureFlag.DATA_PREFIX + featureFlag.getFlag(), featureFlag.getResult()); } } From 4ea930e0703d1e28042e40f8a53dd8bbd07bc5d0 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Fri, 24 Oct 2025 09:08:47 +0000 Subject: [PATCH 18/30] Format code --- .../test/kotlin/io/sentry/systemtest/PersonSystemTest.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index d349709b412..73905c87409 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -27,7 +27,11 @@ class PersonSystemTest { } testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHave(transaction, op = "http.server", featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true)) + testHelper.doesTransactionHave( + transaction, + op = "http.server", + featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), + ) } Thread.sleep(10000) From e4e48db072f2fccda8e38c1c0e818a5e6e383dfc Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 24 Oct 2025 11:45:57 +0200 Subject: [PATCH 19/30] test feature flag serialization from SentryTracer --- sentry/src/test/java/io/sentry/JsonSerializerTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index ba8b5507abb..73d2d1b13db 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -1174,7 +1174,9 @@ class JsonSerializerTest { trace.data["dataKey"] = "dataValue" val tracer = SentryTracer(trace, fixture.scopes) tracer.setData("dataKey", "dataValue") + tracer.addFeatureFlag("transaction-feature-flag", true) val span = tracer.startChild("child") + span.addFeatureFlag("span-feature-flag", false) span.finish(SpanStatus.OK) tracer.finish() @@ -1200,9 +1202,11 @@ class JsonSerializerTest { assertNotNull("ok", jsonSpan["status"] as String) assertNotNull(jsonSpan["timestamp"]) assertNotNull(jsonSpan["start_timestamp"]) + assertFalse((jsonSpan["data"] as Map<*, *>)["flag.evaluation.span-feature-flag"] as Boolean) val jsonTrace = (element["contexts"] as Map<*, *>)["trace"] as Map<*, *> assertEquals("dataValue", (jsonTrace["data"] as Map<*, *>)["dataKey"] as String) + assertTrue((jsonTrace["data"] as Map<*, *>)["flag.evaluation.transaction-feature-flag"] as Boolean) assertNotNull(jsonTrace["trace_id"] as String) assertNotNull(jsonTrace["span_id"] as String) assertNotNull(jsonTrace["data"] as Map<*, *>) { assertEquals("dataValue", it["dataKey"]) } From d0649533e431f24c880d4c40dd9ad597d1f8ff21 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Fri, 24 Oct 2025 09:58:22 +0000 Subject: [PATCH 20/30] Format code --- sentry/src/test/java/io/sentry/JsonSerializerTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index 73d2d1b13db..f9308a9123a 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -1206,7 +1206,9 @@ class JsonSerializerTest { val jsonTrace = (element["contexts"] as Map<*, *>)["trace"] as Map<*, *> assertEquals("dataValue", (jsonTrace["data"] as Map<*, *>)["dataKey"] as String) - assertTrue((jsonTrace["data"] as Map<*, *>)["flag.evaluation.transaction-feature-flag"] as Boolean) + assertTrue( + (jsonTrace["data"] as Map<*, *>)["flag.evaluation.transaction-feature-flag"] as Boolean + ) assertNotNull(jsonTrace["trace_id"] as String) assertNotNull(jsonTrace["span_id"] as String) assertNotNull(jsonTrace["data"] as Map<*, *>) { assertEquals("dataValue", it["dataKey"]) } From 96f057bbb1150af8ae7911a42ec1123b46d7867b Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 24 Oct 2025 12:10:33 +0200 Subject: [PATCH 21/30] fix e2e test assertions; handle null data map --- .../test/kotlin/io/sentry/systemtest/PersonSystemTest.kt | 2 +- .../test/kotlin/io/sentry/systemtest/PersonSystemTest.kt | 2 +- .../test/kotlin/io/sentry/systemtest/PersonSystemTest.kt | 2 +- sentry/src/main/java/io/sentry/protocol/SentrySpan.java | 7 +++++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 6b5104c5eec..4ce3e15c899 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -41,7 +41,7 @@ class PersonSystemTest { transaction, op = "spanCreatedThroughOtelApi", featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), - ) + ) && testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 6b5104c5eec..4ce3e15c899 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -41,7 +41,7 @@ class PersonSystemTest { transaction, op = "spanCreatedThroughOtelApi", featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), - ) + ) && testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 6b5104c5eec..4ce3e15c899 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -41,7 +41,7 @@ class PersonSystemTest { transaction, op = "spanCreatedThroughOtelApi", featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), - ) + ) && testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") } diff --git a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java index bd0b5c7470b..6274c8b00d7 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java +++ b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java @@ -77,9 +77,12 @@ public SentrySpan(final @NotNull Span span, final @Nullable Map final @NotNull IFeatureFlagBuffer featureFlagBuffer = span.getSpanContext().getFeatureFlagBuffer(); final @Nullable FeatureFlags featureFlags = featureFlagBuffer.getFeatureFlags(); - if (featureFlags != null && data != null) { + if (featureFlags != null) { + if (this.data == null) { + this.data = new HashMap<>(); + } for (FeatureFlag featureFlag : featureFlags.getValues()) { - data.put(FeatureFlag.DATA_PREFIX + featureFlag.getFlag(), featureFlag.getResult()); + this.data.put(FeatureFlag.DATA_PREFIX + featureFlag.getFlag(), featureFlag.getResult()); } } } From 6e66e07619831a806d3642fbdce016e5ce6a87f6 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Fri, 24 Oct 2025 10:22:50 +0000 Subject: [PATCH 22/30] Format code --- .../src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt | 2 +- .../src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt | 2 +- .../src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 4ce3e15c899..6a4a48cde3d 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -42,7 +42,7 @@ class PersonSystemTest { op = "spanCreatedThroughOtelApi", featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), ) && - testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") + testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") } Thread.sleep(10000) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 4ce3e15c899..6a4a48cde3d 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -42,7 +42,7 @@ class PersonSystemTest { op = "spanCreatedThroughOtelApi", featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), ) && - testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") + testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") } Thread.sleep(10000) diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 4ce3e15c899..6a4a48cde3d 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -42,7 +42,7 @@ class PersonSystemTest { op = "spanCreatedThroughOtelApi", featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), ) && - testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") + testHelper.doesTransactionHaveSpanWith(transaction, op = "spanCreatedThroughSentryApi") } Thread.sleep(10000) From 39dbf57fb16fc3215299d5c07cad857054439c6f Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 24 Oct 2025 12:28:05 +0200 Subject: [PATCH 23/30] changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd989eafd3..fc1029d233f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ ``` +- Add Feature Flags ([#4812](https://github.com/getsentry/sentry-java/pull/4812)) and ([#4831](https://github.com/getsentry/sentry-java/pull/4831)) + - You may use top level API (`Sentry.addFeatureFlag`) to add feature flag evaluations to both scope and the current active span + - Or you may directly add feature flag evaluations to any scope, transaction or span ### Fixes From 15544cd5d42a0f66c3cc509e1acd5f593e73d69e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 6 Nov 2025 09:56:30 +0100 Subject: [PATCH 24/30] changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c55dcc5d73..fd6f350ad0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Features + +- Add feature flags API ([#4812](https://github.com/getsentry/sentry-java/pull/4812)) + - You may now keep track of your feature flag evaluations and have them show up in Sentry. + - You may use top level API (`Sentry.addFeatureFlag("my-feature-flag", true);`) or `IScope` and `IScopes` API + - Feature flag evaluations tracked on scope(s) will be added to any errors reported to Sentry. + - The SDK keeps the latest 100 evaluations from scope(s), replacing old entries as new evaluations are added. + ### Fixes - Removed SentryExecutorService limit for delayed scheduled tasks ([#4846](https://github.com/getsentry/sentry-java/pull/4846)) From 9ac80b5548afec29c0c13384e4463c8fe9194de0 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 6 Nov 2025 09:56:37 +0100 Subject: [PATCH 25/30] nullable getFeatureFlags --- .../src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index 5baa83fda07..46c2e674703 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -60,7 +60,7 @@ public void add(final @Nullable String flag, final @Nullable Boolean result) { } @Override - public @NotNull FeatureFlags getFeatureFlags() { + public @Nullable FeatureFlags getFeatureFlags() { List featureFlags = new ArrayList<>(); for (FeatureFlagEntry entry : flags) { featureFlags.add(entry.toFeatureFlag()); From 7a1bc743f663d03d4094c1de7d95c7be36bef36c Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 6 Nov 2025 10:11:46 +0100 Subject: [PATCH 26/30] update changelog --- CHANGELOG.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7ef0ee91e..38b0feabc76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,15 @@ ### Features -- Add feature flags API ([#4812](https://github.com/getsentry/sentry-java/pull/4812)) +- Add feature flags API ([#4812](https://github.com/getsentry/sentry-java/pull/4812)) and ([#4831](https://github.com/getsentry/sentry-java/pull/4831)) - You may now keep track of your feature flag evaluations and have them show up in Sentry. - - You may use top level API (`Sentry.addFeatureFlag("my-feature-flag", true);`) or `IScope` and `IScopes` API + - Top level API (`Sentry.addFeatureFlag("my-feature-flag", true);`) writes to scopes and the current span (if there is one) + - It is also possible to use API on `IScope`, `IScopes`, `ISpan` and `ITransaction` directly - Feature flag evaluations tracked on scope(s) will be added to any errors reported to Sentry. - - The SDK keeps the latest 100 evaluations from scope(s), replacing old entries as new evaluations are added. + - The SDK keeps the latest 100 evaluations from scope(s), replacing old entries as new evaluations are added. + - For feature flag evaluations tracked on spans: + - Only 10 evaluations are tracked per span, existing flags are updated but new ones exceeding the limit are ignored + - Spans do not inherit evaluations from their parent ### Fixes From 36be3b29446449d1af377c19f09998699e80be41 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 11 Nov 2025 14:16:44 +0100 Subject: [PATCH 27/30] address pr review feedback --- .../featureflags/FeatureFlagBuffer.java | 2 +- .../featureflags/SpanFeatureFlagBuffer.java | 5 ++++ .../featureflags/SpanFeatureFlagBufferTest.kt | 28 +++++++++---------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index 8d9a131c788..f38d0b6db52 100644 --- a/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java @@ -79,7 +79,7 @@ public void add(final @Nullable String flag, final @Nullable Boolean result) { } @Override - public IFeatureFlagBuffer clone() { + public @NotNull IFeatureFlagBuffer clone() { return new FeatureFlagBuffer(this); } diff --git a/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java index a9c8188869f..102e61414d6 100644 --- a/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java @@ -63,6 +63,11 @@ public void add(final @Nullable String flag, final @Nullable Boolean result) { } } + /** + * Does not really clone but instead return a new empty instance. + * + * @return a new empty instance + */ @Override public @NotNull IFeatureFlagBuffer clone() { return create(); diff --git a/sentry/src/test/java/io/sentry/featureflags/SpanFeatureFlagBufferTest.kt b/sentry/src/test/java/io/sentry/featureflags/SpanFeatureFlagBufferTest.kt index 6b3f9c4d162..07c3feaf5c9 100644 --- a/sentry/src/test/java/io/sentry/featureflags/SpanFeatureFlagBufferTest.kt +++ b/sentry/src/test/java/io/sentry/featureflags/SpanFeatureFlagBufferTest.kt @@ -85,29 +85,29 @@ class SpanFeatureFlagBufferTest { @Test fun `maintains insertion order`() { val buffer = SpanFeatureFlagBuffer.create() - buffer.add("first", true) - buffer.add("second", false) - buffer.add("third", true) + buffer.add("uno", true) + buffer.add("due", false) + buffer.add("tre", true) val featureFlags = buffer.featureFlags assertNotNull(featureFlags) val featureFlagValues = featureFlags.values assertEquals(3, featureFlagValues.size) - assertEquals("first", featureFlagValues[0]!!.flag) - assertEquals("second", featureFlagValues[1]!!.flag) - assertEquals("third", featureFlagValues[2]!!.flag) + assertEquals("uno", featureFlagValues[0]!!.flag) + assertEquals("due", featureFlagValues[1]!!.flag) + assertEquals("tre", featureFlagValues[2]!!.flag) } @Test fun `updating existing flag maintains its position`() { val buffer = SpanFeatureFlagBuffer.create() - buffer.add("first", true) - buffer.add("second", false) - buffer.add("third", true) + buffer.add("uno", true) + buffer.add("due", false) + buffer.add("tre", true) - // Update the first flag - buffer.add("first", false) + // Update the uno flag + buffer.add("uno", false) val featureFlags = buffer.featureFlags assertNotNull(featureFlags) @@ -115,9 +115,9 @@ class SpanFeatureFlagBufferTest { assertEquals(3, featureFlagValues.size) // Order should remain the same - assertEquals("first", featureFlagValues[0]!!.flag) + assertEquals("uno", featureFlagValues[0]!!.flag) assertFalse(featureFlagValues[0]!!.result) // Value updated - assertEquals("second", featureFlagValues[1]!!.flag) - assertEquals("third", featureFlagValues[2]!!.flag) + assertEquals("due", featureFlagValues[1]!!.flag) + assertEquals("tre", featureFlagValues[2]!!.flag) } } From d7e80f327a4f5bf734c2b1b3aae744f31bccdee6 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 11 Nov 2025 13:20:06 +0000 Subject: [PATCH 28/30] Format code --- .../main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java index 102e61414d6..2afa45d38d1 100644 --- a/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java +++ b/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java @@ -65,7 +65,7 @@ public void add(final @Nullable String flag, final @Nullable Boolean result) { /** * Does not really clone but instead return a new empty instance. - * + * * @return a new empty instance */ @Override From f54a1d9311fbebac7294fde1c1e4e00b839139e0 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 11 Nov 2025 15:29:20 +0100 Subject: [PATCH 29/30] fix(otel): Copy active span on scope clone (#4878) * Copy active span when cloning scope * changelog --- CHANGELOG.md | 1 + sentry/src/main/java/io/sentry/Scope.java | 1 + sentry/src/test/java/io/sentry/ScopeTest.kt | 16 ++++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c30eb073e9d..dcdc3870eea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Fix log count in client reports ([#4869](https://github.com/getsentry/sentry-java/pull/4869)) - Fix profilerId propagation ([#4833](https://github.com/getsentry/sentry-java/pull/4833)) - Fix profiling init for Spring and Spring Boot w Agent auto-init ([#4815](https://github.com/getsentry/sentry-java/pull/4815)) +- Copy active span on scope clone ([#4878](https://github.com/getsentry/sentry-java/pull/4878)) ### Improvements diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index eb17420dd24..5fc82a648d3 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -124,6 +124,7 @@ public Scope(final @NotNull SentryOptions options) { private Scope(final @NotNull Scope scope) { this.transaction = scope.transaction; this.transactionName = scope.transactionName; + this.activeSpan = scope.activeSpan; this.session = scope.session; this.options = scope.options; this.level = scope.level; diff --git a/sentry/src/test/java/io/sentry/ScopeTest.kt b/sentry/src/test/java/io/sentry/ScopeTest.kt index 42c18049e04..bccc7d602ab 100644 --- a/sentry/src/test/java/io/sentry/ScopeTest.kt +++ b/sentry/src/test/java/io/sentry/ScopeTest.kt @@ -12,6 +12,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNotSame import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue import org.junit.Assert.assertArrayEquals import org.mockito.kotlin.any @@ -155,6 +156,21 @@ class ScopeTest { assertEquals(attachment.contentType, actual.contentType) } + @Test + fun `copying scope copies active span`() { + val scope = Scope(SentryOptions()) + + val transaction = + SentryTracer(TransactionContext("transaction-name", "op"), NoOpScopes.getInstance()) + val span = transaction.startChild("child1") + + scope.setActiveSpan(span) + + val clone = scope.clone() + + assertSame(span, clone.span) + } + @Test fun `copying scope and changing the original values wont change the clone values`() { val scope = Scope(SentryOptions()) From f07c6d1d559f707f01e88becc07d06c41441a983 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 11 Nov 2025 15:36:10 +0100 Subject: [PATCH 30/30] api --- sentry/api/sentry.api | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8b506f86ad7..9c5438fac2a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1086,6 +1086,7 @@ public abstract interface class io/sentry/ISocketTagger { } public abstract interface class io/sentry/ISpan { + public abstract fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public abstract fun finish ()V public abstract fun finish (Lio/sentry/SpanStatus;)V public abstract fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -1781,6 +1782,7 @@ public final class io/sentry/NoOpSocketTagger : io/sentry/ISocketTagger { } public final class io/sentry/NoOpSpan : io/sentry/ISpan { + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -1828,6 +1830,7 @@ public final class io/sentry/NoOpSpanFactory : io/sentry/ISpanFactory { } public final class io/sentry/NoOpTransaction : io/sentry/ITransaction { + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -3859,6 +3862,7 @@ public final class io/sentry/SentryTraceHeader { public final class io/sentry/SentryTracer : io/sentry/ITransaction { public fun (Lio/sentry/TransactionContext;Lio/sentry/IScopes;)V public fun (Lio/sentry/TransactionContext;Lio/sentry/IScopes;Lio/sentry/TransactionOptions;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -3999,6 +4003,7 @@ public final class io/sentry/ShutdownHookIntegration : io/sentry/Integration, ja public final class io/sentry/Span : io/sentry/ISpan { public fun (Lio/sentry/TransactionContext;Lio/sentry/SentryTracer;Lio/sentry/IScopes;Lio/sentry/SpanOptions;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun finish ()V public fun finish (Lio/sentry/SpanStatus;)V public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V @@ -4051,6 +4056,7 @@ public class io/sentry/SpanContext : io/sentry/JsonSerializable, io/sentry/JsonU protected field baggage Lio/sentry/Baggage; protected field data Ljava/util/Map; protected field description Ljava/lang/String; + protected field featureFlags Lio/sentry/featureflags/IFeatureFlagBuffer; protected field op Ljava/lang/String; protected field origin Ljava/lang/String; protected field status Lio/sentry/SpanStatus; @@ -4060,11 +4066,13 @@ public class io/sentry/SpanContext : io/sentry/JsonSerializable, io/sentry/JsonU public fun (Lio/sentry/protocol/SentryId;Lio/sentry/SpanId;Ljava/lang/String;Lio/sentry/SpanId;Lio/sentry/TracesSamplingDecision;)V public fun (Ljava/lang/String;)V public fun (Ljava/lang/String;Lio/sentry/TracesSamplingDecision;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V public fun copyForChild (Ljava/lang/String;Lio/sentry/SpanId;Lio/sentry/SpanId;)Lio/sentry/SpanContext; public fun equals (Ljava/lang/Object;)Z public fun getBaggage ()Lio/sentry/Baggage; public fun getData ()Ljava/util/Map; public fun getDescription ()Ljava/lang/String; + public fun getFeatureFlagBuffer ()Lio/sentry/featureflags/IFeatureFlagBuffer; public fun getInstrumenter ()Lio/sentry/Instrumenter; public fun getOperation ()Ljava/lang/String; public fun getOrigin ()Ljava/lang/String; @@ -4766,6 +4774,14 @@ public final class io/sentry/featureflags/NoOpFeatureFlagBuffer : io/sentry/feat public static fun getInstance ()Lio/sentry/featureflags/NoOpFeatureFlagBuffer; } +public final class io/sentry/featureflags/SpanFeatureFlagBuffer : io/sentry/featureflags/IFeatureFlagBuffer { + public fun add (Ljava/lang/String;Ljava/lang/Boolean;)V + public fun clone ()Lio/sentry/featureflags/IFeatureFlagBuffer; + public synthetic fun clone ()Ljava/lang/Object; + public static fun create ()Lio/sentry/featureflags/IFeatureFlagBuffer; + public fun getFeatureFlags ()Lio/sentry/protocol/FeatureFlags; +} + public abstract interface class io/sentry/hints/AbnormalExit { public abstract fun ignoreCurrentThread ()Z public abstract fun mechanism ()Ljava/lang/String; @@ -5486,6 +5502,7 @@ public final class io/sentry/protocol/Device$JsonKeys { } public final class io/sentry/protocol/FeatureFlag : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field DATA_PREFIX Ljava/lang/String; public fun (Ljava/lang/String;Z)V public fun equals (Ljava/lang/Object;)Z public fun getFlag ()Ljava/lang/String;