Skip to content

Commit eef6e26

Browse files
committed
feat: Add support for hooks (#287)
1 parent 6d84fff commit eef6e26

File tree

6 files changed

+382
-19
lines changed

6 files changed

+382
-19
lines changed

docs/api-main.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ ldclient.config module
2020
:members:
2121
:special-members: __init__
2222

23+
ldclient.hook module
24+
--------------------------
25+
26+
.. automodule:: ldclient.hook
27+
:members:
28+
2329
ldclient.evaluation module
2430
--------------------------
2531

ldclient/client.py

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
This submodule contains the client class that provides most of the SDK functionality.
33
"""
44

5-
from typing import Optional, Any, Dict, Mapping, Union, Tuple, Callable
5+
from typing import Optional, Any, Dict, Mapping, Union, Tuple, Callable, List
66

77
from .impl import AnyNum
88

@@ -15,6 +15,7 @@
1515
from ldclient.config import Config
1616
from ldclient.context import Context
1717
from ldclient.feature_store import _FeatureStoreDataSetSorter
18+
from ldclient.hook import Hook, EvaluationSeriesContext, _EvaluationWithHookResult
1819
from ldclient.evaluation import EvaluationDetail, FeatureFlagsState
1920
from ldclient.impl.big_segments import BigSegmentStoreManager
2021
from ldclient.impl.datasource.feature_requester import FeatureRequesterImpl
@@ -187,8 +188,10 @@ def __init__(self, config: Config, start_wait: float=5):
187188
self._config = config
188189
self._config._validate()
189190

191+
self.__hooks_lock = ReadWriteLock()
192+
self.__hooks = config.hooks # type: List[Hook]
193+
190194
self._event_processor = None
191-
self._lock = Lock()
192195
self._event_factory_default = EventFactory(False)
193196
self._event_factory_with_reasons = EventFactory(True)
194197

@@ -395,8 +398,11 @@ def variation(self, key: str, context: Context, default: Any) -> Any:
395398
available from LaunchDarkly
396399
:return: the variation for the given context, or the ``default`` value if the flag cannot be evaluated
397400
"""
398-
detail, _ = self._evaluate_internal(key, context, default, self._event_factory_default)
399-
return detail.value
401+
def evaluate():
402+
detail, _ = self._evaluate_internal(key, context, default, self._event_factory_default)
403+
return _EvaluationWithHookResult(evaluation_detail=detail)
404+
405+
return self.__evaluate_with_hooks(key=key, context=context, default_value=default, method="variation", block=evaluate).evaluation_detail.value
400406

401407
def variation_detail(self, key: str, context: Context, default: Any) -> EvaluationDetail:
402408
"""Calculates the value of a feature flag for a given context, and returns an object that
@@ -412,8 +418,11 @@ def variation_detail(self, key: str, context: Context, default: Any) -> Evaluati
412418
:return: an :class:`ldclient.evaluation.EvaluationDetail` object that includes the feature
413419
flag value and evaluation reason
414420
"""
415-
detail, _ = self._evaluate_internal(key, context, default, self._event_factory_with_reasons)
416-
return detail
421+
def evaluate():
422+
detail, _ = self._evaluate_internal(key, context, default, self._event_factory_with_reasons)
423+
return _EvaluationWithHookResult(evaluation_detail=detail)
424+
425+
return self.__evaluate_with_hooks(key=key, context=context, default_value=default, method="variation_detail", block=evaluate).evaluation_detail
417426

418427
def migration_variation(self, key: str, context: Context, default_stage: Stage) -> Tuple[Stage, OpTracker]:
419428
"""
@@ -429,17 +438,21 @@ def migration_variation(self, key: str, context: Context, default_stage: Stage)
429438
log.error(f"default stage {default_stage} is not a valid stage; using 'off' instead")
430439
default_stage = Stage.OFF
431440

432-
detail, flag = self._evaluate_internal(key, context, default_stage.value, self._event_factory_default)
441+
def evaluate():
442+
detail, flag = self._evaluate_internal(key, context, default_stage.value, self._event_factory_default)
443+
444+
if isinstance(detail.value, str):
445+
stage = Stage.from_str(detail.value)
446+
if stage is not None:
447+
tracker = OpTracker(key, flag, context, detail, default_stage)
448+
return _EvaluationWithHookResult(evaluation_detail=detail, results={'default_stage': stage, 'tracker': tracker})
433449

434-
if isinstance(detail.value, str):
435-
stage = Stage.from_str(detail.value)
436-
if stage is not None:
437-
tracker = OpTracker(key, flag, context, detail, default_stage)
438-
return stage, tracker
450+
detail = EvaluationDetail(default_stage.value, None, error_reason('WRONG_TYPE'))
451+
tracker = OpTracker(key, flag, context, detail, default_stage)
452+
return _EvaluationWithHookResult(evaluation_detail=detail, results={'default_stage': default_stage, 'tracker': tracker})
439453

440-
detail = EvaluationDetail(default_stage.value, None, error_reason('WRONG_TYPE'))
441-
tracker = OpTracker(key, flag, context, detail, default_stage)
442-
return default_stage, tracker
454+
hook_result = self.__evaluate_with_hooks(key=key, context=context, default_value=default_stage, method="migration_variation", block=evaluate)
455+
return hook_result.results['default_stage'], hook_result.results['tracker']
443456

444457
def _evaluate_internal(self, key: str, context: Context, default: Any, event_factory) -> Tuple[EvaluationDetail, Optional[FeatureFlag]]:
445458
default = self._config.get_default(key, default)
@@ -451,8 +464,7 @@ def _evaluate_internal(self, key: str, context: Context, default: Any, event_fac
451464
if self._store.initialized:
452465
log.warning("Feature Flag evaluation attempted before client has initialized - using last known values from feature store for feature key: " + key)
453466
else:
454-
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: "
455-
+ str(default) + " for feature key: " + key)
467+
log.warning("Feature Flag evaluation attempted before client has initialized! Feature store unavailable - returning default: " + str(default) + " for feature key: " + key)
456468
reason = error_reason('CLIENT_NOT_READY')
457469
self._send_event(event_factory.new_unknown_flag_event(key, context, default, reason))
458470
return EvaluationDetail(default, None, reason), None
@@ -583,6 +595,70 @@ def secure_mode_hash(self, context: Context) -> str:
583595
return ""
584596
return hmac.new(str(self._config.sdk_key).encode(), context.fully_qualified_key.encode(), hashlib.sha256).hexdigest()
585597

598+
def add_hook(self, hook: Hook):
599+
"""
600+
Add a hook to the client. In order to register a hook before the client starts, please use the `hooks` property of
601+
`Config`.
602+
603+
Hooks provide entrypoints which allow for observation of SDK functions.
604+
605+
:param hook:
606+
"""
607+
if not isinstance(hook, Hook):
608+
return
609+
610+
self.__hooks_lock.lock()
611+
self.__hooks.append(hook)
612+
self.__hooks_lock.unlock()
613+
614+
def __evaluate_with_hooks(self, key: str, context: Context, default_value: Any, method: str, block: Callable[[], _EvaluationWithHookResult]) -> _EvaluationWithHookResult:
615+
"""
616+
# evaluate_with_hook will run the provided block, wrapping it with evaluation hook support.
617+
#
618+
# :param key:
619+
# :param context:
620+
# :param default:
621+
# :param method:
622+
# :param block:
623+
# :return:
624+
"""
625+
hooks = [] # type: List[Hook]
626+
try:
627+
self.__hooks_lock.rlock()
628+
629+
if len(self.__hooks) == 0:
630+
return block()
631+
632+
hooks = self.__hooks.copy()
633+
finally:
634+
self.__hooks_lock.runlock()
635+
636+
series_context = EvaluationSeriesContext(key=key, context=context, default_value=default_value, method=method)
637+
hook_data = self.__execute_before_evaluation(hooks, series_context)
638+
evaluation_result = block()
639+
self.__execute_after_evaluation(hooks, series_context, hook_data, evaluation_result.evaluation_detail)
640+
641+
return evaluation_result
642+
643+
def __execute_before_evaluation(self, hooks: List[Hook], series_context: EvaluationSeriesContext) -> List[Any]:
644+
return [
645+
self.__try_execute_stage("beforeEvaluation", hook.metadata.name, lambda: hook.before_evaluation(series_context, {}))
646+
for hook in hooks
647+
]
648+
649+
def __execute_after_evaluation(self, hooks: List[Hook], series_context: EvaluationSeriesContext, hook_data: List[Any], evaluation_detail: EvaluationDetail) -> List[Any]:
650+
return [
651+
self.__try_execute_stage("afterEvaluation", hook.metadata.name, lambda: hook.after_evaluation(series_context, data, evaluation_detail))
652+
for (hook, data) in reversed(list(zip(hooks, hook_data)))
653+
]
654+
655+
def __try_execute_stage(self, method: str, hook_name: str, block: Callable[[], dict]) -> Optional[dict]:
656+
try:
657+
return block()
658+
except BaseException as e:
659+
log.error(f"An error occurred in {method} of the hook {hook_name}: #{e}")
660+
return None
661+
586662
@property
587663
def big_segment_store_status_provider(self) -> BigSegmentStoreStatusProvider:
588664
"""

ldclient/config.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from threading import Event
99

1010
from ldclient.feature_store import InMemoryFeatureStore
11+
from ldclient.hook import Hook
1112
from ldclient.impl.util import log, validate_application_info
1213
from ldclient.interfaces import BigSegmentStore, EventProcessor, FeatureStore, UpdateProcessor, DataSourceUpdateSink
1314

@@ -173,7 +174,8 @@ def __init__(self,
173174
wrapper_version: Optional[str]=None,
174175
http: HTTPConfig=HTTPConfig(),
175176
big_segments: Optional[BigSegmentsConfig]=None,
176-
application: Optional[dict]=None):
177+
application: Optional[dict]=None,
178+
hooks: Optional[List[Hook]]=None):
177179
"""
178180
:param sdk_key: The SDK key for your LaunchDarkly account. This is always required.
179181
:param base_uri: The base URL for the LaunchDarkly server. Most users should use the default
@@ -238,6 +240,7 @@ def __init__(self,
238240
:param http: Optional properties for customizing the client's HTTP/HTTPS behavior. See
239241
:class:`HTTPConfig`.
240242
:param application: Optional properties for setting application metadata. See :py:attr:`~application`
243+
:param hooks: Hooks provide entrypoints which allow for observation of SDK functions.
241244
"""
242245
self.__sdk_key = sdk_key
243246

