diff --git a/CHANGELOG.md b/CHANGELOG.md index 16d39ebed79..dcdc3870eea 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 @@ -20,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-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..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 @@ -505,6 +505,11 @@ public Map getMeasurements() { return scopes; } + @Override + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { + context.addFeatureFlag(flag, result); + } + @Override public @NotNull Context storeInContext(Context context) { final @Nullable ReadWriteSpan otelSpan = getSpan(); 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 c232287e7e1..680177f8451 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..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 @@ -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..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 @@ -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/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..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 @@ -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-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..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 @@ -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/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..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 @@ -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,11 @@ 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/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 00bfa743a39..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 @@ -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,69 @@ 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, + noFeatureFlags: Boolean = false, + ): 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 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}:") + 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/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; 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/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/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index bb89b3d748c..9729ac406b1 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -1023,6 +1023,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..59a925d7a0a 100644 --- a/sentry/src/main/java/io/sentry/Span.java +++ b/sentry/src/main/java/io/sentry/Span.java @@ -458,4 +458,9 @@ private List getDirectChildren() { public @NotNull ISentryLifecycleToken makeCurrent() { return NoOpScopesLifecycleToken.getInstance(); } + + @Override + public void addFeatureFlag(final @Nullable String flag, final @Nullable Boolean result) { + 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..19ab2c3ad87 100644 --- a/sentry/src/main/java/io/sentry/SpanContext.java +++ b/sentry/src/main/java/io/sentry/SpanContext.java @@ -1,6 +1,8 @@ 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 +57,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 +324,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/featureflags/FeatureFlagBuffer.java b/sentry/src/main/java/io/sentry/featureflags/FeatureFlagBuffer.java index 46c2e674703..f38d0b6db52 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 { @@ -69,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 new file mode 100644 index 00000000000..2afa45d38d1 --- /dev/null +++ b/sentry/src/main/java/io/sentry/featureflags/SpanFeatureFlagBuffer.java @@ -0,0 +1,79 @@ +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); + } + } + + /** + * Does not really clone but instead return a new empty instance. + * + * @return a new empty instance + */ + @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/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 8ab31beb835..6274c8b00d7 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,17 @@ 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 @Nullable FeatureFlags featureFlags = featureFlagBuffer.getFeatureFlags(); + if (featureFlags != null) { + if (this.data == null) { + this.data = new HashMap<>(); + } + for (FeatureFlag featureFlag : featureFlags.getValues()) { + this.data.put(FeatureFlag.DATA_PREFIX + featureFlag.getFlag(), featureFlag.getResult()); + } + } } @ApiStatus.Internal diff --git a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java index 2858fb3c27c..ab6ff10a4d8 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,15 @@ 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( + FeatureFlag.DATA_PREFIX + featureFlag.getFlag(), featureFlag.getResult()); + } + } + contexts.setTrace(tracerContextToSend); this.transactionInfo = new TransactionInfo(sentryTracer.getTransactionNameSource().apiName()); diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index ba8b5507abb..f9308a9123a 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,13 @@ 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"]) } 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()) 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..07c3feaf5c9 --- /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("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("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("uno", true) + buffer.add("due", false) + buffer.add("tre", true) + + // Update the uno flag + buffer.add("uno", false) + + val featureFlags = buffer.featureFlags + assertNotNull(featureFlags) + val featureFlagValues = featureFlags.values + assertEquals(3, featureFlagValues.size) + + // Order should remain the same + assertEquals("uno", featureFlagValues[0]!!.flag) + assertFalse(featureFlagValues[0]!!.result) // Value updated + assertEquals("due", featureFlagValues[1]!!.flag) + assertEquals("tre", featureFlagValues[2]!!.flag) + } +}