diff --git a/hooks/openfeature-hooks-opentelemetry/pyproject.toml b/hooks/openfeature-hooks-opentelemetry/pyproject.toml index e4a61f17..f60c0e4d 100644 --- a/hooks/openfeature-hooks-opentelemetry/pyproject.toml +++ b/hooks/openfeature-hooks-opentelemetry/pyproject.toml @@ -16,8 +16,9 @@ classifiers = [ ] keywords = [] dependencies = [ - "openfeature-sdk>=0.6.0", + "openfeature-sdk>=0.8.4", "opentelemetry-api", + "opentelemetry-semantic-conventions>=0.50b0", ] requires-python = ">=3.9" diff --git a/hooks/openfeature-hooks-opentelemetry/src/openfeature/contrib/hook/opentelemetry/__init__.py b/hooks/openfeature-hooks-opentelemetry/src/openfeature/contrib/hook/opentelemetry/__init__.py index 6102be16..892e6245 100644 --- a/hooks/openfeature-hooks-opentelemetry/src/openfeature/contrib/hook/opentelemetry/__init__.py +++ b/hooks/openfeature-hooks-opentelemetry/src/openfeature/contrib/hook/opentelemetry/__init__.py @@ -1,16 +1,23 @@ import json -from openfeature.flag_evaluation import FlagEvaluationDetails +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagEvaluationDetails, Reason from openfeature.hook import Hook, HookContext, HookHints from opentelemetry import trace +from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE -OTEL_EVENT_NAME = "feature_flag" +OTEL_EVENT_NAME = "feature_flag.evaluation" class EventAttributes: - FLAG_KEY = f"{OTEL_EVENT_NAME}.key" - FLAG_VARIANT = f"{OTEL_EVENT_NAME}.variant" - PROVIDER_NAME = f"{OTEL_EVENT_NAME}.provider_name" + KEY = "feature_flag.key" + RESULT_VALUE = "feature_flag.result.value" + RESULT_VARIANT = "feature_flag.result.variant" + CONTEXT_ID = "feature_flag.context.id" + PROVIDER_NAME = "feature_flag.provider.name" + RESULT_REASON = "feature_flag.result.reason" + SET_ID = "feature_flag.set.id" + VERSION = "feature_flag.version" class TracingHook(Hook): @@ -22,18 +29,27 @@ def after( ) -> None: 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, + EventAttributes.KEY: details.flag_key, + EventAttributes.RESULT_VALUE: json.dumps(details.value), + EventAttributes.RESULT_REASON: str( + details.reason or Reason.UNKNOWN + ).lower(), } + if details.variant: + event_attributes[EventAttributes.RESULT_VARIANT] = details.variant + + if details.reason == Reason.ERROR: + error_type = str(details.error_code or ErrorCode.GENERAL).lower() + event_attributes[ERROR_TYPE] = error_type + if details.error_message: + event_attributes["error.message"] = details.error_message + + context = hook_context.evaluation_context + if context.targeting_key: + event_attributes[EventAttributes.CONTEXT_ID] = context.targeting_key + if hook_context.provider_metadata: event_attributes[EventAttributes.PROVIDER_NAME] = ( hook_context.provider_metadata.name diff --git a/hooks/openfeature-hooks-opentelemetry/tests/test_otel.py b/hooks/openfeature-hooks-opentelemetry/tests/test_otel.py index 284ba37e..f8b74385 100644 --- a/hooks/openfeature-hooks-opentelemetry/tests/test_otel.py +++ b/hooks/openfeature-hooks-opentelemetry/tests/test_otel.py @@ -6,8 +6,10 @@ from openfeature.contrib.hook.opentelemetry import TracingHook from openfeature.evaluation_context import EvaluationContext -from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, Reason from openfeature.hook import HookContext +from openfeature.provider.metadata import Metadata @pytest.fixture @@ -15,20 +17,21 @@ def mock_get_current_span(monkeypatch): monkeypatch.setattr(trace, "get_current_span", Mock()) -def test_before(mock_get_current_span): +def test_after(mock_get_current_span): # Given hook = TracingHook() hook_context = HookContext( flag_key="flag_key", flag_type=FlagType.BOOLEAN, default_value=False, - evaluation_context=EvaluationContext(), + evaluation_context=EvaluationContext("123"), + provider_metadata=Metadata(name="test-provider"), ) details = FlagEvaluationDetails( flag_key="flag_key", value=True, variant="enabled", - reason=None, + reason=Reason.TARGETING_MATCH, error_code=None, error_message=None, ) @@ -41,10 +44,52 @@ def test_before(mock_get_current_span): # Then mock_span.add_event.assert_called_once_with( - "feature_flag", + "feature_flag.evaluation", + { + "feature_flag.key": "flag_key", + "feature_flag.result.value": "true", + "feature_flag.result.variant": "enabled", + "feature_flag.result.reason": "targeting_match", + "feature_flag.context.id": "123", + "feature_flag.provider.name": "test-provider", + }, + ) + + +def test_after_evaluation_error(mock_get_current_span): + # Given + hook = TracingHook() + hook_context = HookContext( + flag_key="flag_key", + flag_type=FlagType.BOOLEAN, + default_value=False, + evaluation_context=EvaluationContext(), + provider_metadata=None, + ) + details = FlagEvaluationDetails( + flag_key="flag_key", + value=False, + variant=None, + reason=Reason.ERROR, + error_code=ErrorCode.FLAG_NOT_FOUND, + error_message="Flag not found: flag_key", + ) + + 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.evaluation", { "feature_flag.key": "flag_key", - "feature_flag.variant": "enabled", + "feature_flag.result.value": "false", + "feature_flag.result.reason": "error", + "error.type": "flag_not_found", + "error.message": "Flag not found: flag_key", }, ) diff --git a/uv.lock b/uv.lock index 7bad527c..f5bc442d 100644 --- a/uv.lock +++ b/uv.lock @@ -1076,6 +1076,7 @@ source = { editable = "hooks/openfeature-hooks-opentelemetry" } dependencies = [ { name = "openfeature-sdk" }, { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, ] [package.dev-dependencies] @@ -1088,8 +1089,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "openfeature-sdk", specifier = ">=0.6.0" }, + { name = "openfeature-sdk", specifier = ">=0.8.4" }, { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions", specifier = ">=0.50b0" }, ] [package.metadata.requires-dev] @@ -1290,11 +1292,11 @@ dev = [ [[package]] name = "openfeature-sdk" -version = "0.8.3" +version = "0.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/19/f1319f665e4a5163cbda29aa0a56ca4c0899dbcf3ad6d929670157498ee0/openfeature_sdk-0.8.3.tar.gz", hash = "sha256:37c379e3e5da39567f07b9410fca9153882cf0a95da81e03ccf7559fca054fd2", size = 33299, upload-time = "2025-09-21T09:08:55.503Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/08/f6698d0614b8703170117b786bd77b7b0a04f3ee00f19fbe9b360d2dee69/openfeature_sdk-0.8.4.tar.gz", hash = "sha256:66abf71f928ec8c0db1111072bb0ef2635dfbd09510f77f4b548e5d0ea0e6c1a", size = 29676, upload-time = "2025-12-09T07:31:13.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/f5/707a5b144115de1a49bf5761a63af2545fef0a1824f72db39ddea0a3438f/openfeature_sdk-0.8.3-py3-none-any.whl", hash = "sha256:28e817514c5398e2243d0a158f3306624383757ba833032336ceba2b3cbcddd6", size = 35561, upload-time = "2025-09-21T09:08:54.072Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/f6532778188c573cc83790b11abccde717d4c1442514e722d6bb6140e55c/openfeature_sdk-0.8.4-py3-none-any.whl", hash = "sha256:805ba090669798fc343ca9fdcbc56ff0f4b57bf6757533f0854d2021192e620a", size = 35986, upload-time = "2025-12-09T07:31:12.092Z" }, ] [[package]] @@ -1310,6 +1312,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/a2/d86e01c28300bd41bab8f18afd613676e2bd63515417b77636fc1add426f/opentelemetry_api-1.38.0-py3-none-any.whl", hash = "sha256:2891b0197f47124454ab9f0cf58f3be33faca394457ac3e09daba13ff50aa582", size = 65947, upload-time = "2025-10-16T08:35:30.23Z" }, ] +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.59b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/bc/8b9ad3802cd8ac6583a4eb7de7e5d7db004e89cb7efe7008f9c8a537ee75/opentelemetry_semantic_conventions-0.59b0.tar.gz", hash = "sha256:7a6db3f30d70202d5bf9fa4b69bc866ca6a30437287de6c510fb594878aed6b0", size = 129861, upload-time = "2025-10-16T08:36:03.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/7d/c88d7b15ba8fe5c6b8f93be50fc11795e9fc05386c44afaf6b76fe191f9b/opentelemetry_semantic_conventions-0.59b0-py3-none-any.whl", hash = "sha256:35d3b8833ef97d614136e253c1da9342b4c3c083bbaf29ce31d572a1c3825eed", size = 207954, upload-time = "2025-10-16T08:35:48.054Z" }, +] + [[package]] name = "packaging" version = "25.0"