@@ -270,6 +273,7 @@ def __init__(self,
270273
self.__http = http
271274
self.__big_segments = BigSegmentsConfig() if not big_segments else big_segments
272275
self.__application = validate_application_info(application or {}, log)
276+
self.__hooks = [hook for hook in hooks if isinstance(hook, Hook)] if hooks else []
273277
self._data_source_update_sink: Optional[DataSourceUpdateSink] = None
274278

275279
def copy_with_new_sdk_key(self, new_sdk_key: str) -> 'Config':
@@ -442,6 +446,19 @@ def application(self) -> dict:
442446
"""
443447
return self.__application
444448

449+
@property
450+
def hooks(self) -> List[Hook]:
451+
"""
452+
Initial set of hooks for the client.
453+
454+
Hooks provide entrypoints which allow for observation of SDK functions.
455+
456+
LaunchDarkly provides integration packages, and most applications will
457+
not need to implement their own hooks. Refer to the
458+
`launchdarkly-server-sdk-otel`.
459+
"""
460+
return self.__hooks
461+
445462
@property
446463
def data_source_update_sink(self) -> Optional[DataSourceUpdateSink]:
447464
"""

ldclient/hook.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from ldclient.context import Context
2+
from ldclient.evaluation import EvaluationDetail
3+
4+
from abc import ABCMeta, abstractmethod, abstractproperty
5+
from dataclasses import dataclass
6+
from typing import Any
7+
8+
9+
@dataclass
10+
class EvaluationSeriesContext:
11+
"""
12+
Contextual information that will be provided to handlers during evaluation
13+
series.
14+
"""
15+
16+
key: str #: The flag key used to trigger the evaluation.
17+
context: Context #: The context used during evaluation.
18+
default_value: Any #: The default value provided to the evaluation method
19+
method: str #: The string version of the method which triggered the evaluation series.
20+
21+
22+
@dataclass
23+
class Metadata:
24+
"""
25+
Metadata data class used for annotating hook implementations.
26+
"""
27+
28+
name: str #: A name representing a hook instance.
29+
30+
31+
class Hook:
32+
"""
33+
Abstract class for extending SDK functionality via hooks.
34+
35+
All provided hook implementations **MUST** inherit from this class.
36+
37+
This class includes default implementations for all hook handlers. This
38+
allows LaunchDarkly to expand the list of hook handlers without breaking
39+
customer integrations.
40+
"""
41+
__metaclass__ = ABCMeta
42+
43+
@abstractproperty
44+
def metadata(self) -> Metadata:
45+
"""
46+
Get metadata about the hook implementation.
47+
"""
48+
return Metadata(name='UNDEFINED')
49+
50+
@abstractmethod
51+
def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict) -> dict:
52+
"""
53+
The before method is called during the execution of a variation method
54+
before the flag value has been determined. The method is executed
55+
synchronously.
56+
57+
:param series_context: Contains information about the evaluation being performed. This is not mutable.
58+
:param data: A record associated with each stage of hook invocations.
59+
Each stage is called with the data of the previous stage for a series.
60+
The input record should not be modified.
61+
:return: Data to use when executing the next state of the hook in the evaluation series.
62+
"""
63+
return data
64+
65+
@abstractmethod
66+
def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict, detail: EvaluationDetail) -> dict:
67+
"""
68+
The after method is called during the execution of the variation method
69+
after the flag value has been determined. The method is executed
70+
synchronously.
71+
72+
:param series_context: Contains read-only information about the
73+
evaluation being performed.
74+
:param data: A record associated with each stage of hook invocations.
75+
Each stage is called with the data of the previous stage for a series.
76+
:param detail: The result of the evaluation. This value should not be modified.
77+
:return: Data to use when executing the next state of the hook in the evaluation series.
78+
"""
79+
return data
80+
81+
82+
@dataclass
83+
class _EvaluationWithHookResult:
84+
evaluation_detail: EvaluationDetail
85+
results: Any = None

0 commit comments

Comments
 (0)