Skip to content

feat: Add feature flag checking for rust and python#92

Open
markstory wants to merge 17 commits intofeat-add-feature-schemafrom
feat-add-features
Open

feat: Add feature flag checking for rust and python#92
markstory wants to merge 17 commits intofeat-add-feature-schemafrom
feat-add-features

Conversation

@markstory
Copy link
Member

Continue work started in #91 to add methods for checking feature flags from both rust + python. I'd like to spend some more time expanding python tests to include more of the existing flagpole tests to ensure that flag checking behavior is consistent. I can do that separately as these changes are large already.

cursor[bot]

This comment was marked as outdated.

@markstory markstory requested a review from a team March 3, 2026 20:37
Comment on lines +660 to +693
fn test_feature_context_id_value_align_with_python() {
// Context.id() determines rollout rates with modulo
// This implementation should generate the same rollout slots
// as the previous implementation did.
let ctx = FeatureContext::new();
assert_eq!(ctx.id() % 100, 5, "should match with python implementation");

let mut ctx = FeatureContext::new();
ctx.insert("foo", "bar".into());
ctx.insert("baz", "barfoo".into());
ctx.identity_fields(vec!["foo"]);
assert_eq!(ctx.id() % 100, 62);

// Undefined fields should not contribute to the id.
let mut ctx = FeatureContext::new();
ctx.insert("foo", "bar".into());
ctx.insert("baz", "barfoo".into());
ctx.identity_fields(vec!["foo", "whoops"]);
assert_eq!(ctx.id() % 100, 62);

let mut ctx = FeatureContext::new();
ctx.insert("foo", "bar".into());
ctx.insert("baz", "barfoo".into());
ctx.identity_fields(vec!["foo", "baz"]);
assert_eq!(ctx.id() % 100, 1);

let mut ctx = FeatureContext::new();
ctx.insert("foo", "bar".into());
ctx.insert("baz", "barfoo".into());
// When there is no overlap with identity fields and data,
// all fields should be used
ctx.identity_fields(vec!["whoops", "nope"]);
assert_eq!(ctx.id() % 100, 1);
}
Copy link
Member Author

Choose a reason for hiding this comment

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

This test matches the attributes and rollout bucket assignments in getsentry/sentry#109822

pub struct FeatureContext {
data: HashMap<String, ContextValue>,
identity_fields: Vec<String>,
cached_id: Cell<Option<u64>>,
Copy link
Member

Choose a reason for hiding this comment

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

i assume we use Cell so we don't need to pass around &mut self? (asking for my own learning)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes it lets the outer struct be immutable, but the cached_id can be mutable. This also goes by interior mutability


/// A typed value that can be stored in a [`FeatureContext`].
#[derive(Debug, Clone)]
pub enum ContextValue {
Copy link
Member

Choose a reason for hiding this comment

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

this enum seems like a lot of code to hold a generic list of objects and perform an operation on them (to_id_string()), could we just reuse serde_json::Value?

the one downside is it'll make it a bit less object oriented:

  • (to_id_string()) will live on its own
  • we reuse serde_json::Value when it doesn't have anything to do with json

i got an LLM to preview what that would look like: #94

what do you think?

Copy link
Member Author

Choose a reason for hiding this comment

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

... could we just reuse serde_json::Value?

We could. That would be simpler 👍

if self.rollout >= 100 {
return true;
}
context.id() % 100 < self.rollout
Copy link
Member

@kenzoengineer kenzoengineer Mar 5, 2026

Choose a reason for hiding this comment

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

this seems to be <= in sentry src/flagpole/conditions.py https://github.com/getsentry/sentry/blob/master/src/flagpole/conditions.py#L251

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
context.id() % 100 < self.rollout
context.id() % 100 <= self.rollout

Copy link
Member

Choose a reason for hiding this comment

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

ah wait, i see you made this change in e5b80b3. I guess flagpole has always been wrong then? < would be mathematically correct as % 100 returns [0, 99] and 0 <= 0 would match the flag..

i guess this is an improvement then!

Copy link
Member Author

@markstory markstory Mar 5, 2026

Choose a reason for hiding this comment

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

Yes, the python implementation has an off-by one error. I thought this was a good time to fix that mistake. It will change rollout groups though which could pose a problem during the transition from the current implementation to this one.

@kenzoengineer
Copy link
Member

@markstory markstory force-pushed the feat-add-features branch from 9f7ae45 to 69c8bbc Compare March 6, 2026 14:59
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

@markstory markstory force-pushed the feat-add-features branch from a0f1a30 to 90c76dc Compare March 6, 2026 18:41
@markstory
Copy link
Member Author

I was curious about the memory impacts and runtime behavior of this implementation. I had claude convert all of our existing feature flags (290 of them) to sentry-options values format, and generate a schema namespace schema for them. I wrote up a small jank script that looks like

Details
import contextlib
import resource
import sentry_options
import time

@contextlib.contextmanager
def timer(name: str):
    start_mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    start = time.time()
    yield
    end = time.time()
    end_mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss

