Skip to content

feat(flags): Add integration for custom tracking of flag evaluations #3860

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3652ce3
Add new integration and unit tests
aliu39 Dec 4, 2024
625969e
Test flag values for LD and OF threaded/asyncio, not just flag names
aliu39 Dec 4, 2024
beb9512
update ffIntegration test to be e2e, and fix LRU copy bug
aliu39 Dec 5, 2024
296545d
Merge branch 'master' into aliu/ff-integration-and-added-coverage
aliu39 Dec 6, 2024
921b133
make a helper fixture and test error processor in original thread
aliu39 Dec 6, 2024
4651b6a
Move api to top-level, rename to add_flag
aliu39 Dec 6, 2024
36bc869
Add docstrs
aliu39 Dec 6, 2024
81c1761
Rename to add_feature_flag
aliu39 Dec 6, 2024
42f76a3
Rm extra import in test_lru_cache
aliu39 Dec 6, 2024
ef01990
Revert lru comment
aliu39 Dec 6, 2024
381ccc1
Type annotate
aliu39 Dec 6, 2024
c76192e
Review comments
aliu39 Dec 6, 2024
b63982b
Update launchdarkly and openfeature tests to be e2e
aliu39 Dec 6, 2024
af1128c
Merge branch 'master' into aliu/ff-integration-and-added-coverage
aliu39 Dec 16, 2024
4c6f08a
Merge branch 'master' into aliu/ff-integration-and-added-coverage
antonpirker Dec 18, 2024
b4eb421
Update docstrs
aliu39 Dec 18, 2024
043e298
Skip threading test for <3.7
aliu39 Dec 18, 2024
41fdde5
Merge branch 'master' into aliu/ff-integration-and-added-coverage
aliu39 Dec 18, 2024
2dc679d
Skip ffs asyncio test if 3.6
aliu39 Dec 18, 2024
af9e92d
undo 'skip threading test'
aliu39 Dec 18, 2024
2cea37b
Try commenting out asyncio
aliu39 Dec 19, 2024
fea761c
Use importorskip
aliu39 Dec 19, 2024
752ce7d
Import order
aliu39 Dec 19, 2024
d77ebc3
Also use skipif
aliu39 Dec 19, 2024
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
44 changes: 44 additions & 0 deletions sentry_sdk/integrations/featureflags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from sentry_sdk.flag_utils import flag_error_processor

import sentry_sdk
from sentry_sdk.integrations import Integration


class FeatureFlagsIntegration(Integration):
"""
Sentry integration for buffering feature flags manually with an API and capturing them on
error events. We recommend you do this on each flag evaluation. Flags are buffered per Sentry
scope and limited to 100 per event.

See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags)
for more information.

@example
```
import sentry_sdk
from sentry_sdk.integrations.featureflags import FeatureFlagsIntegration, add_feature_flag

sentry_sdk.init(dsn="my_dsn", integrations=[FeatureFlagsIntegration()]);

add_feature_flag('my-flag', true);
sentry_sdk.capture_exception(Exception('broke')); // 'my-flag' should be captured on this Sentry event.
```
"""

identifier = "featureflags"

@staticmethod
def setup_once():
# type: () -> None
scope = sentry_sdk.get_current_scope()
scope.add_error_processor(flag_error_processor)


def add_feature_flag(flag, result):
# type: (str, bool) -> None
"""
Records a flag and its value to be sent on subsequent error events. We recommend you do this
on flag evaluations. Flags are buffered per Sentry scope and limited to 100 per event.
"""
flags = sentry_sdk.get_current_scope().flags
flags.set(flag, result)
Empty file.
142 changes: 142 additions & 0 deletions tests/integrations/featureflags/test_featureflags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import asyncio
import concurrent.futures as cf

import pytest

import sentry_sdk
from sentry_sdk.integrations import _processed_integrations, _installed_integrations
from sentry_sdk.integrations.featureflags import (
FeatureFlagsIntegration,
add_feature_flag,
)


@pytest.fixture
def uninstall_integration():
Copy link
Member Author

@aliu39 aliu39 Dec 6, 2024

Choose a reason for hiding this comment

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

Note on this fixture: I found using the reset_integrations fixture in conftest.py caused issues with the default dedupe integration. We just want the integration being tested to be setup at the start of every new test.

Without using this, the error processor isn't added correctly. This isn't a concern for real use, since user apps only call init once

"""Forces the next call to sentry_init to re-install/setup an integration."""

def inner(identifier):
_processed_integrations.discard(identifier)
_installed_integrations.discard(identifier)

return inner


def test_featureflags_integration(sentry_init, capture_events, uninstall_integration):
uninstall_integration(FeatureFlagsIntegration.identifier)
sentry_init(integrations=[FeatureFlagsIntegration()])

add_feature_flag("hello", False)
add_feature_flag("world", True)
add_feature_flag("other", False)

events = capture_events()
sentry_sdk.capture_exception(Exception("something wrong!"))
[event] = events

assert event["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": False},
{"flag": "world", "result": True},
{"flag": "other", "result": False},
]
}


def test_featureflags_integration_threaded(
sentry_init, capture_events, uninstall_integration
):
uninstall_integration(FeatureFlagsIntegration.identifier)
sentry_init(integrations=[FeatureFlagsIntegration()])
events = capture_events()

# Capture an eval before we split isolation scopes.
add_feature_flag("hello", False)

