Skip to content
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

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

Open
wants to merge 16 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
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):
aliu39 marked this conversation as resolved.
Show resolved Hide resolved
"""
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.

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.
aliu39 marked this conversation as resolved.
Show resolved Hide resolved
"""
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!"))

assert len(events) == 1
assert events[0]["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