Skip to content

Commit e3229fa

Browse files
committed
feat: add metrics hook
Signed-off-by: Danju Visvanathan <[email protected]>
1 parent 80da6b9 commit e3229fa

6 files changed

Lines changed: 319 additions & 81 deletions

File tree

Lines changed: 3 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,4 @@
1-
import json
1+
from .metric import MetricsHook
2+
from .trace import TracingHook
23

3-
from openfeature.exception import ErrorCode
4-
from openfeature.flag_evaluation import FlagEvaluationDetails, Reason
5-
from openfeature.hook import Hook, HookContext, HookHints
6-
from opentelemetry import trace
7-
from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
8-
9-
OTEL_EVENT_NAME = "feature_flag.evaluation"
10-
11-
12-
class EventAttributes:
13-
KEY = "feature_flag.key"
14-
RESULT_VALUE = "feature_flag.result.value"
15-
RESULT_VARIANT = "feature_flag.result.variant"
16-
CONTEXT_ID = "feature_flag.context.id"
17-
PROVIDER_NAME = "feature_flag.provider.name"
18-
RESULT_REASON = "feature_flag.result.reason"
19-
SET_ID = "feature_flag.set.id"
20-
VERSION = "feature_flag.version"
21-
22-
23-
class TracingHook(Hook):
24-
def __init__(self, exclude_exceptions: bool = False):
25-
self.exclude_exceptions = exclude_exceptions
26-
27-
def finally_after(
28-
self,
29-
hook_context: HookContext,
30-
details: FlagEvaluationDetails,
31-
hints: HookHints,
32-
) -> None:
33-
current_span = trace.get_current_span()
34-
35-
event_attributes = {
36-
EventAttributes.KEY: details.flag_key,
37-
EventAttributes.RESULT_VALUE: json.dumps(details.value),
38-
EventAttributes.RESULT_REASON: str(
39-
details.reason or Reason.UNKNOWN
40-
).lower(),
41-
}
42-
43-
if details.variant:
44-
event_attributes[EventAttributes.RESULT_VARIANT] = details.variant
45-
46-
if details.reason == Reason.ERROR:
47-
error_type = str(details.error_code or ErrorCode.GENERAL).lower()
48-
event_attributes[ERROR_TYPE] = error_type
49-
if details.error_message:
50-
event_attributes["error.message"] = details.error_message
51-
52-
context = hook_context.evaluation_context
53-
if context.targeting_key:
54-
event_attributes[EventAttributes.CONTEXT_ID] = context.targeting_key
55-
56-
if hook_context.provider_metadata:
57-
event_attributes[EventAttributes.PROVIDER_NAME] = (
58-
hook_context.provider_metadata.name
59-
)
60-
61-
current_span.add_event(OTEL_EVENT_NAME, event_attributes)
62-
63-
def error(
64-
self, hook_context: HookContext, exception: Exception, hints: HookHints
65-
) -> None:
66-
if self.exclude_exceptions:
67-
return
68-
attributes = {
69-
EventAttributes.KEY: hook_context.flag_key,
70-
EventAttributes.RESULT_VALUE: json.dumps(hook_context.default_value),
71-
}
72-
if hook_context.provider_metadata:
73-
attributes[EventAttributes.PROVIDER_NAME] = (
74-
hook_context.provider_metadata.name
75-
)
76-
current_span = trace.get_current_span()
77-
current_span.record_exception(exception, attributes)
4+
__all__ = ["MetricsHook", "TracingHook"]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
class Attributes:
2+
OTEL_CONTEXT_ID = "feature_flag.context.id"
3+
OTEL_EVENT_NAME = "feature_flag.evaluation"
4+
OTEL_ERROR_TYPE = "error.type"
5+
OTEL_ERROR_MESSAGE = "error.message"
6+
OTEL_FLAG_KEY = "feature_flag.key"
7+
OTEL_FLAG_VARIANT = "feature_flag.result.variant"
8+
OTEL_PROVIDER_NAME = "feature_flag.provider.name"
9+
OTEL_RESULT_VALUE = "feature_flag.result.value"
10+
OTEL_RESULT_REASON = "feature_flag.result.reason"
11+
OTEL_SET_ID = "feature_flag.set.id"
12+
OTEL_VERSION = "feature_flag.version"
13+
14+
15+
class Metrics:
16+
ACTIVE_TOTAL = "feature_flag.evaluation.active_total"
17+
SUCCESS_TOTAL = "feature_flag.evaluation.success_total"
18+
REQUEST_TOTAL = "feature_flag.evaluation.request_total"
19+
ERROR_TOTAL = "feature_flag.evaluation.error_total"
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from openfeature.flag_evaluation import FlagEvaluationDetails
2+
from openfeature.hook import Hook, HookContext, HookHints
3+
from opentelemetry import metrics
4+
5+
from .constants import Attributes, Metrics
6+
7+
8+
class MetricsHook(Hook):
9+
def __init__(self) -> None:
10+
meter: metrics.Meter = metrics.get_meter("openfeature.hooks.opentelemetry")
11+
self.evaluation_active_total = meter.create_up_down_counter(
12+
Metrics.ACTIVE_TOTAL, "active flag evaluations"
13+
)
14+
self.evaluation_error_total = meter.create_counter(
15+
Metrics.ERROR_TOTAL, "error flag evaluations"
16+
)
17+
self.evaluation_success_total = meter.create_counter(
18+
Metrics.SUCCESS_TOTAL, "success flag evaluations"
19+
)
20+
self.evaluation_request_total = meter.create_counter(
21+
Metrics.REQUEST_TOTAL, "request flag evaluations"
22+
)
23+
24+
def before(self, hook_context: HookContext, hints: HookHints) -> None:
25+
attributes = {
26+
Attributes.OTEL_FLAG_KEY: hook_context.flag_key,
27+
}
28+
if hook_context.provider_metadata:
29+
attributes[Attributes.OTEL_PROVIDER_NAME] = (
30+
hook_context.provider_metadata.name
31+
)
32+
self.evaluation_active_total.add(1, attributes)
33+
self.evaluation_request_total.add(1, attributes)
34+
35+
def after(
36+
self,
37+
hook_context: HookContext,
38+
details: FlagEvaluationDetails,
39+
hints: HookHints,
40+
) -> None:
41+
attributes = {
42+
Attributes.OTEL_FLAG_KEY: details.flag_key,
43+
}
44+
if details.variant:
45+
attributes[Attributes.OTEL_FLAG_VARIANT] = details.variant
46+
if hook_context.provider_metadata:
47+
attributes[Attributes.OTEL_PROVIDER_NAME] = (
48+
hook_context.provider_metadata.name
49+
)
50+
self.evaluation_success_total.add(1, attributes)
51+
52+
def error(
53+
self, hook_context: HookContext, exception: Exception, hints: HookHints
54+
) -> None:
55+
attributes = {
56+
Attributes.OTEL_FLAG_KEY: hook_context.flag_key,
57+
"exception": str(exception).lower(),
58+
}
59+
if hook_context.provider_metadata:
60+
attributes[Attributes.OTEL_PROVIDER_NAME] = (
61+
hook_context.provider_metadata.name
62+
)
63+
self.evaluation_error_total.add(1, attributes)
64+
65+
def finally_after(
66+
self,
67+
hook_context: HookContext,
68+
details: FlagEvaluationDetails,
69+
hints: HookHints,
70+
) -> None:
71+
attributes = {
72+
Attributes.OTEL_FLAG_KEY: hook_context.flag_key,
73+
}
74+
if hook_context.provider_metadata:
75+
attributes[Attributes.OTEL_PROVIDER_NAME] = (
76+
hook_context.provider_metadata.name
77+
)
78+
self.evaluation_active_total.add(-1, attributes)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import json
2+
3+
from openfeature.exception import ErrorCode
4+
from openfeature.flag_evaluation import FlagEvaluationDetails, Reason
5+
from openfeature.hook import Hook, HookContext, HookHints
6+
from opentelemetry import trace
7+
8+
from .constants import Attributes
9+
10+
11+
class TracingHook(Hook):
12+
def __init__(self, exclude_exceptions: bool = False):
13+
self.exclude_exceptions = exclude_exceptions
14+
15+
def finally_after(
16+
self,
17+
hook_context: HookContext,
18+
details: FlagEvaluationDetails,
19+
hints: HookHints,
20+
) -> None:
21+
current_span = trace.get_current_span()
22+
23+
event_attributes = {
24+
Attributes.OTEL_FLAG_KEY: details.flag_key,
25+
Attributes.OTEL_RESULT_VALUE: json.dumps(details.value),
26+
Attributes.OTEL_RESULT_REASON: str(
27+
details.reason or Reason.UNKNOWN
28+
).lower(),
29+
}
30+
31+
if details.variant:
32+
event_attributes[Attributes.OTEL_FLAG_VARIANT] = details.variant
33+
34+
if details.reason == Reason.ERROR:
35+
error_type = str(details.error_code or ErrorCode.GENERAL).lower()
36+
event_attributes[Attributes.OTEL_ERROR_TYPE] = error_type
37+
if details.error_message:
38+
event_attributes["error.message"] = details.error_message
39+
40+
context = hook_context.evaluation_context
41+
if context.targeting_key:
42+
event_attributes[Attributes.OTEL_CONTEXT_ID] = context.targeting_key
43+
44+
if hook_context.provider_metadata:
45+
event_attributes[Attributes.OTEL_PROVIDER_NAME] = (
46+
hook_context.provider_metadata.name
47+
)
48+
49+
current_span.add_event(Attributes.OTEL_EVENT_NAME, event_attributes)
50+
51+
def error(
52+
self, hook_context: HookContext, exception: Exception, hints: HookHints
53+
) -> None:
54+
if self.exclude_exceptions:
55+
return
56+
attributes = {
57+
Attributes.OTEL_FLAG_KEY: hook_context.flag_key,
58+
Attributes.OTEL_RESULT_VALUE: json.dumps(hook_context.default_value),
59+
}
60+
if hook_context.provider_metadata:
61+
attributes[Attributes.OTEL_PROVIDER_NAME] = (
62+
hook_context.provider_metadata.name
63+
)
64+
current_span = trace.get_current_span()
65+
current_span.record_exception(exception, attributes)
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
from unittest.mock import Mock
2+
3+
import pytest
4+
from opentelemetry import metrics
5+
6+
from openfeature.contrib.hook.opentelemetry import MetricsHook
7+
from openfeature.evaluation_context import EvaluationContext
8+
from openfeature.flag_evaluation import Reason
9+
from openfeature.hook import FlagEvaluationDetails, FlagType, HookContext
10+
from openfeature.provider.metadata import Metadata
11+
12+
13+
@pytest.fixture
14+
def mock_get_meter(monkeypatch):
15+
mock_counters = {
16+
"feature_flag.evaluation.active_total": Mock(spec=metrics.UpDownCounter),
17+
"feature_flag.evaluation.error_total": Mock(spec=metrics.Counter),
18+
"feature_flag.evaluation.success_total": Mock(spec=metrics.Counter),
19+
"feature_flag.evaluation.request_total": Mock(spec=metrics.Counter),
20+
}
21+
22+
def side_effect(*args, **kwargs):
23+
return mock_counters[args[0]]
24+
25+
mock_meter = Mock(
26+
spec=metrics.Meter,
27+
create_up_down_counter=side_effect,
28+
create_counter=side_effect,
29+
)
30+
monkeypatch.setattr(metrics, "get_meter", lambda name: mock_meter)
31+
32+
return mock_meter, mock_counters
33+
34+
35+
def test_metric_before(mock_get_meter):
36+
_, mock_counters = mock_get_meter
37+
hook = MetricsHook()
38+
hook_context = HookContext(
39+
flag_key="flag_key",
40+
flag_type=FlagType.BOOLEAN,
41+
default_value=False,
42+
evaluation_context=EvaluationContext(),
43+
provider_metadata=Metadata(name="test-provider"),
44+
)
45+
46+
hook.before(hook_context, hints={})
47+
mock_counters["feature_flag.evaluation.active_total"].add.assert_called_once_with(
48+
1,
49+
{
50+
"feature_flag.key": "flag_key",
51+
"feature_flag.provider.name": "test-provider",
52+
},
53+
)
54+
mock_counters["feature_flag.evaluation.request_total"].add.assert_called_once_with(
55+
1,
56+
{
57+
"feature_flag.key": "flag_key",
58+
"feature_flag.provider.name": "test-provider",
59+
},
60+
)
61+
mock_counters["feature_flag.evaluation.error_total"].add.assert_not_called()
62+
mock_counters["feature_flag.evaluation.success_total"].add.assert_not_called()
63+
64+
65+
def test_metric_after(mock_get_meter):
66+
_, mock_counters = mock_get_meter
67+
hook = MetricsHook()
68+
hook_context = HookContext(
69+
flag_key="flag_key",
70+
flag_type=FlagType.BOOLEAN,
71+
default_value=False,
72+
evaluation_context=EvaluationContext(),
73+
provider_metadata=Metadata(name="test-provider"),
74+
)
75+
details = FlagEvaluationDetails(
76+
flag_key="flag_key",
77+
value=True,
78+
variant="enabled",
79+
reason=Reason.TARGETING_MATCH,
80+
error_code=None,
81+
error_message=None,
82+
)
83+
hook.after(hook_context, details, hints={})
84+
mock_counters["feature_flag.evaluation.success_total"].add.assert_called_once_with(
85+
1,
86+
{
87+
"feature_flag.key": "flag_key",
88+
"feature_flag.result.variant": "enabled",
89+
"feature_flag.provider.name": "test-provider",
90+
},
91+
)
92+
mock_counters["feature_flag.evaluation.error_total"].add.assert_not_called()
93+
mock_counters["feature_flag.evaluation.request_total"].add.assert_not_called()
94+
mock_counters["feature_flag.evaluation.active_total"].add.assert_not_called()
95+
96+
97+
def test_metric_error(mock_get_meter):
98+
_, mock_counters = mock_get_meter
99+
hook = MetricsHook()
100+
hook_context = HookContext(
101+
flag_key="flag_key",
102+
flag_type=FlagType.BOOLEAN,
103+
default_value=False,
104+
evaluation_context=EvaluationContext(),
105+
provider_metadata=Metadata(name="test-provider"),
106+
)
107+
hook.error(hook_context, Exception("test error"), hints={})
108+
mock_counters["feature_flag.evaluation.error_total"].add.assert_called_once_with(
109+
1,
110+
{
111+
"feature_flag.key": "flag_key",
112+
"feature_flag.provider.name": "test-provider",
113+
"exception": "test error",
114+
},
115+
)
116+
mock_counters["feature_flag.evaluation.success_total"].add.assert_not_called()
117+
mock_counters["feature_flag.evaluation.request_total"].add.assert_not_called()
118+
mock_counters["feature_flag.evaluation.active_total"].add.assert_not_called()
119+
120+
121+
def test_metric_finally_after(mock_get_meter):
122+
_, mock_counters = mock_get_meter
123+
hook = MetricsHook()
124+
hook_context = HookContext(
125+
flag_key="flag_key",
126+
flag_type=FlagType.BOOLEAN,
127+
default_value=False,
128+
evaluation_context=EvaluationContext(),
129+
provider_metadata=Metadata(name="test-provider"),
130+
)
131+
details = FlagEvaluationDetails(
132+
flag_key="flag_key",
133+
value=True,
134+
variant="enabled",
135+
reason=Reason.TARGETING_MATCH,
136+
error_code=None,
137+
error_message=None,
138+
)
139+
hook.finally_after(hook_context, details, hints={})
140+
mock_counters["feature_flag.evaluation.active_total"].add.assert_called_once_with(
141+
-1,
142+
{
143+
"feature_flag.key": "flag_key",
144+
"feature_flag.provider.name": "test-provider",
145+
},
146+
)
147+
mock_counters["feature_flag.evaluation.success_total"].add.assert_not_called()
148+
mock_counters["feature_flag.evaluation.request_total"].add.assert_not_called()
149+
mock_counters["feature_flag.evaluation.error_total"].add.assert_not_called()

0 commit comments

Comments
 (0)