Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
5b475d1
Add scope based feature flags
adinauer Oct 16, 2025
74cc427
fix equals
adinauer Oct 16, 2025
fb42140
add serialization tests
adinauer Oct 17, 2025
045337a
Create new reference on buffer clone
adinauer Oct 17, 2025
c1061ca
add comment explaining the merge method
adinauer Oct 17, 2025
74acb10
optimize merge method
adinauer Oct 17, 2025
eeae5c6
make flag and result nullable params
adinauer Oct 17, 2025
21806f2
handle empty/noop buffer on merge and add tests for it
adinauer Oct 17, 2025
95550ec
optimize add method
adinauer Oct 21, 2025
865ad5d
format; fix test
adinauer Oct 21, 2025
fa81bf8
Add rules for feature flags and mention in overview_dev
adinauer Oct 21, 2025
a5c7aef
Fix duplicate check
adinauer Oct 21, 2025
0ab241e
Update sentry/src/main/java/io/sentry/SentryOptions.java
adinauer Oct 21, 2025
4b757b8
Merge branch 'main' into feat/feature-flags-on-scope
adinauer Oct 21, 2025
ab1a146
Format code
getsentry-bot Oct 21, 2025
d320dd6
Merge branch 'feat/feature-flags-on-scope' into feat/feature-flags-rules
adinauer Oct 22, 2025
196b3b4
Add feature flags to spans
adinauer Oct 23, 2025
9194849
move feature flag buffer to span context; add e2e test
adinauer Oct 23, 2025
a4b6ca3
e2e tests; fix POTel feature flags
adinauer Oct 24, 2025
4ea930e
Format code
getsentry-bot Oct 24, 2025
e4e48db
test feature flag serialization from SentryTracer
adinauer Oct 24, 2025
d064953
Format code
getsentry-bot Oct 24, 2025
96f057b
fix e2e test assertions; handle null data map
adinauer Oct 24, 2025
6e66e07
Format code
getsentry-bot Oct 24, 2025
39dbf57
changelog
adinauer Oct 24, 2025
1cbda53
Merge branch 'main' into feat/feature-flags-on-scope
adinauer Nov 5, 2025
15544cd
changelog
adinauer Nov 6, 2025
9ac80b5
nullable getFeatureFlags
adinauer Nov 6, 2025
839d878
Merge branch 'main' into feat/feature-flags-on-scope
adinauer Nov 6, 2025
6041a82
Merge branch 'feat/feature-flags-on-scope' into feat/feature-flags-rules
adinauer Nov 6, 2025
7d8ba60
Merge branch 'feat/feature-flags-rules' into feat/feature-flags-on-spans
adinauer Nov 6, 2025
7a1bc74
update changelog
adinauer Nov 6, 2025
36be3b2
address pr review feedback
adinauer Nov 11, 2025
d7e80f3
Format code
getsentry-bot Nov 11, 2025
db8e32e
Merge branch 'main' into feat/feature-flags-on-spans
adinauer Nov 11, 2025
f54a1d9
fix(otel): Copy active span on scope clone (#4878)
adinauer Nov 11, 2025
e10c410
Merge branch 'main' into feat/feature-flags-on-spans
adinauer Nov 11, 2025
f07c6d1
api
adinauer Nov 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .cursor/rules/feature_flags.mdc
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .cursor/rules/overview_dev.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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-*`)
Expand Down Expand Up @@ -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`
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## Unreleased

### Features

- 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.
- 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.
- 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

- Removed SentryExecutorService limit for delayed scheduled tasks ([#4846](https://github.com/getsentry/sentry-java/pull/4846))
Expand Down Expand Up @@ -69,6 +81,9 @@
<meta-data android:name="io.sentry.session-replay.screenshot-strategy" android:value="canvas" />
</application>
```
- 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (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
Expand Down Expand Up @@ -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 <init> (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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public final class io/sentry/opentelemetry/OtelSpanUtils {

public final class io/sentry/opentelemetry/OtelSpanWrapper : io/sentry/opentelemetry/IOtelSpanWrapper {
public fun <init> (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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,11 @@ public Map<String, MeasurementValue> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, String> tags = sourceSpan.getTags();
for (Map.Entry<String, String> entry : tags.entrySet()) {
targetSpan.setTag(entry.getKey(), entry.getValue());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "]");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +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("inner-feature-flag", true);
ISpan currentSpan = Sentry.getSpan();
ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi");
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,9 +21,33 @@ class PersonSystemTest {
restClient.getPerson(1L)
assertEquals(500, restClient.lastKnownStatusCode)

testHelper.ensureErrorReceived { event ->
event.message?.formatted == "Trying person with id=1" &&
testHelper.doesEventHaveFlag(event, "inner-feature-flag", true)
}

testHelper.ensureErrorReceived { event ->
testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") &&
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ 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");
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 {
Expand Down
Loading
Loading