    duration = end - start
    print(f"> {name} : {duration:.8f}")
    mem_diff = end_mem - start_mem
    if mem_diff > 1_000_000:
        print(f"! memory increase: {name} : {mem_diff:.2f}")


def main():
    print("with sentry-options")
    with timer("init()"):
        sentry_options.init()

    ctx = sentry_options.FeatureContext(
        {
            "organization_id": 1,
            "organization_slug": "sentry",
            "user_email": "mark@sentry.io",
        },
        identity_fields=["organization_id"]
    )
    with timer("check single feature once"):
        assert sentry_options.features("flagpole").has("organizations:anomaly-detection-eap", ctx)

    with timer("check single feature 10000 times"):
        for i in range(10000):
            sentry_options.features("flagpole").has("organizations:anomaly-detection-eap", ctx)

if __name__ == "__main__":
    main()

Running this locally yielded:

with sentry-options
> init() : 2.23160505
! memory increase: init() : 30572544.00
> check single feature once : 0.00007677
> check single feature 10000 times : 0.13953114

The init() function will add ~2s to application startup time, but after that feature flag checks are quite efficient compared to the overhead of checking a feature today. I built a similar test for the existing flagpole implementation:

Details
import contextlib
import resource
import time

from sentry.runner import configure

configure()

from sentry import options
from sentry import features
from flagpole import Feature, EvaluationContext


@contextlib.contextmanager
def timer(name: str) -> None:
    start_mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    start = time.time()
    yield
    end = time.time()
    end_mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss

    duration = end - start
    print(f"> {name} : {duration:.8f}")
    mem_diff = end_mem - start_mem
    if mem_diff > 100_000:
        print(f"! memory increase: {name} : {mem_diff:.2f}")

def main() -> None:
    print("sentry flagpole timing")

    ctx = EvaluationContext(
        {
            "organization_id": 1,
            "organization_slug": "sentry",
            "user_email": "mark@sentry.io",
        },
        identity_fields=set(["organization_id"])
    )
    with timer("check single feature once"):
        name = "organizations:anomaly-detection-eap"
        config = options.get(f"feature.{name}")
        feature = Feature.from_feature_dictionary(name, config)
        assert feature.match(ctx)

    with timer("check single feature 10000 times"):
        name = "organizations:anomaly-detection-eap"
        for i in range(10000):
            config = options.get(f"feature.{name}")
            feature = Feature.from_feature_dictionary(name, config)
            feature.match(ctx)

    with timer("check every feature 100 times"):
        for key in features.all().keys():
            if key.startswith("feature"):
                name = key.split(".")[1]
                for i in range(100):
                    config = options.get(f"feature.{name}")
                    feature = Feature.from_feature_config_json(name, config)
                    feature.match(ctx)



if __name__ == "__main__":
    main()

and got higher durations for both the single and many checks.

sentry flagpole timing
> check single feature once : 0.00123692
> check single feature 10000 times : 0.07596898

kenzoengineer and others added 2 commits March 6, 2026 13:49
* Add sha1 package

* Implement features() and FeatureChecker

Add a first pass of features() and FeatureChecker.

* Implement features() and FeatureChecker in python

* Fix clippy warnings

* Fix tests

* Fix tests

* Tweak rollout rate comparison

1% should only include the 0th bucket, not 0 & 1.

* Features should default to enabled=false

* Add num dep

Need this for bigint support

* cargo fmt

* Fix key names in conftest.

* Remove dbg! and fix potential panic

* as serde value

* bump this version as well

---------

Co-authored-by: Mark Story <mark@mark-story.com>
Instead of monkey patching the test suite, use the existing fixture
files.
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.

2 participants