Skip to content

Conversation

adinauer
Copy link
Member

@adinauer adinauer commented Oct 16, 2025

📜 Description

addFeatureFlag can be used to track feature flag evaluations and have them show up on errors in Sentry

💡 Motivation and Context

Scope based part of #4763

💚 How did you test it?

Manually + E2E tests + unit tests

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

🔮 Next steps

  • Test (de)serialization
  • Add maxFeatureFlags to external options (sentry.properties)
  • Add span based API
  • Make params on addFeatureFlag nullable

Copy link
Contributor

github-actions bot commented Oct 16, 2025

Fails
🚫 Please consider adding a changelog entry for the next release.
Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Instructions and example for changelog

Please add an entry to CHANGELOG.md to the "Unreleased" section. Make sure the entry includes this PR's number.

Example:

## Unreleased

### Features

- Add scope based feature flags ([#4812](https://github.com/getsentry/sentry-java/pull/4812))

If none of the above apply, you can opt out of this check by adding #skip-changelog to the PR description or adding a skip-changelog label.

Generated by 🚫 dangerJS against 4b757b8

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

Copy link
Contributor

github-actions bot commented Oct 16, 2025

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 349.64 ms 423.82 ms 74.18 ms
Size 1.58 MiB 2.12 MiB 549.53 KiB

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
b3d8889 420.46 ms 453.71 ms 33.26 ms
ee747ae 386.94 ms 431.43 ms 44.49 ms
ee747ae 400.46 ms 423.61 ms 23.15 ms
9fbb112 361.43 ms 427.57 ms 66.14 ms
cf708bd 408.35 ms 458.98 ms 50.63 ms
ee747ae 396.82 ms 441.67 ms 44.86 ms
cf708bd 434.73 ms 502.96 ms 68.22 ms
bdbe1f4 380.66 ms 464.44 ms 83.78 ms
806307f 357.85 ms 424.64 ms 66.79 ms
f634d01 375.06 ms 420.04 ms 44.98 ms

App size

Revision Plain With Sentry Diff
b3d8889 1.58 MiB 2.10 MiB 535.07 KiB
ee747ae 1.58 MiB 2.10 MiB 530.95 KiB
ee747ae 1.58 MiB 2.10 MiB 530.95 KiB
9fbb112 1.58 MiB 2.11 MiB 539.18 KiB
cf708bd 1.58 MiB 2.11 MiB 539.71 KiB
ee747ae 1.58 MiB 2.10 MiB 530.95 KiB
cf708bd 1.58 MiB 2.11 MiB 539.71 KiB
bdbe1f4 1.58 MiB 2.11 MiB 538.88 KiB
806307f 1.58 MiB 2.10 MiB 533.42 KiB
f634d01 1.58 MiB 2.10 MiB 533.40 KiB

Previous results on branch: feat/feature-flags-on-scope

Startup times

Revision Plain With Sentry Diff
0983aa0 369.83 ms 412.26 ms 42.43 ms

App size

Revision Plain With Sentry Diff
0983aa0 1.58 MiB 2.11 MiB 540.70 KiB

tmpList.remove(0);
}

flags = new CopyOnWriteArrayList<>(tmpList);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it need to be CopyOnWrite if we never actually call flags.add()?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted for CopyOnWriteArrayList due to it being a good choice for scope cloning without affecting parent scopes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotta think about whether using something else would work too, probably yes but what's the benefit?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be possible to replace with ArrayList but then we must never modify the collection. I was still hoping for some enlightenment on how to speed up the add method. I've written this down and depending on how the add method turns out, we can replace it.

We could in theory wrap it with Collections.unmodifyableList but I'd rather send corrupted data as opposed to crashing the customers application.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but then we must never modify the collection.

why is that? sorry for the questions I guess i'm missing some context 😅

Copy link
Member Author

@adinauer adinauer Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed in more detail internally.

We currently optimized feature flags storage for scope cloning performance (where CopyOnWriteArrayList simply re-uses the internal array, when cloning the list, which is O(1)).
Merging was our second priority.
Add is currently rather inefficient.

We wanna release as is and if customers request better performance, we can revisit and create an Android specific implementation, that prioritizes differently and/or optimizes for fewer allocations.

private @NotNull String flag;

/** Evaluation result of the feature flag. */
private boolean result;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flag evaluation is allowed to be an arbitrary JSON according to Relay https://github.com/getsentry/relay/blob/21c2e18ebe6f834a4ce4e149c6a43e4bec1e90f8/relay-event-schema/src/protocol/contexts/flags.rs#L30
However in docs and frontend this is typed as bool.
I think it will be easy to extend this with an overload that takes Object or we just change this to take it into account from the get go.
Let's also double check with @cmanallen on how to proceed here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relay is set up to accommodate remote-configs/feature-flags of any JSON type. The UI currently expects boolean though. I'm not sure how resilient it is to non-boolean types. The goal is to expand our UI to accommodate more types in the future but AFAIK that's not been realized yet. Something I can find out later today.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cmanallen can we release this as is with Boolean param exposed to customers?

Comment on lines 43 to 56
final int size = flags.size();
final @NotNull ArrayList<FeatureFlagEntry> 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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
final int size = flags.size();
final @NotNull ArrayList<FeatureFlagEntry> 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);
final int size = flags.size();
for (int i = 0; i < size; i++) {
if (flags.get(i).equals(flag)) {
flags.remove(i);
break;
}
}
flags.add(new FeatureFlagEntry(flag, result, System.nanoTime()));
if (flags.size() > maxSize) {
flags.remove(0);
}

