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: Add support for hooks #287

Merged
merged 1 commit into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions docs/api-main.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ ldclient.config module
:members:
:special-members: __init__

ldclient.hook module
--------------------------

.. automodule:: ldclient.hook
:members:

ldclient.evaluation module
--------------------------

Expand Down
110 changes: 93 additions & 17 deletions ldclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
This submodule contains the client class that provides most of the SDK functionality.
"""

from typing import Optional, Any, Dict, Mapping, Union, Tuple, Callable
from typing import Optional, Any, Dict, Mapping, Union, Tuple, Callable, List

from .impl import AnyNum

Expand All @@ -15,6 +15,7 @@
from ldclient.config import Config
from ldclient.context import Context
from ldclient.feature_store import _FeatureStoreDataSetSorter
from ldclient.hook import Hook, EvaluationSeriesContext, _EvaluationWithHookResult
from ldclient.evaluation import EvaluationDetail, FeatureFlagsState
from ldclient.impl.big_segments import BigSegmentStoreManager
from ldclient.impl.datasource.feature_requester import FeatureRequesterImpl
Expand Down Expand Up @@ -187,8 +188,10 @@ def __init__(self, config: Config, start_wait: float=5):
self._config = config
self._config._validate()

self.__hooks_lock = ReadWriteLock()
self.__hooks = config.hooks # type: List[Hook]

self._event_processor = None
self._lock = Lock()
self._event_factory_default = EventFactory(False)
self._event_factory_with_reasons = EventFactory(True)

Expand Down Expand Up @@ -395,8 +398,11 @@ def variation(self, key: str, context: Context, default: Any) -> Any:
available from LaunchDarkly
:return: the variation for the given context, or the ``default`` value if the flag cannot be evaluated
"""
detail, _ = self._evaluate_internal(key, context, default, self._event_factory_default)
return detail.value
def evaluate():
detail, _ = self._evaluate_internal(key, context, default, self._event_factory_default)
return _EvaluationWithHookResult(evaluation_detail=detail)

return self.__evaluate_with_hooks(key=key, context=context, default_value=default, method="variation", block=evaluate).evaluation_detail.value

def variation_detail(self, key: str, context: Context, default: Any) -> EvaluationDetail:
"""Calculates the value of a feature flag for a given context, and returns an object that
Expand All @@ -412,8 +418,11 @@ def variation_detail(self, key: str, context: Context, default: Any) -> Evaluati
:return: an :class:`ldclient.evaluation.EvaluationDetail` object that includes the feature
flag value and evaluation reason
"""
detail, _ = self._evaluate_internal(key, context, default, self._event_factory_with_reasons)
return detail
def evaluate():
detail, _ = self._evaluate_internal(key, context, default, self._event_factory_with_reasons)
return _EvaluationWithHookResult(evaluation_detail=detail)

return self.__evaluate_with_hooks(key=key, context=context, default_value=default, method="variation_detail", block=evaluate).evaluation_detail

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

detail, flag = self._evaluate_internal(key, context, default_stage.value, self._event_factory_default)
def evaluate():
detail, flag = self._evaluate_internal(key, context, default_stage.value, self._event_factory_default)

if isinstance(detail.value, str):
stage = Stage.from_str(detail.value)
if stage is not None:
tracker = OpTracker(key, flag, context, detail, default_stage)
return _EvaluationWithHookResult(evaluation_detail=detail, results={'default_stage': stage, 'tracker': tracker})

if isinstance(detail.value, str):
stage = Stage.from_str(detail.value)
if stage is not None:
tracker = OpTracker(key, flag, context, detail, default_stage)
return stage, tracker
detail = EvaluationDetail(default_stage.value, None, error_reason('WRONG_TYPE'))
tracker = OpTracker(key, flag, context, detail, default_stage)
return _EvaluationWithHookResult(evaluation_detail=detail, results={'default_stage': default_stage, 'tracker': tracker})

detail = EvaluationDetail(default_stage.value, None, error_reason('WRONG_TYPE'))
tracker = OpTracker(key, flag, context, detail, default_stage)
return default_stage, tracker
hook_result = self.__evaluate_with_hooks(key=key, context=context, default_value=default_stage, method="migration_variation", block=evaluate)
return hook_result.results['default_stage'], hook_result.results['tracker']

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

def add_hook(self, hook: Hook):
"""
Add a hook to the client. In order to register a hook before the client starts, please use the `hooks` property of
`Config`.

Hooks provide entrypoints which allow for observation of SDK functions.

:param hook:
"""
if not isinstance(hook, Hook):
return

self.__hooks_lock.lock()
self.__hooks.append(hook)
self.__hooks_lock.unlock()

def __evaluate_with_hooks(self, key: str, context: Context, default_value: Any, method: str, block: Callable[[], _EvaluationWithHookResult]) -> _EvaluationWithHookResult:
"""
# evaluate_with_hook will run the provided block, wrapping it with evaluation hook support.
#
# :param key:
# :param context:
# :param default:
# :param method:
# :param block:
# :return:
"""
hooks = [] # type: List[Hook]
try:
self.__hooks_lock.rlock()

