From 876bf4bba8792fd5ea2b72bf319f21b9f8fe2b78 Mon Sep 17 00:00:00 2001 From: Danju Visvanathan Date: Sat, 14 Mar 2026 21:54:18 +1100 Subject: [PATCH 01/11] feat: add logging hook Signed-off-by: Danju Visvanathan --- openfeature/hook/__init__.py | 65 ++++++++++++ tests/hook/test_logging_hook.py | 179 ++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 tests/hook/test_logging_hook.py diff --git a/openfeature/hook/__init__.py b/openfeature/hook/__init__.py index 247d316b..f67fc1b2 100644 --- a/openfeature/hook/__init__.py +++ b/openfeature/hook/__init__.py @@ -1,11 +1,13 @@ from __future__ import annotations +import logging import typing from collections.abc import Mapping, MutableMapping, Sequence from datetime import datetime from enum import Enum from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ErrorCode, OpenFeatureError from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, FlagValueType if typing.TYPE_CHECKING: @@ -18,6 +20,7 @@ "HookData", "HookHints", "HookType", + "LoggingHook", "add_hooks", "clear_hooks", "get_hooks", @@ -163,3 +166,65 @@ def clear_hooks() -> None: def get_hooks() -> list[Hook]: return _hooks + + +class LoggingHook(Hook): + def __init__( + self, + include_evaluation_context: bool = False, + logger: logging.Logger | None = None, + ): + self.logger = logger or logging.getLogger("openfeature") + self.include_evaluation_context = include_evaluation_context + + def _build_args(self, hook_context: HookContext) -> dict: + args = { + "domain": hook_context.client_metadata.domain + if hook_context.client_metadata + else None, + "provider_name": hook_context.provider_metadata.name + if hook_context.provider_metadata + else None, + "flag_key": hook_context.flag_key, + "default_value": hook_context.default_value, + } + if self.include_evaluation_context: + args["evaluation_context"] = { + "targeting_key": hook_context.evaluation_context.targeting_key, + "attributes": hook_context.evaluation_context.attributes, + } + return args + + def before( + self, hook_context: HookContext, hints: HookHints + ) -> EvaluationContext | None: + args = self._build_args(hook_context) + args["stage"] = "before" + self.logger.debug("Before stage %s", args) + return None + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + args = self._build_args(hook_context) + extra_args = { + "stage": "after", + "reason": details.reason, + "variant": details.variant, + "value": details.value, + } + self.logger.debug("After stage %s", {**args, **extra_args}) + + def error( + self, hook_context: HookContext, exception: Exception, hints: HookHints + ) -> None: + args = self._build_args(hook_context) + extra_args = { + "stage": "error", + "error_code": exception.error_code if isinstance(exception, OpenFeatureError) else ErrorCode.GENERAL, + "error_message": str(exception), + } + self.logger.error("Error stage %s", {**args, **extra_args}) diff --git a/tests/hook/test_logging_hook.py b/tests/hook/test_logging_hook.py new file mode 100644 index 00000000..ebebe649 --- /dev/null +++ b/tests/hook/test_logging_hook.py @@ -0,0 +1,179 @@ +from unittest.mock import MagicMock + +import pytest + +from openfeature.client import ClientMetadata +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ErrorCode, FlagNotFoundError +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType +from openfeature.hook import HookContext, LoggingHook +from openfeature.provider.metadata import Metadata + + +@pytest.fixture() +def hook_context(): + return HookContext( + flag_key="my-flag", + flag_type=FlagType.BOOLEAN, + default_value=False, + evaluation_context=EvaluationContext("user-1", {"env": "prod"}), + client_metadata=ClientMetadata(domain="my-domain"), + provider_metadata=Metadata(name="my-provider"), + ) + + +def test_before_calls_debug_with_stage(hook_context): + mock_logger = MagicMock() + hook = LoggingHook(logger=mock_logger) + hook.before(hook_context, hints={}) + mock_logger.debug.assert_called_with( + "Before stage %s", + { + "stage": "before", + "flag_key": "my-flag", + "default_value": False, + "domain": "my-domain", + "provider_name": "my-provider", + }, + ) + + +def test_after_calls_debug_with_stage(hook_context): + mock_logger = MagicMock() + hook = LoggingHook(logger=mock_logger) + details = FlagEvaluationDetails( + flag_key="my-flag", + value=True, + variant="on", + reason="STATIC", + ) + hook.after(hook_context, details, hints={}) + + mock_logger.debug.assert_called_with( + "After stage %s", + { + "stage": "after", + "flag_key": "my-flag", + "default_value": False, + "domain": "my-domain", + "provider_name": "my-provider", + "reason": "STATIC", + "variant": "on", + "value": True, + }, + ) + + +def test_after_calls_debug_with_evaluation_context(hook_context): + mock_logger = MagicMock() + hook = LoggingHook(logger=mock_logger, include_evaluation_context=True) + details = FlagEvaluationDetails( + flag_key="my-flag", + value=True, + variant="on", + reason="STATIC", + ) + hook.after(hook_context, details, hints={}) + + mock_logger.debug.assert_called_with( + "After stage %s", + { + "stage": "after", + "flag_key": "my-flag", + "default_value": False, + "domain": "my-domain", + "provider_name": "my-provider", + "reason": "STATIC", + "variant": "on", + "value": True, + "evaluation_context": { + "targeting_key": "user-1", + "attributes": {"env": "prod"}, + }, + }, + ) + + +def test_error_calls_error_log(hook_context): + mock_logger = MagicMock() + hook = LoggingHook(logger=mock_logger) + exception = Exception("something went wrong") + hook.error(hook_context, exception, hints={}) + + mock_logger.error.assert_called_with( + "Error stage %s", + { + "stage": "error", + "flag_key": "my-flag", + "default_value": False, + "domain": "my-domain", + "provider_name": "my-provider", + "error_code": ErrorCode.GENERAL, + "error_message": "something went wrong", + }, + ) + + +def test_error_extracts_error_code_from_open_feature_error(hook_context): + mock_logger = MagicMock() + hook = LoggingHook(logger=mock_logger) + exception = FlagNotFoundError("flag not found") + hook.error(hook_context, exception, hints={}) + + mock_logger.error.assert_called_with( + "Error stage %s", + { + "stage": "error", + "flag_key": "my-flag", + "default_value": False, + "domain": "my-domain", + "provider_name": "my-provider", + "error_code": ErrorCode.FLAG_NOT_FOUND, + "error_message": str(exception), + }, + ) + + +def test_build_args_without_metadata(): + hook = LoggingHook() + ctx = HookContext( + flag_key="flag", + flag_type=FlagType.STRING, + default_value="default", + evaluation_context=EvaluationContext(None, {}), + client_metadata=None, + provider_metadata=None, + ) + result = hook._build_args(ctx) + assert result == { + "flag_key": "flag", + "default_value": "default", + "domain": None, + "provider_name": None, + } + + +def test_build_args_excludes_evaluation_context_by_default(hook_context): + hook = LoggingHook() + result = hook._build_args(hook_context) + assert result == { + "flag_key": "my-flag", + "default_value": False, + "domain": "my-domain", + "provider_name": "my-provider", + } + + +def test_build_args_includes_evaluation_context_when_enabled(hook_context): + hook = LoggingHook(include_evaluation_context=True) + result = hook._build_args(hook_context) + assert result == { + "flag_key": "my-flag", + "default_value": False, + "domain": "my-domain", + "provider_name": "my-provider", + "evaluation_context": { + "targeting_key": "user-1", + "attributes": {"env": "prod"}, + }, + } From 4b0f7eef13b2add25edb01d092ef0549b0262897 Mon Sep 17 00:00:00 2001 From: Danju Visvanathan Date: Sat, 14 Mar 2026 22:37:10 +1100 Subject: [PATCH 02/11] refactor: split logginghook into separate module Signed-off-by: Danju Visvanathan --- openfeature/hook/__init__.py | 204 +------------------------------ openfeature/hook/hook.py | 137 +++++++++++++++++++++ openfeature/hook/logging_hook.py | 74 +++++++++++ tests/hook/test_logging_hook.py | 31 +++-- 4 files changed, 236 insertions(+), 210 deletions(-) create mode 100644 openfeature/hook/hook.py create mode 100644 openfeature/hook/logging_hook.py diff --git a/openfeature/hook/__init__.py b/openfeature/hook/__init__.py index f67fc1b2..da2f5ca5 100644 --- a/openfeature/hook/__init__.py +++ b/openfeature/hook/__init__.py @@ -1,18 +1,5 @@ -from __future__ import annotations - -import logging -import typing -from collections.abc import Mapping, MutableMapping, Sequence -from datetime import datetime -from enum import Enum - -from openfeature.evaluation_context import EvaluationContext -from openfeature.exception import ErrorCode, OpenFeatureError -from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, FlagValueType - -if typing.TYPE_CHECKING: - from openfeature.client import ClientMetadata - from openfeature.provider.metadata import Metadata +from openfeature.hook.hook import Hook, HookContext, HookData, HookHints, HookType +from openfeature.hook.logging_hook import LoggingHook __all__ = [ "Hook", @@ -29,131 +16,6 @@ _hooks: list[Hook] = [] -# https://openfeature.dev/specification/sections/hooks/#requirement-461 -HookData = MutableMapping[str, typing.Any] - - -class HookType(Enum): - BEFORE = "before" - AFTER = "after" - FINALLY_AFTER = "finally_after" - ERROR = "error" - - -class HookContext: - def __init__( # noqa: PLR0913 - self, - flag_key: str, - flag_type: FlagType, - default_value: FlagValueType, - evaluation_context: EvaluationContext, - client_metadata: ClientMetadata | None = None, - provider_metadata: Metadata | None = None, - hook_data: HookData | None = None, - ): - self.flag_key = flag_key - self.flag_type = flag_type - self.default_value = default_value - self.evaluation_context = evaluation_context - self.client_metadata = client_metadata - self.provider_metadata = provider_metadata - self.hook_data = hook_data or {} - - def __setattr__(self, key: str, value: typing.Any) -> None: - if hasattr(self, key) and key in ( - "flag_key", - "flag_type", - "default_value", - "client_metadata", - "provider_metadata", - ): - raise AttributeError(f"Attribute {key!r} is immutable") - super().__setattr__(key, value) - - -# https://openfeature.dev/specification/sections/hooks/#requirement-421 -HookHintValue: typing.TypeAlias = ( - bool - | int - | float - | str - | datetime - | Sequence["HookHintValue"] - | Mapping[str, "HookHintValue"] -) - -HookHints = Mapping[str, HookHintValue] - - -class Hook: - def before( - self, hook_context: HookContext, hints: HookHints - ) -> EvaluationContext | None: - """ - Runs before flag is resolved. - - :param hook_context: Information about the particular flag evaluation - :param hints: An immutable mapping of data for users to - communicate to the hooks. - :return: An EvaluationContext. It will be merged with the - EvaluationContext instances from other hooks, the client and API. - """ - return None - - def after( - self, - hook_context: HookContext, - details: FlagEvaluationDetails[FlagValueType], - hints: HookHints, - ) -> None: - """ - Runs after a flag is resolved. - - :param hook_context: Information about the particular flag evaluation - :param details: Information about how the flag was resolved, - including any resolved values. - :param hints: A mapping of data for users to communicate to the hooks. - """ - pass - - def error( - self, hook_context: HookContext, exception: Exception, hints: HookHints - ) -> None: - """ - Run when evaluation encounters an error. Errors thrown will be swallowed. - - :param hook_context: Information about the particular flag evaluation - :param exception: The exception that was thrown - :param hints: A mapping of data for users to communicate to the hooks. - """ - pass - - def finally_after( - self, - hook_context: HookContext, - details: FlagEvaluationDetails[FlagValueType], - hints: HookHints, - ) -> None: - """ - Run after flag evaluation, including any error processing. - This will always run. Errors will be swallowed. - - :param hook_context: Information about the particular flag evaluation - :param hints: A mapping of data for users to communicate to the hooks. - """ - pass - - def supports_flag_value_type(self, flag_type: FlagType) -> bool: - """ - Check to see if the hook supports the particular flag type. - - :param flag_type: particular type of the flag - :return: a boolean containing whether the flag type is supported (True) - or not (False) - """ - return True - - def add_hooks(hooks: list[Hook]) -> None: global _hooks _hooks = _hooks + hooks @@ -166,65 +28,3 @@ def clear_hooks() -> None: def get_hooks() -> list[Hook]: return _hooks - - -class LoggingHook(Hook): - def __init__( - self, - include_evaluation_context: bool = False, - logger: logging.Logger | None = None, - ): - self.logger = logger or logging.getLogger("openfeature") - self.include_evaluation_context = include_evaluation_context - - def _build_args(self, hook_context: HookContext) -> dict: - args = { - "domain": hook_context.client_metadata.domain - if hook_context.client_metadata - else None, - "provider_name": hook_context.provider_metadata.name - if hook_context.provider_metadata - else None, - "flag_key": hook_context.flag_key, - "default_value": hook_context.default_value, - } - if self.include_evaluation_context: - args["evaluation_context"] = { - "targeting_key": hook_context.evaluation_context.targeting_key, - "attributes": hook_context.evaluation_context.attributes, - } - return args - - def before( - self, hook_context: HookContext, hints: HookHints - ) -> EvaluationContext | None: - args = self._build_args(hook_context) - args["stage"] = "before" - self.logger.debug("Before stage %s", args) - return None - - def after( - self, - hook_context: HookContext, - details: FlagEvaluationDetails[FlagValueType], - hints: HookHints, - ) -> None: - args = self._build_args(hook_context) - extra_args = { - "stage": "after", - "reason": details.reason, - "variant": details.variant, - "value": details.value, - } - self.logger.debug("After stage %s", {**args, **extra_args}) - - def error( - self, hook_context: HookContext, exception: Exception, hints: HookHints - ) -> None: - args = self._build_args(hook_context) - extra_args = { - "stage": "error", - "error_code": exception.error_code if isinstance(exception, OpenFeatureError) else ErrorCode.GENERAL, - "error_message": str(exception), - } - self.logger.error("Error stage %s", {**args, **extra_args}) diff --git a/openfeature/hook/hook.py b/openfeature/hook/hook.py new file mode 100644 index 00000000..1820c2cf --- /dev/null +++ b/openfeature/hook/hook.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import typing +from collections.abc import Mapping, MutableMapping, Sequence +from datetime import datetime +from enum import Enum + +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, FlagValueType + +if typing.TYPE_CHECKING: + from openfeature.client import ClientMetadata + from openfeature.provider.metadata import Metadata + +# https://openfeature.dev/specification/sections/hooks/#requirement-461 +HookData = MutableMapping[str, typing.Any] + + +class HookType(Enum): + BEFORE = "before" + AFTER = "after" + FINALLY_AFTER = "finally_after" + ERROR = "error" + + +class HookContext: + def __init__( # noqa: PLR0913 + self, + flag_key: str, + flag_type: FlagType, + default_value: FlagValueType, + evaluation_context: EvaluationContext, + client_metadata: ClientMetadata | None = None, + provider_metadata: Metadata | None = None, + hook_data: HookData | None = None, + ): + self.flag_key = flag_key + self.flag_type = flag_type + self.default_value = default_value + self.evaluation_context = evaluation_context + self.client_metadata = client_metadata + self.provider_metadata = provider_metadata + self.hook_data = hook_data or {} + + def __setattr__(self, key: str, value: typing.Any) -> None: + if hasattr(self, key) and key in ( + "flag_key", + "flag_type", + "default_value", + "client_metadata", + "provider_metadata", + ): + raise AttributeError(f"Attribute {key!r} is immutable") + super().__setattr__(key, value) + + +# https://openfeature.dev/specification/sections/hooks/#requirement-421 +HookHintValue: typing.TypeAlias = ( + bool + | int + | float + | str + | datetime + | Sequence["HookHintValue"] + | Mapping[str, "HookHintValue"] +) + +HookHints = Mapping[str, HookHintValue] + + +class Hook: + def before( + self, hook_context: HookContext, hints: HookHints + ) -> EvaluationContext | None: + """ + Runs before flag is resolved. + + :param hook_context: Information about the particular flag evaluation + :param hints: An immutable mapping of data for users to + communicate to the hooks. + :return: An EvaluationContext. It will be merged with the + EvaluationContext instances from other hooks, the client and API. + """ + return None + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + """ + Runs after a flag is resolved. + + :param hook_context: Information about the particular flag evaluation + :param details: Information about how the flag was resolved, + including any resolved values. + :param hints: A mapping of data for users to communicate to the hooks. + """ + pass + + def error( + self, hook_context: HookContext, exception: Exception, hints: HookHints + ) -> None: + """ + Run when evaluation encounters an error. Errors thrown will be swallowed. + + :param hook_context: Information about the particular flag evaluation + :param exception: The exception that was thrown + :param hints: A mapping of data for users to communicate to the hooks. + """ + pass + + def finally_after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + """ + Run after flag evaluation, including any error processing. + This will always run. Errors will be swallowed. + + :param hook_context: Information about the particular flag evaluation + :param hints: A mapping of data for users to communicate to the hooks. + """ + pass + + def supports_flag_value_type(self, flag_type: FlagType) -> bool: + """ + Check to see if the hook supports the particular flag type. + + :param flag_type: particular type of the flag + :return: a boolean containing whether the flag type is supported (True) + or not (False) + """ + return True diff --git a/openfeature/hook/logging_hook.py b/openfeature/hook/logging_hook.py new file mode 100644 index 00000000..e5197d95 --- /dev/null +++ b/openfeature/hook/logging_hook.py @@ -0,0 +1,74 @@ +import json +import logging + +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ErrorCode, OpenFeatureError +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagValueType +from openfeature.hook.hook import Hook, HookContext, HookHints + + +class LoggingHook(Hook): + def __init__( + self, + include_evaluation_context: bool = False, + logger: logging.Logger | None = None, + ): + self.logger = logger or logging.getLogger("openfeature") + self.include_evaluation_context = include_evaluation_context + + def _build_args(self, hook_context: HookContext) -> dict: + args = { + "domain": hook_context.client_metadata.domain + if hook_context.client_metadata + else None, + "provider_name": hook_context.provider_metadata.name + if hook_context.provider_metadata + else None, + "flag_key": hook_context.flag_key, + "default_value": hook_context.default_value, + } + if self.include_evaluation_context: + args["evaluation_context"] = json.dumps( + { + "targeting_key": hook_context.evaluation_context.targeting_key, + "attributes": hook_context.evaluation_context.attributes, + }, + default=str, + ) + return args + + def before( + self, hook_context: HookContext, hints: HookHints + ) -> EvaluationContext | None: + args = self._build_args(hook_context) + args["stage"] = "before" + self.logger.debug("Before stage %s", args) + return None + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + args = self._build_args(hook_context) + extra_args = { + "stage": "after", + "reason": details.reason, + "variant": details.variant, + "value": details.value, + } + self.logger.debug("After stage %s", {**args, **extra_args}) + + def error( + self, hook_context: HookContext, exception: Exception, hints: HookHints + ) -> None: + args = self._build_args(hook_context) + extra_args = { + "stage": "error", + "error_code": exception.error_code + if isinstance(exception, OpenFeatureError) + else ErrorCode.GENERAL, + "error_message": str(exception), + } + self.logger.error("Error stage %s", {**args, **extra_args}) diff --git a/tests/hook/test_logging_hook.py b/tests/hook/test_logging_hook.py index ebebe649..09dc577c 100644 --- a/tests/hook/test_logging_hook.py +++ b/tests/hook/test_logging_hook.py @@ -86,10 +86,7 @@ def test_after_calls_debug_with_evaluation_context(hook_context): "reason": "STATIC", "variant": "on", "value": True, - "evaluation_context": { - "targeting_key": "user-1", - "attributes": {"env": "prod"}, - }, + "evaluation_context": '{"targeting_key": "user-1", "attributes": {"env": "prod"}}', }, ) @@ -172,8 +169,26 @@ def test_build_args_includes_evaluation_context_when_enabled(hook_context): "default_value": False, "domain": "my-domain", "provider_name": "my-provider", - "evaluation_context": { - "targeting_key": "user-1", - "attributes": {"env": "prod"}, - }, + "evaluation_context": '{"targeting_key": "user-1", "attributes": {"env": "prod"}}', } + + +def test_error_calls_error_log_with_evaluation_context(hook_context): + mock_logger = MagicMock() + hook = LoggingHook(logger=mock_logger, include_evaluation_context=True) + exception = Exception("something went wrong") + hook.error(hook_context, exception, hints={}) + + mock_logger.error.assert_called_with( + "Error stage %s", + { + "stage": "error", + "flag_key": "my-flag", + "default_value": False, + "domain": "my-domain", + "provider_name": "my-provider", + "evaluation_context": '{"targeting_key": "user-1", "attributes": {"env": "prod"}}', + "error_code": ErrorCode.GENERAL, + "error_message": "something went wrong", + }, + ) From 2b7f68cbadc7c8442c197f1b3e609500fb1993c4 Mon Sep 17 00:00:00 2001 From: Danju Visvanathan Date: Sat, 14 Mar 2026 22:39:08 +1100 Subject: [PATCH 03/11] chore: update README.md Signed-off-by: Danju Visvanathan --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index ccec0686..616a0a33 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,17 @@ client.get_boolean_flag("my-flag", False, flag_evaluation_options=options) The OpenFeature SDK logs to the `openfeature` logger using the `logging` package from the Python Standard Library. +#### Logging Hook + +The Python SDK includes a `LoggingHook`, which logs detailed information at key points during flag evaluation, using the `logging` package. This hook can be particularly helpful for troubleshooting and debugging; simply attach it at the global, client or invocation level and ensure your log level is set to "debug". + +```python +from openfeature import api +from openfeature.hook import LoggingHook + +api.add_hooks([LoggingHook()]) +``` + ### Domains Clients can be assigned to a domain. From 256e94fed45989ce7c8f8d8fc1ae84879b66d63b Mon Sep 17 00:00:00 2001 From: Danju Visvanathan Date: Sun, 15 Mar 2026 02:19:32 +1100 Subject: [PATCH 04/11] fix: consistent appending to args Signed-off-by: Danju Visvanathan --- openfeature/hook/logging_hook.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/openfeature/hook/logging_hook.py b/openfeature/hook/logging_hook.py index e5197d95..4bd1f59a 100644 --- a/openfeature/hook/logging_hook.py +++ b/openfeature/hook/logging_hook.py @@ -52,23 +52,17 @@ def after( hints: HookHints, ) -> None: args = self._build_args(hook_context) - extra_args = { - "stage": "after", - "reason": details.reason, - "variant": details.variant, - "value": details.value, - } - self.logger.debug("After stage %s", {**args, **extra_args}) + args["stage"] = "after" + args["reason"] = details.reason + args["variant"] = details.variant + args["value"] = details.value + self.logger.debug("After stage %s", args) def error( self, hook_context: HookContext, exception: Exception, hints: HookHints ) -> None: args = self._build_args(hook_context) - extra_args = { - "stage": "error", - "error_code": exception.error_code - if isinstance(exception, OpenFeatureError) - else ErrorCode.GENERAL, - "error_message": str(exception), - } - self.logger.error("Error stage %s", {**args, **extra_args}) + args["stage"] = "error" + args["error_code"] = exception.error_code if isinstance(exception, OpenFeatureError) else ErrorCode.GENERAL + args["error_message"] = str(exception) + self.logger.error("Error stage %s", args) From dafe7b6396d7afeb962698adbb64357671515107 Mon Sep 17 00:00:00 2001 From: Danju Visvanathan Date: Sun, 15 Mar 2026 02:20:18 +1100 Subject: [PATCH 05/11] chore: fmt Signed-off-by: Danju Visvanathan --- openfeature/hook/logging_hook.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openfeature/hook/logging_hook.py b/openfeature/hook/logging_hook.py index 4bd1f59a..e90cf3b6 100644 --- a/openfeature/hook/logging_hook.py +++ b/openfeature/hook/logging_hook.py @@ -63,6 +63,10 @@ def error( ) -> None: args = self._build_args(hook_context) args["stage"] = "error" - args["error_code"] = exception.error_code if isinstance(exception, OpenFeatureError) else ErrorCode.GENERAL + args["error_code"] = ( + exception.error_code + if isinstance(exception, OpenFeatureError) + else ErrorCode.GENERAL + ) args["error_message"] = str(exception) self.logger.error("Error stage %s", args) From 7a39376449a4c53976d2b3cd73df7b7e608e245d Mon Sep 17 00:00:00 2001 From: Danju Visvanathan Date: Sun, 15 Mar 2026 21:18:27 +1100 Subject: [PATCH 06/11] revert: refactor of hook package Signed-off-by: Danju Visvanathan --- openfeature/hook/__init__.py | 141 ++++++++++++++++++++++++++++++- openfeature/hook/hook.py | 137 ------------------------------ openfeature/hook/logging_hook.py | 2 +- tests/hook/test_logging_hook.py | 2 +- 4 files changed, 140 insertions(+), 142 deletions(-) delete mode 100644 openfeature/hook/hook.py diff --git a/openfeature/hook/__init__.py b/openfeature/hook/__init__.py index da2f5ca5..247d316b 100644 --- a/openfeature/hook/__init__.py +++ b/openfeature/hook/__init__.py @@ -1,5 +1,16 @@ -from openfeature.hook.hook import Hook, HookContext, HookData, HookHints, HookType -from openfeature.hook.logging_hook import LoggingHook +from __future__ import annotations + +import typing +from collections.abc import Mapping, MutableMapping, Sequence +from datetime import datetime +from enum import Enum + +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, FlagValueType + +if typing.TYPE_CHECKING: + from openfeature.client import ClientMetadata + from openfeature.provider.metadata import Metadata __all__ = [ "Hook", @@ -7,7 +18,6 @@ "HookData", "HookHints", "HookType", - "LoggingHook", "add_hooks", "clear_hooks", "get_hooks", @@ -16,6 +26,131 @@ _hooks: list[Hook] = [] +# https://openfeature.dev/specification/sections/hooks/#requirement-461 +HookData = MutableMapping[str, typing.Any] + + +class HookType(Enum): + BEFORE = "before" + AFTER = "after" + FINALLY_AFTER = "finally_after" + ERROR = "error" + + +class HookContext: + def __init__( # noqa: PLR0913 + self, + flag_key: str, + flag_type: FlagType, + default_value: FlagValueType, + evaluation_context: EvaluationContext, + client_metadata: ClientMetadata | None = None, + provider_metadata: Metadata | None = None, + hook_data: HookData | None = None, + ): + self.flag_key = flag_key + self.flag_type = flag_type + self.default_value = default_value + self.evaluation_context = evaluation_context + self.client_metadata = client_metadata + self.provider_metadata = provider_metadata + self.hook_data = hook_data or {} + + def __setattr__(self, key: str, value: typing.Any) -> None: + if hasattr(self, key) and key in ( + "flag_key", + "flag_type", + "default_value", + "client_metadata", + "provider_metadata", + ): + raise AttributeError(f"Attribute {key!r} is immutable") + super().__setattr__(key, value) + + +# https://openfeature.dev/specification/sections/hooks/#requirement-421 +HookHintValue: typing.TypeAlias = ( + bool + | int + | float + | str + | datetime + | Sequence["HookHintValue"] + | Mapping[str, "HookHintValue"] +) + +HookHints = Mapping[str, HookHintValue] + + +class Hook: + def before( + self, hook_context: HookContext, hints: HookHints + ) -> EvaluationContext | None: + """ + Runs before flag is resolved. + + :param hook_context: Information about the particular flag evaluation + :param hints: An immutable mapping of data for users to + communicate to the hooks. + :return: An EvaluationContext. It will be merged with the + EvaluationContext instances from other hooks, the client and API. + """ + return None + + def after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + """ + Runs after a flag is resolved. + + :param hook_context: Information about the particular flag evaluation + :param details: Information about how the flag was resolved, + including any resolved values. + :param hints: A mapping of data for users to communicate to the hooks. + """ + pass + + def error( + self, hook_context: HookContext, exception: Exception, hints: HookHints + ) -> None: + """ + Run when evaluation encounters an error. Errors thrown will be swallowed. + + :param hook_context: Information about the particular flag evaluation + :param exception: The exception that was thrown + :param hints: A mapping of data for users to communicate to the hooks. + """ + pass + + def finally_after( + self, + hook_context: HookContext, + details: FlagEvaluationDetails[FlagValueType], + hints: HookHints, + ) -> None: + """ + Run after flag evaluation, including any error processing. + This will always run. Errors will be swallowed. + + :param hook_context: Information about the particular flag evaluation + :param hints: A mapping of data for users to communicate to the hooks. + """ + pass + + def supports_flag_value_type(self, flag_type: FlagType) -> bool: + """ + Check to see if the hook supports the particular flag type. + + :param flag_type: particular type of the flag + :return: a boolean containing whether the flag type is supported (True) + or not (False) + """ + return True + + def add_hooks(hooks: list[Hook]) -> None: global _hooks _hooks = _hooks + hooks diff --git a/openfeature/hook/hook.py b/openfeature/hook/hook.py deleted file mode 100644 index 1820c2cf..00000000 --- a/openfeature/hook/hook.py +++ /dev/null @@ -1,137 +0,0 @@ -from __future__ import annotations - -import typing -from collections.abc import Mapping, MutableMapping, Sequence -from datetime import datetime -from enum import Enum - -from openfeature.evaluation_context import EvaluationContext -from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, FlagValueType - -if typing.TYPE_CHECKING: - from openfeature.client import ClientMetadata - from openfeature.provider.metadata import Metadata - -# https://openfeature.dev/specification/sections/hooks/#requirement-461 -HookData = MutableMapping[str, typing.Any] - - -class HookType(Enum): - BEFORE = "before" - AFTER = "after" - FINALLY_AFTER = "finally_after" - ERROR = "error" - - -class HookContext: - def __init__( # noqa: PLR0913 - self, - flag_key: str, - flag_type: FlagType, - default_value: FlagValueType, - evaluation_context: EvaluationContext, - client_metadata: ClientMetadata | None = None, - provider_metadata: Metadata | None = None, - hook_data: HookData | None = None, - ): - self.flag_key = flag_key - self.flag_type = flag_type - self.default_value = default_value - self.evaluation_context = evaluation_context - self.client_metadata = client_metadata - self.provider_metadata = provider_metadata - self.hook_data = hook_data or {} - - def __setattr__(self, key: str, value: typing.Any) -> None: - if hasattr(self, key) and key in ( - "flag_key", - "flag_type", - "default_value", - "client_metadata", - "provider_metadata", - ): - raise AttributeError(f"Attribute {key!r} is immutable") - super().__setattr__(key, value) - - -# https://openfeature.dev/specification/sections/hooks/#requirement-421 -HookHintValue: typing.TypeAlias = ( - bool - | int - | float - | str - | datetime - | Sequence["HookHintValue"] - | Mapping[str, "HookHintValue"] -) - -HookHints = Mapping[str, HookHintValue] - - -class Hook: - def before( - self, hook_context: HookContext, hints: HookHints - ) -> EvaluationContext | None: - """ - Runs before flag is resolved. - - :param hook_context: Information about the particular flag evaluation - :param hints: An immutable mapping of data for users to - communicate to the hooks. - :return: An EvaluationContext. It will be merged with the - EvaluationContext instances from other hooks, the client and API. - """ - return None - - def after( - self, - hook_context: HookContext, - details: FlagEvaluationDetails[FlagValueType], - hints: HookHints, - ) -> None: - """ - Runs after a flag is resolved. - - :param hook_context: Information about the particular flag evaluation - :param details: Information about how the flag was resolved, - including any resolved values. - :param hints: A mapping of data for users to communicate to the hooks. - """ - pass - - def error( - self, hook_context: HookContext, exception: Exception, hints: HookHints - ) -> None: - """ - Run when evaluation encounters an error. Errors thrown will be swallowed. - - :param hook_context: Information about the particular flag evaluation - :param exception: The exception that was thrown - :param hints: A mapping of data for users to communicate to the hooks. - """ - pass - - def finally_after( - self, - hook_context: HookContext, - details: FlagEvaluationDetails[FlagValueType], - hints: HookHints, - ) -> None: - """ - Run after flag evaluation, including any error processing. - This will always run. Errors will be swallowed. - - :param hook_context: Information about the particular flag evaluation - :param hints: A mapping of data for users to communicate to the hooks. - """ - pass - - def supports_flag_value_type(self, flag_type: FlagType) -> bool: - """ - Check to see if the hook supports the particular flag type. - - :param flag_type: particular type of the flag - :return: a boolean containing whether the flag type is supported (True) - or not (False) - """ - return True diff --git a/openfeature/hook/logging_hook.py b/openfeature/hook/logging_hook.py index e90cf3b6..68909b1a 100644 --- a/openfeature/hook/logging_hook.py +++ b/openfeature/hook/logging_hook.py @@ -4,7 +4,7 @@ from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ErrorCode, OpenFeatureError from openfeature.flag_evaluation import FlagEvaluationDetails, FlagValueType -from openfeature.hook.hook import Hook, HookContext, HookHints +from openfeature.hook import Hook, HookContext, HookHints class LoggingHook(Hook): diff --git a/tests/hook/test_logging_hook.py b/tests/hook/test_logging_hook.py index 09dc577c..fa3e8ca5 100644 --- a/tests/hook/test_logging_hook.py +++ b/tests/hook/test_logging_hook.py @@ -6,7 +6,7 @@ from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ErrorCode, FlagNotFoundError from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType -from openfeature.hook import HookContext, LoggingHook +from openfeature.hook.logging_hook import HookContext, LoggingHook from openfeature.provider.metadata import Metadata From 8db2fc2817404e452e87224964d448b0d2cea234 Mon Sep 17 00:00:00 2001 From: Danju Visvanathan Date: Sun, 15 Mar 2026 21:20:51 +1100 Subject: [PATCH 07/11] fix: parameterise stage key Signed-off-by: Danju Visvanathan --- openfeature/hook/logging_hook.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openfeature/hook/logging_hook.py b/openfeature/hook/logging_hook.py index 68909b1a..0dc90ced 100644 --- a/openfeature/hook/logging_hook.py +++ b/openfeature/hook/logging_hook.py @@ -16,7 +16,7 @@ def __init__( self.logger = logger or logging.getLogger("openfeature") self.include_evaluation_context = include_evaluation_context - def _build_args(self, hook_context: HookContext) -> dict: + def _build_args(self, hook_context: HookContext, stage: str) -> dict: args = { "domain": hook_context.client_metadata.domain if hook_context.client_metadata @@ -26,6 +26,7 @@ def _build_args(self, hook_context: HookContext) -> dict: else None, "flag_key": hook_context.flag_key, "default_value": hook_context.default_value, + "stage": stage, } if self.include_evaluation_context: args["evaluation_context"] = json.dumps( @@ -40,8 +41,7 @@ def _build_args(self, hook_context: HookContext) -> dict: def before( self, hook_context: HookContext, hints: HookHints ) -> EvaluationContext | None: - args = self._build_args(hook_context) - args["stage"] = "before" + args = self._build_args(hook_context, "before") self.logger.debug("Before stage %s", args) return None @@ -51,8 +51,7 @@ def after( details: FlagEvaluationDetails[FlagValueType], hints: HookHints, ) -> None: - args = self._build_args(hook_context) - args["stage"] = "after" + args = self._build_args(hook_context, "after") args["reason"] = details.reason args["variant"] = details.variant args["value"] = details.value @@ -61,7 +60,7 @@ def after( def error( self, hook_context: HookContext, exception: Exception, hints: HookHints ) -> None: - args = self._build_args(hook_context) + args = self._build_args(hook_context, "error") args["stage"] = "error" args["error_code"] = ( exception.error_code From 5ae437f76b3a58db7a17fa3b38f916b338117be4 Mon Sep 17 00:00:00 2001 From: Danju Visvanathan Date: Sun, 15 Mar 2026 22:45:55 +1100 Subject: [PATCH 08/11] refactor: parameterise stage value Signed-off-by: Danju Visvanathan --- README.md | 2 +- openfeature/hook/logging_hook.py | 1 - tests/hook/test_logging_hook.py | 9 ++++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 616a0a33..09e8dee9 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ The Python SDK includes a `LoggingHook`, which logs detailed information at key ```python from openfeature import api -from openfeature.hook import LoggingHook +from openfeature.hook.logging_hook import LoggingHook api.add_hooks([LoggingHook()]) ``` diff --git a/openfeature/hook/logging_hook.py b/openfeature/hook/logging_hook.py index 0dc90ced..dedc3f8c 100644 --- a/openfeature/hook/logging_hook.py +++ b/openfeature/hook/logging_hook.py @@ -61,7 +61,6 @@ def error( self, hook_context: HookContext, exception: Exception, hints: HookHints ) -> None: args = self._build_args(hook_context, "error") - args["stage"] = "error" args["error_code"] = ( exception.error_code if isinstance(exception, OpenFeatureError) diff --git a/tests/hook/test_logging_hook.py b/tests/hook/test_logging_hook.py index fa3e8ca5..55faee14 100644 --- a/tests/hook/test_logging_hook.py +++ b/tests/hook/test_logging_hook.py @@ -141,35 +141,38 @@ def test_build_args_without_metadata(): client_metadata=None, provider_metadata=None, ) - result = hook._build_args(ctx) + result = hook._build_args(ctx, "before") assert result == { "flag_key": "flag", "default_value": "default", "domain": None, "provider_name": None, + "stage": "before", } def test_build_args_excludes_evaluation_context_by_default(hook_context): hook = LoggingHook() - result = hook._build_args(hook_context) + result = hook._build_args(hook_context, "before") assert result == { "flag_key": "my-flag", "default_value": False, "domain": "my-domain", "provider_name": "my-provider", + "stage": "before", } def test_build_args_includes_evaluation_context_when_enabled(hook_context): hook = LoggingHook(include_evaluation_context=True) - result = hook._build_args(hook_context) + result = hook._build_args(hook_context, "after") assert result == { "flag_key": "my-flag", "default_value": False, "domain": "my-domain", "provider_name": "my-provider", "evaluation_context": '{"targeting_key": "user-1", "attributes": {"env": "prod"}}', + "stage": "after", } From 8b3bc3ab0727689030056b7070516a8bc7a62ef8 Mon Sep 17 00:00:00 2001 From: Danju Visvanathan Date: Sun, 15 Mar 2026 22:54:39 +1100 Subject: [PATCH 09/11] fix: use asdict() Signed-off-by: Danju Visvanathan --- openfeature/hook/logging_hook.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openfeature/hook/logging_hook.py b/openfeature/hook/logging_hook.py index dedc3f8c..48a48190 100644 --- a/openfeature/hook/logging_hook.py +++ b/openfeature/hook/logging_hook.py @@ -1,5 +1,6 @@ import json import logging +from dataclasses import asdict from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ErrorCode, OpenFeatureError @@ -30,10 +31,7 @@ def _build_args(self, hook_context: HookContext, stage: str) -> dict: } if self.include_evaluation_context: args["evaluation_context"] = json.dumps( - { - "targeting_key": hook_context.evaluation_context.targeting_key, - "attributes": hook_context.evaluation_context.attributes, - }, + asdict(hook_context.evaluation_context), default=str, ) return args From 22d8203f0d1e208cf569579e8893cc7aa27c2ca3 Mon Sep 17 00:00:00 2001 From: Danju Visvanathan Date: Sun, 15 Mar 2026 22:58:23 +1100 Subject: [PATCH 10/11] fix: change log message to not duplicate stage Signed-off-by: Danju Visvanathan --- openfeature/hook/logging_hook.py | 6 +++--- tests/hook/test_logging_hook.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/openfeature/hook/logging_hook.py b/openfeature/hook/logging_hook.py index 48a48190..b0e2307e 100644 --- a/openfeature/hook/logging_hook.py +++ b/openfeature/hook/logging_hook.py @@ -40,7 +40,7 @@ def before( self, hook_context: HookContext, hints: HookHints ) -> EvaluationContext | None: args = self._build_args(hook_context, "before") - self.logger.debug("Before stage %s", args) + self.logger.debug("Flag evaluation %s", args) return None def after( @@ -53,7 +53,7 @@ def after( args["reason"] = details.reason args["variant"] = details.variant args["value"] = details.value - self.logger.debug("After stage %s", args) + self.logger.debug("Flag evaluation %s", args) def error( self, hook_context: HookContext, exception: Exception, hints: HookHints @@ -65,4 +65,4 @@ def error( else ErrorCode.GENERAL ) args["error_message"] = str(exception) - self.logger.error("Error stage %s", args) + self.logger.error("Flag evaluation %s", args) diff --git a/tests/hook/test_logging_hook.py b/tests/hook/test_logging_hook.py index 55faee14..6490e47a 100644 --- a/tests/hook/test_logging_hook.py +++ b/tests/hook/test_logging_hook.py @@ -27,7 +27,7 @@ def test_before_calls_debug_with_stage(hook_context): hook = LoggingHook(logger=mock_logger) hook.before(hook_context, hints={}) mock_logger.debug.assert_called_with( - "Before stage %s", + "Flag evaluation %s", { "stage": "before", "flag_key": "my-flag", @@ -50,7 +50,7 @@ def test_after_calls_debug_with_stage(hook_context): hook.after(hook_context, details, hints={}) mock_logger.debug.assert_called_with( - "After stage %s", + "Flag evaluation %s", { "stage": "after", "flag_key": "my-flag", @@ -76,7 +76,7 @@ def test_after_calls_debug_with_evaluation_context(hook_context): hook.after(hook_context, details, hints={}) mock_logger.debug.assert_called_with( - "After stage %s", + "Flag evaluation %s", { "stage": "after", "flag_key": "my-flag", @@ -98,7 +98,7 @@ def test_error_calls_error_log(hook_context): hook.error(hook_context, exception, hints={}) mock_logger.error.assert_called_with( - "Error stage %s", + "Flag evaluation %s", { "stage": "error", "flag_key": "my-flag", @@ -118,7 +118,7 @@ def test_error_extracts_error_code_from_open_feature_error(hook_context): hook.error(hook_context, exception, hints={}) mock_logger.error.assert_called_with( - "Error stage %s", + "Flag evaluation %s", { "stage": "error", "flag_key": "my-flag", @@ -183,7 +183,7 @@ def test_error_calls_error_log_with_evaluation_context(hook_context): hook.error(hook_context, exception, hints={}) mock_logger.error.assert_called_with( - "Error stage %s", + "Flag evaluation %s", { "stage": "error", "flag_key": "my-flag", From c5f6b69afab87fe86b274bdfe9de620cede69059 Mon Sep 17 00:00:00 2001 From: Danju Visvanathan Date: Sun, 15 Mar 2026 23:17:54 +1100 Subject: [PATCH 11/11] fix: propogate error_message to avoid silently dropping it Signed-off-by: Danju Visvanathan --- openfeature/hook/logging_hook.py | 12 ++++++------ tests/hook/test_logging_hook.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openfeature/hook/logging_hook.py b/openfeature/hook/logging_hook.py index b0e2307e..00294b76 100644 --- a/openfeature/hook/logging_hook.py +++ b/openfeature/hook/logging_hook.py @@ -59,10 +59,10 @@ def error( self, hook_context: HookContext, exception: Exception, hints: HookHints ) -> None: args = self._build_args(hook_context, "error") - args["error_code"] = ( - exception.error_code - if isinstance(exception, OpenFeatureError) - else ErrorCode.GENERAL - ) - args["error_message"] = str(exception) + if isinstance(exception, OpenFeatureError): + args["error_code"] = exception.error_code + args["error_message"] = exception.error_message + else: + args["error_code"] = ErrorCode.GENERAL + args["error_message"] = str(exception) self.logger.error("Flag evaluation %s", args) diff --git a/tests/hook/test_logging_hook.py b/tests/hook/test_logging_hook.py index 6490e47a..c3cd09a0 100644 --- a/tests/hook/test_logging_hook.py +++ b/tests/hook/test_logging_hook.py @@ -126,7 +126,7 @@ def test_error_extracts_error_code_from_open_feature_error(hook_context): "domain": "my-domain", "provider_name": "my-provider", "error_code": ErrorCode.FLAG_NOT_FOUND, - "error_message": str(exception), + "error_message": "flag not found", }, )