Skip to content

Commit 5e31dff

Browse files
committed
feat: add logging hook
Signed-off-by: Danju Visvanathan <danju.visvanathan@gmail.com>
1 parent a6366e2 commit 5e31dff

2 files changed

Lines changed: 244 additions & 0 deletions

File tree

openfeature/hook/__init__.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from __future__ import annotations
22

3+
import logging
34
import typing
45
from collections.abc import Mapping, MutableMapping, Sequence
56
from datetime import datetime
67
from enum import Enum
78

89
from openfeature.evaluation_context import EvaluationContext
10+
from openfeature.exception import ErrorCode, OpenFeatureError
911
from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, FlagValueType
1012

1113
if typing.TYPE_CHECKING:
@@ -18,6 +20,7 @@
1820
"HookData",
1921
"HookHints",
2022
"HookType",
23+
"LoggingHook",
2124
"add_hooks",
2225
"clear_hooks",
2326
"get_hooks",
@@ -163,3 +166,65 @@ def clear_hooks() -> None:
163166

164167
def get_hooks() -> list[Hook]:
165168
return _hooks
169+
170+
171+
class LoggingHook(Hook):
172+
def __init__(
173+
self,
174+
include_evaluation_context: bool = False,
175+
logger: logging.Logger | None = None,
176+
):
177+
self.logger = logger or logging.getLogger("openfeature")
178+
self.include_evaluation_context = include_evaluation_context
179+
180+
def _build_args(self, hook_context: HookContext) -> dict:
181+
args = {
182+
"domain": hook_context.client_metadata.domain
183+
if hook_context.client_metadata
184+
else None,
185+
"provider_name": hook_context.provider_metadata.name
186+
if hook_context.provider_metadata
187+
else None,
188+
"flag_key": hook_context.flag_key,
189+
"default_value": hook_context.default_value,
190+
}
191+
if self.include_evaluation_context:
192+
args["evaluation_context"] = {
193+
"targeting_key": hook_context.evaluation_context.targeting_key,
194+
"attributes": hook_context.evaluation_context.attributes,
195+
}
196+
return args
197+
198+
def before(
199+
self, hook_context: HookContext, hints: HookHints
200+
) -> EvaluationContext | None:
201+
args = self._build_args(hook_context)
202+
args["stage"] = "before"
203+
self.logger.debug("Before stage %s", args)
204+
return None
205+
206+
def after(
207+
self,
208+
hook_context: HookContext,
209+
details: FlagEvaluationDetails[FlagValueType],
210+
hints: HookHints,
211+
) -> None:
212+
args = self._build_args(hook_context)
213+
extra_args = {
214+
"stage": "after",
215+
"reason": details.reason,
216+
"variant": details.variant,
217+
"value": details.value,
218+
}
219+
self.logger.debug("After stage %s", {**args, **extra_args})
220+
221+
def error(
222+
self, hook_context: HookContext, exception: Exception, hints: HookHints
223+
) -> None:
224+
args = self._build_args(hook_context)
225+
extra_args = {
226+
"stage": "error",
227+
"error_code": exception.error_code if isinstance(exception, OpenFeatureError) else ErrorCode.GENERAL,
228+
"error_message": str(exception),
229+
}
230+
self.logger.error("Error stage %s", {**args, **extra_args})