Maybe this is better?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!
Yes, it's better, see #4812 (comment) and #4812 (comment)
This is called "Mutable CopyOnWriteArrayList" in the tables.

@adinauer
Copy link
Member Author

Table compares ops/s for add method of different implementations

Implementation Unique Duplicates Duplicates in Full Buffer
Immutable CopyOnWriteArrayList 74,071.034 363,250.814 29,828.637
Immutable ArrayList 67,329.692 425,473.814 26,404.218
Mutable CopyOnWriteArrayList 146,023.283 155,988.324 51,812.315
  • Immutable CopyOnWriteArrayList = current state in this PR, where we create a temporary ArrayList for creating the new state
            final int size = flags.size();
            final @NotNull ArrayList<FeatureFlagEntry> 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);
  • Immutable ArrayList = replaced CopyOnWriteArrayList with ArrayList which we replace in add
            final int size = flags.size();
            final @NotNull ArrayList<FeatureFlagEntry> 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 = tmpList;
  • Mutable CopyOnWriteArrayList = iterate flags, remove existing entry if found
            final int size = flags.size();
            for (int i = 0; i < size; i++) {
                if (flags.get(i).equals(flag)) {
                    flags.remove(i);
                    break;
                }
            }
            flags.add(new FeatureFlagEntry(flag, result, System.nanoTime()));

            if (flags.size() > maxSize) {
                flags.remove(0);
            }

Here's the code I used to benchmark:

  @Benchmark
  public void addUniqueFeatureFlag(Blackhole blackhole) throws InterruptedException {
    IFeatureFlagBuffer buffer = FeatureFlagBufferCOWAL.create(100);

    for (int i = 0; i < 100; i++) {
      buffer.add("unique_" + i, true);
    }

    blackhole.consume(buffer);
  }

  @Benchmark
  public void addNonUniqueFeatureFlag(Blackhole blackhole) throws InterruptedException {
    IFeatureFlagBuffer buffer = FeatureFlagBufferCOWAL.create(100);

    for (int i = 0; i < 100; i++) {
      buffer.add("unique_" + 1, true);
    }

    blackhole.consume(buffer);
  }

  @Benchmark
  public void addNonUniqueToFullFeatureFlagBuffer(Blackhole blackhole) throws InterruptedException {
    IFeatureFlagBuffer buffer = FeatureFlagBufferCOWAL.create(100);

    for (int i = 0; i < 100; i++) {
      buffer.add("unique_" + i, true);
    }

    for (int i = 0; i < 100; i++) {
      buffer.add("unique_" + 1, true);
    }

    blackhole.consume(buffer);
  }

@adinauer
Copy link
Member Author

adinauer commented Oct 20, 2025

Reran benchmarks for a (hopefully) more realistic scenario

NOTE: I just updated these after fixing a bug with the duplicate check

Implementation Realistic Usage
Immutable CopyOnWriteArrayList 100,522.600
Immutable ArrayList 89,760.930
Mutable CopyOnWriteArrayList 135,685.613
Mutable CopyOnWriteArrayList (reverse) 133,285.356
  @Benchmark
  public void realisticFeatureFlagUsage(Blackhole blackhole) throws InterruptedException {
    IFeatureFlagBuffer buffer = FeatureFlagBufferCOWAL.create(100);

    for (int i = 0; i < 50; i++) {
      buffer.add("flag_" + i, i % 2 == 0);
    }

    int[] duplicateIndices = {25, 0, 49, 12, 37, 5, 44, 18, 31, 2, 47, 8, 41, 15, 28, 22, 35, 10, 39, 20};
    for (int i = 0; i < 20; i++) {
      buffer.add("flag_" + duplicateIndices[i], true);
    }

    for (int i = 50; i < 70; i++) {
      buffer.add("flag_" + i, i % 2 == 0);
    }

    blackhole.consume(buffer);
  }

Added a new implementation which looks for the duplicate flag in reverse order:

            final int size = flags.size();
            for (int i = size - 1; i >= 0; i--) {
        		final @NotNull FeatureFlagEntry entry = flags.get(i);
        		if (entry.flag.equals(flag)) {
                    flags.remove(i);
                    break;
                }
            }
            flags.add(new FeatureFlagEntry(flag, result, System.nanoTime()));

            if (flags.size() > maxSize) {
                flags.remove(0);
            }

cursor[bot]

This comment was marked as outdated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants