From bf2a5eff2a10a239f645c6bf99056ecd5d9b3ec8 Mon Sep 17 00:00:00 2001 From: Joe Haines Date: Tue, 25 Jul 2023 08:34:14 +0100 Subject: [PATCH] Add feature flag API to Client --- bugsnag/__init__.py | 3 +- bugsnag/client.py | 63 ++++++++++++++++++++++-- bugsnag/event.py | 5 +- tests/test_client.py | 111 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 175 insertions(+), 7 deletions(-) diff --git a/bugsnag/__init__.py b/bugsnag/__init__.py index c1fe3505..38716463 100644 --- a/bugsnag/__init__.py +++ b/bugsnag/__init__.py @@ -8,6 +8,7 @@ Breadcrumbs, OnBreadcrumbCallback ) +from bugsnag.feature_flags import FeatureFlag from bugsnag.legacy import (configuration, configure, configure_request, add_metadata_tab, clear_request_config, notify, auto_notify, before_notify, start_session, @@ -21,4 +22,4 @@ 'auto_notify_exc_info', 'Notification', 'logger', 'BreadcrumbType', 'Breadcrumb', 'Breadcrumbs', 'OnBreadcrumbCallback', 'leave_breadcrumb', 'add_on_breadcrumb', - 'remove_on_breadcrumb') + 'remove_on_breadcrumb', 'FeatureFlag') diff --git a/bugsnag/client.py b/bugsnag/client.py index 587f6031..6916c6fb 100644 --- a/bugsnag/client.py +++ b/bugsnag/client.py @@ -14,6 +14,7 @@ ) from bugsnag.configuration import Configuration, RequestConfiguration from bugsnag.event import Event +from bugsnag.feature_flags import FeatureFlag, FeatureFlagDelegate from bugsnag.handlers import BugsnagHandler from bugsnag.sessiontracker import SessionTracker from bugsnag.utils import to_rfc3339 @@ -21,6 +22,17 @@ __all__ = ('Client',) +try: + from contextvars import ContextVar + _feature_flag_delegate_context_var = ContextVar( + 'bugsnag-client-feature-flag-delegate', + default=None + ) # type: ContextVar[Optional[FeatureFlagDelegate]] +except ImportError: + from bugsnag.utils import ThreadContextVar + _feature_flag_delegate_context_var = ThreadContextVar('bugsnag-client-feature-flag-delegate', default=None) # type: ignore # noqa: E501 + + class Client: """ A Bugsnag monitoring and reporting client. @@ -78,8 +90,13 @@ def notify(self, exception: BaseException, asynchronous=None, **options): >>> client.notify(Exception('Example')) # doctest: +SKIP """ - event = Event(exception, self.configuration, - RequestConfiguration.get_instance(), **options) + event = Event( + exception, + self.configuration, + RequestConfiguration.get_instance(), + **options, + feature_flag_delegate=self._feature_flag_delegate + ) self._leave_breadcrumb_for_event(event) self.deliver(event, asynchronous=asynchronous) @@ -94,8 +111,13 @@ def notify_exc_info(self, exc_type, exc_value, traceback, exception = exc_value options['traceback'] = traceback - event = Event(exception, self.configuration, - RequestConfiguration.get_instance(), **options) + event = Event( + exception, + self.configuration, + RequestConfiguration.get_instance(), + **options, + feature_flag_delegate=self._feature_flag_delegate + ) self._leave_breadcrumb_for_event(event) self.deliver(event, asynchronous=asynchronous) @@ -213,6 +235,39 @@ def log_handler( ) -> BugsnagHandler: return BugsnagHandler(client=self, extra_fields=extra_fields) + @property + def _feature_flag_delegate(self) -> FeatureFlagDelegate: + try: + feature_flag_delegate = _feature_flag_delegate_context_var.get() + except LookupError: + feature_flag_delegate = None + + if feature_flag_delegate is None: + feature_flag_delegate = FeatureFlagDelegate() + _feature_flag_delegate_context_var.set(feature_flag_delegate) + + return feature_flag_delegate + + @property + def feature_flags(self) -> List[FeatureFlag]: + return self._feature_flag_delegate.to_list() + + def add_feature_flag( + self, + name: Union[str, bytes], + variant: Union[None, str, bytes] = None + ) -> None: + self._feature_flag_delegate.add(name, variant) + + def add_feature_flags(self, feature_flags: List[FeatureFlag]) -> None: + self._feature_flag_delegate.merge(feature_flags) + + def clear_feature_flag(self, name: Union[str, bytes]) -> None: + self._feature_flag_delegate.remove(name) + + def clear_feature_flags(self) -> None: + self._feature_flag_delegate.clear() + @property def breadcrumbs(self) -> List[Breadcrumb]: return self.configuration.breadcrumbs diff --git a/bugsnag/event.py b/bugsnag/event.py index 990894c0..87b13f9f 100644 --- a/bugsnag/event.py +++ b/bugsnag/event.py @@ -66,7 +66,10 @@ def __init__(self, exception: BaseException, config, request_config, self._breadcrumbs = [ deepcopy(breadcrumb) for breadcrumb in config.breadcrumbs ] - self._feature_flag_delegate = FeatureFlagDelegate() + self._feature_flag_delegate = options.pop( + 'feature_flag_delegate', + FeatureFlagDelegate() + ).copy() def get_config(key): return options.pop(key, getattr(self.config, key)) diff --git a/tests/test_client.py b/tests/test_client.py index accea859..b4bd28c8 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,7 +12,8 @@ Client, Configuration, BreadcrumbType, - Breadcrumb + Breadcrumb, + FeatureFlag ) import bugsnag.legacy as legacy @@ -35,6 +36,8 @@ def setUp(self): asynchronous=False, install_sys_hook=False) + self.client.clear_feature_flags() + # Initialisation def test_init_no_configuration(self): @@ -1512,6 +1515,112 @@ def test_deleting_skip_bugsnag_attr_allows_notify(self): assert self.sent_report_count == 1 + def test_feature_flags_can_be_added_individually(self): + self.client.add_feature_flag('one') + self.client.add_feature_flag('two', 'a') + self.client.add_feature_flag('three', None) + + assert self.client.feature_flags == [ + FeatureFlag('one'), + FeatureFlag('two', 'a'), + FeatureFlag('three') + ] + + def test_feature_flags_can_be_added_in_bulk(self): + self.client.add_feature_flags([ + FeatureFlag('a', '1'), + FeatureFlag('b'), + FeatureFlag('c', '3') + ]) + + assert self.client.feature_flags == [ + FeatureFlag('a', '1'), + FeatureFlag('b'), + FeatureFlag('c', '3') + ] + + def test_feature_flags_can_be_removed_individually(self): + self.client.add_feature_flags([ + FeatureFlag('a', '1'), + FeatureFlag('b'), + FeatureFlag('c', '3') + ]) + + self.client.clear_feature_flag('b') + + assert self.client.feature_flags == [ + FeatureFlag('a', '1'), + FeatureFlag('c', '3') + ] + + def test_feature_flags_can_be_cleared(self): + self.client.add_feature_flags([ + FeatureFlag('a', '1'), + FeatureFlag('b'), + FeatureFlag('c', '3') + ]) + + self.client.clear_feature_flags() + + assert self.client.feature_flags == [] + + def test_feature_flags_are_included_in_payload(self): + self.client.add_feature_flags([ + FeatureFlag('a', '1'), + FeatureFlag('b'), + FeatureFlag('c', '3') + ]) + + self.client.notify(Exception('abc')) + + assert self.sent_report_count == 1 + + payload = self.server.received[0]['json_body'] + feature_flags = payload['events'][0]['featureFlags'] + + assert feature_flags == [ + {'name': 'a', 'variant': '1'}, + {'name': 'b'}, + {'name': 'c', 'variant': '3'} + ] + + def test_mutating_client_feature_flags_does_not_affect_event(self): + self.client.add_feature_flags([ + FeatureFlag('a', '1'), + FeatureFlag('b'), + FeatureFlag('c', '3') + ]) + + def on_error(event): + # adding a flag to the event directly should affect the payload + event.add_feature_flag('d') + + # adding a flag to the client should not affect the payload as the + # event has already been created + self.client.add_feature_flag('e') + + self.client.configuration.middleware.before_notify(on_error) + self.client.notify(Exception('abc')) + + assert self.sent_report_count == 1 + + payload = self.server.received[0]['json_body'] + feature_flags = payload['events'][0]['featureFlags'] + + assert feature_flags == [ + {'name': 'a', 'variant': '1'}, + {'name': 'b'}, + {'name': 'c', 'variant': '3'}, + {'name': 'd'} + ] + + assert self.client.feature_flags == [ + FeatureFlag('a', '1'), + FeatureFlag('b'), + FeatureFlag('c', '3'), + FeatureFlag('e') + ] + @pytest.mark.parametrize("metadata,type", [ (1234, 'int'),