tests/hook/test_logging_hook.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
from unittest.mock import MagicMock
2+
3+
import pytest
4+
5+
from openfeature.client import ClientMetadata
6+
from openfeature.evaluation_context import EvaluationContext
7+
from openfeature.exception import ErrorCode, FlagNotFoundError
8+
from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType
9+
from openfeature.hook import HookContext, LoggingHook
10+
from openfeature.provider.metadata import Metadata
11+
12+
13+
@pytest.fixture()
14+
def hook_context():
15+
return HookContext(
16+
flag_key="my-flag",
17+
flag_type=FlagType.BOOLEAN,
18+
default_value=False,
19+
evaluation_context=EvaluationContext("user-1", {"env": "prod"}),
20+
client_metadata=ClientMetadata(domain="my-domain"),
21+
provider_metadata=Metadata(name="my-provider"),
22+
)
23+
24+
25+
def test_before_calls_debug_with_stage(hook_context):
26+
mock_logger = MagicMock()
27+
hook = LoggingHook(logger=mock_logger)
28+
hook.before(hook_context, hints={})
29+
mock_logger.debug.assert_called_with(
30+
"Before stage %s",
31+
{
32+
"stage": "before",
33+
"flag_key": "my-flag",
34+
"default_value": False,
35+
"domain": "my-domain",
36+
"provider_name": "my-provider",
37+
},
38+
)
39+
40+
41+
def test_after_calls_debug_with_stage(hook_context):
42+
mock_logger = MagicMock()
43+
hook = LoggingHook(logger=mock_logger)
44+
details = FlagEvaluationDetails(
45+
flag_key="my-flag",
46+
value=True,
47+
variant="on",
48+
reason="STATIC",
49+
)
50+
hook.after(hook_context, details, hints={})
51+
52+
mock_logger.debug.assert_called_with(
53+
"After stage %s",
54+
{
55+
"stage": "after",
56+
"flag_key": "my-flag",
57+
"default_value": False,
58+
"domain": "my-domain",
59+
"provider_name": "my-provider",
60+
"reason": "STATIC",
61+
"variant": "on",
62+
"value": True,
63+
},
64+
)
65+
66+
67+
def test_after_calls_debug_with_evaluation_context(hook_context):
68+
mock_logger = MagicMock()
69+
hook = LoggingHook(logger=mock_logger, include_evaluation_context=True)
70+
details = FlagEvaluationDetails(
71+
flag_key="my-flag",
72+
value=True,
73+
variant="on",
74+
reason="STATIC",
75+
)
76+
hook.after(hook_context, details, hints={})
77+
78+
mock_logger.debug.assert_called_with(
79+
"After stage %s",
80+
{
81+
"stage": "after",
82+
"flag_key": "my-flag",
83+
"default_value": False,
84+
"domain": "my-domain",
85+
"provider_name": "my-provider",
86+
"reason": "STATIC",
87+
"variant": "on",
88+
"value": True,
89+
"evaluation_context": {
90+
"targeting_key": "user-1",
91+
"attributes": {"env": "prod"},
92+
},
93+
},
94+
)
95+
96+
97+
def test_error_calls_error_log(hook_context):
98+
mock_logger = MagicMock()
99+
hook = LoggingHook(logger=mock_logger)
100+
exception = Exception("something went wrong")
101+
hook.error(hook_context, exception, hints={})
102+
103+
mock_logger.error.assert_called_with(
104+
"Error stage %s",
105+
{
106+
"stage": "error",
107+
"flag_key": "my-flag",
108+
"default_value": False,
109+
"domain": "my-domain",
110+
"provider_name": "my-provider",
111+
"error_code": ErrorCode.GENERAL,
112+
"error_message": "something went wrong",
113+
},
114+
)
115+
116+
117+
def test_error_extracts_error_code_from_open_feature_error(hook_context):
118+
mock_logger = MagicMock()
119+
hook = LoggingHook(logger=mock_logger)
120+
exception = FlagNotFoundError("flag not found")
121+
hook.error(hook_context, exception, hints={})
122+
123+
mock_logger.error.assert_called_with(
124+
"Error stage %s",
125+
{
126+
"stage": "error",
127+
"flag_key": "my-flag",
128+
"default_value": False,
129+
"domain": "my-domain",
130+
"provider_name": "my-provider",
131+
"error_code": ErrorCode.FLAG_NOT_FOUND,
132+
"error_message": str(exception),
133+
},
134+
)
135+
136+
137+
def test_build_args_without_metadata():
138+
hook = LoggingHook()
139+
ctx = HookContext(
140+
flag_key="flag",
141+
flag_type=FlagType.STRING,
142+
default_value="default",
143+
evaluation_context=EvaluationContext(None, {}),
144+
client_metadata=None,
145+
provider_metadata=None,
146+
)
147+
result = hook._build_args(ctx)
148+
assert result == {
149+
"flag_key": "flag",
150+
"default_value": "default",
151+
"domain": None,
152+
"provider_name": None,
153+
}
154+
155+
156+
def test_build_args_excludes_evaluation_context_by_default(hook_context):
157+
hook = LoggingHook()
158+
result = hook._build_args(hook_context)
159+
assert result == {
160+
"flag_key": "my-flag",
161+
"default_value": False,
162+
"domain": "my-domain",
163+
"provider_name": "my-provider",
164+
}
165+
166+
167+
def test_build_args_includes_evaluation_context_when_enabled(hook_context):
168+
hook = LoggingHook(include_evaluation_context=True)
169+
result = hook._build_args(hook_context)
170+
assert result == {
171+
"flag_key": "my-flag",
172+
"default_value": False,
173+
"domain": "my-domain",
174+
"provider_name": "my-provider",
175+
"evaluation_context": {
176+
"targeting_key": "user-1",
177+
"attributes": {"env": "prod"},
178+
},
179+
}

0 commit comments

Comments
 (0)