if len(self.__hooks) == 0:
return block()

hooks = self.__hooks.copy()
finally:
self.__hooks_lock.runlock()

series_context = EvaluationSeriesContext(key=key, context=context, default_value=default_value, method=method)
hook_data = self.__execute_before_evaluation(hooks, series_context)
evaluation_result = block()
self.__execute_after_evaluation(hooks, series_context, hook_data, evaluation_result.evaluation_detail)

return evaluation_result

def __execute_before_evaluation(self, hooks: List[Hook], series_context: EvaluationSeriesContext) -> List[Any]:
return [
self.__try_execute_stage("beforeEvaluation", hook.metadata.name, lambda: hook.before_evaluation(series_context, {}))
for hook in hooks
]

def __execute_after_evaluation(self, hooks: List[Hook], series_context: EvaluationSeriesContext, hook_data: List[Any], evaluation_detail: EvaluationDetail) -> List[Any]:
return [
self.__try_execute_stage("afterEvaluation", hook.metadata.name, lambda: hook.after_evaluation(series_context, data, evaluation_detail))
for (hook, data) in reversed(list(zip(hooks, hook_data)))
]

def __try_execute_stage(self, method: str, hook_name: str, block: Callable[[], dict]) -> Optional[dict]:
try:
return block()
except BaseException as e:
log.error(f"An error occurred in {method} of the hook {hook_name}: #{e}")
return None

@property
def big_segment_store_status_provider(self) -> BigSegmentStoreStatusProvider:
"""
Expand Down
19 changes: 18 additions & 1 deletion ldclient/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from threading import Event

from ldclient.feature_store import InMemoryFeatureStore
from ldclient.hook import Hook
from ldclient.impl.util import log, validate_application_info
from ldclient.interfaces import BigSegmentStore, EventProcessor, FeatureStore, UpdateProcessor, DataSourceUpdateSink

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

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

def copy_with_new_sdk_key(self, new_sdk_key: str) -> 'Config':
Expand Down Expand Up @@ -442,6 +446,19 @@ def application(self) -> dict:
"""
return self.__application

@property
def hooks(self) -> List[Hook]:
"""
Initial set of hooks for the client.

Hooks provide entrypoints which allow for observation of SDK functions.

LaunchDarkly provides integration packages, and most applications will
not need to implement their own hooks. Refer to the
`launchdarkly-server-sdk-otel`.
"""
return self.__hooks

@property
def data_source_update_sink(self) -> Optional[DataSourceUpdateSink]:
"""
Expand Down
85 changes: 85 additions & 0 deletions ldclient/hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from ldclient.context import Context
from ldclient.evaluation import EvaluationDetail

from abc import ABCMeta, abstractmethod, abstractproperty
from dataclasses import dataclass
from typing import Any


@dataclass
class EvaluationSeriesContext:
"""
Contextual information that will be provided to handlers during evaluation
series.
"""

key: str #: The flag key used to trigger the evaluation.
context: Context #: The context used during evaluation.
default_value: Any #: The default value provided to the evaluation method
method: str #: The string version of the method which triggered the evaluation series.


@dataclass
class Metadata:
"""
Metadata data class used for annotating hook implementations.
"""

name: str #: A name representing a hook instance.


class Hook:
"""
Abstract class for extending SDK functionality via hooks.

All provided hook implementations **MUST** inherit from this class.

This class includes default implementations for all hook handlers. This
allows LaunchDarkly to expand the list of hook handlers without breaking
customer integrations.
"""
__metaclass__ = ABCMeta

@abstractproperty
def metadata(self) -> Metadata:
"""
Get metadata about the hook implementation.
"""
return Metadata(name='UNDEFINED')

@abstractmethod
def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict) -> dict:
"""
The before method is called during the execution of a variation method
before the flag value has been determined. The method is executed
synchronously.

:param series_context: Contains information about the evaluation being performed. This is not mutable.
:param data: A record associated with each stage of hook invocations.
Each stage is called with the data of the previous stage for a series.
The input record should not be modified.
:return: Data to use when executing the next state of the hook in the evaluation series.
"""
return data

@abstractmethod
def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict, detail: EvaluationDetail) -> dict:
"""
The after method is called during the execution of the variation method
after the flag value has been determined. The method is executed
synchronously.

:param series_context: Contains read-only information about the
evaluation being performed.
:param data: A record associated with each stage of hook invocations.
Each stage is called with the data of the previous stage for a series.
:param detail: The result of the evaluation. This value should not be modified.
:return: Data to use when executing the next state of the hook in the evaluation series.
"""
return data


@dataclass
class _EvaluationWithHookResult:
evaluation_detail: EvaluationDetail
results: Any = None
Loading
Loading