From d9443b7df5d1e5a66e249a12e12343589cea0eed Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Sat, 15 Jul 2023 21:09:31 -0300 Subject: [PATCH] feat: implement OpenTelemetry traces hook Signed-off-by: Federico Bond --- open_feature_contrib/hooks/otel.py | 45 +++++++++++++++++++ requirements-dev.in | 1 + requirements-dev.txt | 12 ++++- tests/hooks/__init__.py | 0 tests/hooks/test_otel.py | 70 ++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 open_feature_contrib/hooks/otel.py create mode 100644 tests/hooks/__init__.py create mode 100644 tests/hooks/test_otel.py diff --git a/open_feature_contrib/hooks/otel.py b/open_feature_contrib/hooks/otel.py new file mode 100644 index 0000000..fa3e36e --- /dev/null +++ b/open_feature_contrib/hooks/otel.py @@ -0,0 +1,45 @@ +import json + +from open_feature.flag_evaluation.flag_evaluation_details import FlagEvaluationDetails +from open_feature.hooks.hook import Hook +from open_feature.hooks.hook_context import 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 93e3d58..892cd2c 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.0.9 # 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..1b5862c --- /dev/null +++ b/tests/hooks/test_otel.py @@ -0,0 +1,70 @@ +from unittest.mock import Mock + +import pytest +from open_feature.hooks.hook_context import HookContext +from open_feature.flag_evaluation.flag_type import FlagType +from open_feature.evaluation_context.evaluation_context import EvaluationContext +from open_feature.flag_evaluation.flag_evaluation_details import FlagEvaluationDetails +from open_feature_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)