diff --git a/openfeature/contrib/hooks/otel.py b/openfeature/contrib/hooks/otel.py new file mode 100644 index 0000000..d5f2b2c --- /dev/null +++ b/openfeature/contrib/hooks/otel.py @@ -0,0 +1,44 @@ +import json + +from openfeature.flag_evaluation import FlagEvaluationDetails +from openfeature.hook import Hook, HookContext +from opentelemetry import trace + + +OTEL_EVENT_NAME = "feature_flag" + + +class EventAttributes: + FLAG_KEY = f"{OTEL_EVENT_NAME}.key" + FLAG_VARIANT = f"{OTEL_EVENT_NAME}.variant" + PROVIDER_NAME = f"{OTEL_EVENT_NAME}.provider_name" + + +class OTelTracesHook(Hook): + def after( + self, hook_context: HookContext, details: FlagEvaluationDetails, hints: dict + ): + current_span = trace.get_current_span() + + variant = details.variant + if variant is None: + if isinstance(details.value, str): + variant = str(details.value) + else: + variant = json.dumps(details.value) + + event_attributes = { + EventAttributes.FLAG_KEY: details.flag_key, + EventAttributes.FLAG_VARIANT: variant, + } + + if hook_context.provider_metadata: + event_attributes[ + EventAttributes.PROVIDER_NAME + ] = hook_context.provider_metadata.name + + current_span.add_event(OTEL_EVENT_NAME, event_attributes) + + def error(self, hook_context: HookContext, exception: Exception, hints: dict): + current_span = trace.get_current_span() + current_span.record_exception(exception) diff --git a/requirements-dev.in b/requirements-dev.in index fd83b24..891eb89 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -8,3 +8,4 @@ flake8 pytest-mock coverage openfeature-sdk +opentelemetry-api diff --git a/requirements-dev.txt b/requirements-dev.txt index bea06d5..76cf7e0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -18,6 +18,8 @@ click==8.1.3 # pip-tools coverage==7.2.7 # via -r requirements-dev.in +deprecated==1.2.14 + # via opentelemetry-api dill==0.3.4 # via pylint distlib==0.3.4 @@ -28,6 +30,8 @@ flake8==4.0.1 # via -r requirements-dev.in identify==2.5.0 # via pre-commit +importlib-metadata==6.8.0 + # via opentelemetry-api iniconfig==1.1.1 # via pytest isort==5.10.1 @@ -44,6 +48,8 @@ nodeenv==1.6.0 # via pre-commit openfeature-sdk==0.3.1 # via -r requirements-dev.in +opentelemetry-api==1.19.0 + # via -r requirements-dev.in packaging==21.3 # via pytest pathspec==0.9.0 @@ -92,7 +98,11 @@ virtualenv==20.14.1 wheel==0.37.1 # via pip-tools wrapt==1.14.1 - # via astroid + # via + # astroid + # deprecated +zipp==3.16.2 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/tests/hooks/__init__.py b/tests/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/hooks/test_otel.py b/tests/hooks/test_otel.py new file mode 100644 index 0000000..811b0ed --- /dev/null +++ b/tests/hooks/test_otel.py @@ -0,0 +1,69 @@ +from unittest.mock import Mock + +import pytest +from openfeature.hook import HookContext +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType +from openfeature.evaluation_context import EvaluationContext +from openfeature.contrib.hooks.otel import OTelTracesHook +from opentelemetry import trace +from opentelemetry.trace import Span + + +@pytest.fixture +def mock_get_current_span(monkeypatch): + monkeypatch.setattr(trace, "get_current_span", Mock()) + + +def test_before(mock_get_current_span): + # Given + hook = OTelTracesHook() + hook_context = HookContext( + flag_key="flag_key", + flag_type=FlagType.BOOLEAN, + default_value=False, + evaluation_context=EvaluationContext(), + ) + details = FlagEvaluationDetails( + flag_key="flag_key", + value=True, + variant="enabled", + reason=None, + error_code=None, + error_message=None, + ) + + mock_span = Mock(spec=Span) + trace.get_current_span.return_value = mock_span + + # When + hook.after(hook_context, details, hints={}) + + # Then + mock_span.add_event.assert_called_once_with( + "feature_flag", + { + "feature_flag.key": "flag_key", + "feature_flag.variant": "enabled", + }, + ) + + +def test_error(mock_get_current_span): + # Given + hook = OTelTracesHook() + hook_context = HookContext( + flag_key="flag_key", + flag_type=FlagType.BOOLEAN, + default_value=False, + evaluation_context=EvaluationContext(), + ) + exception = Exception() + + mock_span = Mock(spec=Span) + trace.get_current_span.return_value = mock_span + + # When + hook.error(hook_context, exception, hints={}) + + # Then + mock_span.record_exception.assert_called_once_with(exception)