def task(flag_key):
# Creates a new isolation scope for the thread.
# This means the evaluations in each task are captured separately.
with sentry_sdk.isolation_scope():
add_feature_flag(flag_key, False)
# use a tag to identify to identify events later on
sentry_sdk.set_tag("task_id", flag_key)
sentry_sdk.capture_exception(Exception("something wrong!"))

# Run tasks in separate threads
with cf.ThreadPoolExecutor(max_workers=2) as pool:
pool.map(task, ["world", "other"])

# Capture error in original scope
sentry_sdk.set_tag("task_id", "0")
sentry_sdk.capture_exception(Exception("something wrong!"))

assert len(events) == 3
events.sort(key=lambda e: e["tags"]["task_id"])

assert events[0]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": False},
]
}
assert events[1]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": False},
{"flag": "other", "result": False},
]
}
assert events[2]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": False},
{"flag": "world", "result": False},
]
}


def test_featureflags_integration_asyncio(
sentry_init, capture_events, uninstall_integration
):
uninstall_integration(FeatureFlagsIntegration.identifier)
sentry_init(integrations=[FeatureFlagsIntegration()])
events = capture_events()

# Capture an eval before we split isolation scopes.
add_feature_flag("hello", False)

async def task(flag_key):
# Creates a new isolation scope for the thread.
# This means the evaluations in each task are captured separately.
with sentry_sdk.isolation_scope():
add_feature_flag(flag_key, False)
# use a tag to identify to identify events later on
sentry_sdk.set_tag("task_id", flag_key)
sentry_sdk.capture_exception(Exception("something wrong!"))

async def runner():
return asyncio.gather(task("world"), task("other"))

asyncio.run(runner())

# Capture error in original scope
sentry_sdk.set_tag("task_id", "0")
sentry_sdk.capture_exception(Exception("something wrong!"))

assert len(events) == 3
events.sort(key=lambda e: e["tags"]["task_id"])

assert events[0]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": False},
]
}
assert events[1]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": False},
{"flag": "other", "result": False},
]
}
assert events[2]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": False},
{"flag": "world", "result": False},
]
}
24 changes: 18 additions & 6 deletions tests/integrations/launchdarkly/test_launchdarkly.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def task(flag_key):
# This means the evaluations in each task are captured separately.
with sentry_sdk.isolation_scope():
client.variation(flag_key, context, False)
return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()]
return sentry_sdk.get_current_scope().flags.get()

td.update(td.flag("hello").variation_for_all(True))
td.update(td.flag("world").variation_for_all(False))
Expand All @@ -67,8 +67,14 @@ def task(flag_key):
with cf.ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(task, ["world", "other"]))

assert results[0] == ["hello", "world"]
assert results[1] == ["hello", "other"]
assert results[0] == [
{"flag": "hello", "result": True},
{"flag": "world", "result": False},
]
assert results[1] == [
{"flag": "hello", "result": True},
{"flag": "other", "result": False},
]


def test_launchdarkly_integration_asyncio(sentry_init):
Expand All @@ -81,7 +87,7 @@ def test_launchdarkly_integration_asyncio(sentry_init):
async def task(flag_key):
with sentry_sdk.isolation_scope():
client.variation(flag_key, context, False)
return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()]
return sentry_sdk.get_current_scope().flags.get()

async def runner():
return asyncio.gather(task("world"), task("other"))
Expand All @@ -91,8 +97,14 @@ async def runner():
client.variation("hello", context, False)

results = asyncio.run(runner()).result()
assert results[0] == ["hello", "world"]
assert results[1] == ["hello", "other"]
assert results[0] == [
{"flag": "hello", "result": True},
{"flag": "world", "result": False},
]
assert results[1] == [
{"flag": "hello", "result": True},
{"flag": "other", "result": False},
]


def test_launchdarkly_integration_did_not_enable(monkeypatch):
Expand Down
24 changes: 18 additions & 6 deletions tests/integrations/openfeature/test_openfeature.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,19 @@ def task(flag):
# Create a new isolation scope for the thread. This means the flags
with sentry_sdk.isolation_scope():
client.get_boolean_value(flag, default_value=False)
return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()]
return sentry_sdk.get_current_scope().flags.get()

with cf.ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(task, ["world", "other"]))

assert results[0] == ["hello", "world"]
assert results[1] == ["hello", "other"]
assert results[0] == [
{"flag": "hello", "result": True},
{"flag": "world", "result": False},
]
assert results[1] == [
{"flag": "hello", "result": True},
{"flag": "other", "result": False},
]


def test_openfeature_integration_asyncio(sentry_init):
Expand All @@ -59,7 +65,7 @@ def test_openfeature_integration_asyncio(sentry_init):
async def task(flag):
with sentry_sdk.isolation_scope():
client.get_boolean_value(flag, default_value=False)
return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()]
return sentry_sdk.get_current_scope().flags.get()

async def runner():
return asyncio.gather(task("world"), task("other"))
Expand All @@ -76,5 +82,11 @@ async def runner():
client.get_boolean_value("hello", default_value=False)

results = asyncio.run(runner()).result()
assert results[0] == ["hello", "world"]
assert results[1] == ["hello", "other"]
assert results[0] == [
{"flag": "hello", "result": True},
{"flag": "world", "result": False},
]
assert results[1] == [
{"flag": "hello", "result": True},
{"flag": "other", "result": False},
]
Loading