From 5f2ea530318eceabe59d61c7cfc495e4bc61f099 Mon Sep 17 00:00:00 2001 From: gruebel Date: Mon, 17 Feb 2025 00:22:21 +0100 Subject: [PATCH] add OTel utility function Signed-off-by: gruebel --- openfeature/exception.py | 5 +- openfeature/flag_evaluation.py | 3 +- openfeature/telemetry/__init__.py | 75 +++++++++++++++++ openfeature/telemetry/attributes.py | 19 +++++ openfeature/telemetry/body.py | 11 +++ openfeature/telemetry/metadata.py | 13 +++ tests/telemetry/__init__.py | 0 tests/telemetry/test_evaluation_event.py | 101 +++++++++++++++++++++++ 8 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 openfeature/telemetry/__init__.py create mode 100644 openfeature/telemetry/attributes.py create mode 100644 openfeature/telemetry/body.py create mode 100644 openfeature/telemetry/metadata.py create mode 100644 tests/telemetry/__init__.py create mode 100644 tests/telemetry/test_evaluation_event.py diff --git a/openfeature/exception.py b/openfeature/exception.py index 0576ec17..0912ef5f 100644 --- a/openfeature/exception.py +++ b/openfeature/exception.py @@ -2,7 +2,8 @@ import typing from collections.abc import Mapping -from enum import Enum + +from openfeature._backports.strenum import StrEnum __all__ = [ "ErrorCode", @@ -163,7 +164,7 @@ def __init__(self, error_message: typing.Optional[str]): super().__init__(ErrorCode.INVALID_CONTEXT, error_message) -class ErrorCode(Enum): +class ErrorCode(StrEnum): PROVIDER_NOT_READY = "PROVIDER_NOT_READY" PROVIDER_FATAL = "PROVIDER_FATAL" FLAG_NOT_FOUND = "FLAG_NOT_FOUND" diff --git a/openfeature/flag_evaluation.py b/openfeature/flag_evaluation.py index 5cab623c..c522eecd 100644 --- a/openfeature/flag_evaluation.py +++ b/openfeature/flag_evaluation.py @@ -34,8 +34,9 @@ class Reason(StrEnum): DEFAULT = "DEFAULT" DISABLED = "DISABLED" ERROR = "ERROR" - STATIC = "STATIC" SPLIT = "SPLIT" + STATIC = "STATIC" + STALE = "STALE" TARGETING_MATCH = "TARGETING_MATCH" UNKNOWN = "UNKNOWN" diff --git a/openfeature/telemetry/__init__.py b/openfeature/telemetry/__init__.py new file mode 100644 index 00000000..a4b82ab6 --- /dev/null +++ b/openfeature/telemetry/__init__.py @@ -0,0 +1,75 @@ +import typing +from collections.abc import Mapping +from dataclasses import dataclass + +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagEvaluationDetails, Reason +from openfeature.hook import HookContext +from openfeature.telemetry.attributes import TelemetryAttribute +from openfeature.telemetry.body import TelemetryBodyField +from openfeature.telemetry.metadata import TelemetryFlagMetadata + +__all__ = [ + "EvaluationEvent", + "TelemetryAttribute", + "TelemetryBodyField", + "TelemetryFlagMetadata", + "create_evaluation_event", +] + +FLAG_EVALUATION_EVENT_NAME = "feature_flag.evaluation" + +T_co = typing.TypeVar("T_co", covariant=True) + + +@dataclass +class EvaluationEvent(typing.Generic[T_co]): + name: str + attributes: Mapping[TelemetryAttribute, typing.Union[str, T_co]] + body: Mapping[TelemetryBodyField, T_co] + + +def create_evaluation_event( + hook_context: HookContext, details: FlagEvaluationDetails[T_co] +) -> EvaluationEvent[T_co]: + attributes = { + TelemetryAttribute.KEY: details.flag_key, + TelemetryAttribute.EVALUATION_REASON: ( + details.reason or Reason.UNKNOWN + ).lower(), + } + body = {} + + if variant := details.variant: + attributes[TelemetryAttribute.VARIANT] = variant + else: + body[TelemetryBodyField.VALUE] = details.value + + context_id = details.flag_metadata.get( + TelemetryFlagMetadata.CONTEXT_ID, hook_context.evaluation_context.targeting_key + ) + if context_id: + attributes[TelemetryAttribute.CONTEXT_ID] = context_id + + if set_id := details.flag_metadata.get(TelemetryFlagMetadata.FLAG_SET_ID): + attributes[TelemetryAttribute.SET_ID] = set_id + + if version := details.flag_metadata.get(TelemetryFlagMetadata.VERSION): + attributes[TelemetryAttribute.VERSION] = version + + if metadata := hook_context.provider_metadata: + attributes[TelemetryAttribute.PROVIDER_NAME] = metadata.name + + if details.reason == Reason.ERROR: + attributes[TelemetryAttribute.ERROR_TYPE] = ( + details.error_code or ErrorCode.GENERAL + ).lower() + + if err_msg := details.error_message: + attributes[TelemetryAttribute.EVALUATION_ERROR_MESSAGE] = err_msg + + return EvaluationEvent( + name=FLAG_EVALUATION_EVENT_NAME, + attributes=attributes, + body=body, + ) diff --git a/openfeature/telemetry/attributes.py b/openfeature/telemetry/attributes.py new file mode 100644 index 00000000..e232cee6 --- /dev/null +++ b/openfeature/telemetry/attributes.py @@ -0,0 +1,19 @@ +from openfeature._backports.strenum import StrEnum + + +class TelemetryAttribute(StrEnum): + """ + The attributes of an OpenTelemetry compliant event for flag evaluation. + + See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + """ + + CONTEXT_ID = "feature_flag.context.id" + ERROR_TYPE = "error.type" + EVALUATION_ERROR_MESSAGE = "feature_flag.evaluation.error.message" + EVALUATION_REASON = "feature_flag.evaluation.reason" + KEY = "feature_flag.key" + PROVIDER_NAME = "feature_flag.provider_name" + SET_ID = "feature_flag.set.id" + VARIANT = "feature_flag.variant" + VERSION = "feature_flag.version" diff --git a/openfeature/telemetry/body.py b/openfeature/telemetry/body.py new file mode 100644 index 00000000..7b47bbff --- /dev/null +++ b/openfeature/telemetry/body.py @@ -0,0 +1,11 @@ +from openfeature._backports.strenum import StrEnum + + +class TelemetryBodyField(StrEnum): + """ + OpenTelemetry event body fields. + + See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + """ + + VALUE = "value" diff --git a/openfeature/telemetry/metadata.py b/openfeature/telemetry/metadata.py new file mode 100644 index 00000000..5b7b1085 --- /dev/null +++ b/openfeature/telemetry/metadata.py @@ -0,0 +1,13 @@ +from openfeature._backports.strenum import StrEnum + + +class TelemetryFlagMetadata(StrEnum): + """ + Well-known flag metadata attributes for telemetry events. + + See: https://openfeature.dev/specification/appendix-d/#flag-metadata + """ + + CONTEXT_ID = "contextId" + FLAG_SET_ID = "flagSetId" + VERSION = "version" diff --git a/tests/telemetry/__init__.py b/tests/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/telemetry/test_evaluation_event.py b/tests/telemetry/test_evaluation_event.py new file mode 100644 index 00000000..3def8655 --- /dev/null +++ b/tests/telemetry/test_evaluation_event.py @@ -0,0 +1,101 @@ +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, Reason +from openfeature.hook import HookContext +from openfeature.provider import Metadata +from openfeature.telemetry import ( + TelemetryAttribute, + TelemetryBodyField, + TelemetryFlagMetadata, + create_evaluation_event, +) + + +def test_create_evaluation_event(): + # given + hook_context = HookContext( + flag_key="flag_key", + flag_type=FlagType.BOOLEAN, + default_value=True, + evaluation_context=EvaluationContext(), + provider_metadata=Metadata(name="test_provider"), + ) + details = FlagEvaluationDetails( + flag_key=hook_context.flag_key, + value=False, + reason=Reason.CACHED, + ) + + # when + event = create_evaluation_event(hook_context=hook_context, details=details) + + # then + assert event.name == "feature_flag.evaluation" + assert event.attributes[TelemetryAttribute.KEY] == "flag_key" + assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "cached" + assert event.attributes[TelemetryAttribute.PROVIDER_NAME] == "test_provider" + assert event.body[TelemetryBodyField.VALUE] is False + + +def test_create_evaluation_event_with_variant(): + # given + hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext()) + details = FlagEvaluationDetails( + flag_key=hook_context.flag_key, + value=True, + variant="true", + ) + + # when + event = create_evaluation_event(hook_context=hook_context, details=details) + + # then + assert event.name == "feature_flag.evaluation" + assert event.attributes[TelemetryAttribute.KEY] == "flag_key" + assert event.attributes[TelemetryAttribute.VARIANT] == "true" + assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "unknown" + + +def test_create_evaluation_event_with_metadata(): + # given + hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext()) + details = FlagEvaluationDetails( + flag_key=hook_context.flag_key, + value=False, + flag_metadata={ + TelemetryFlagMetadata.CONTEXT_ID: "5157782b-2203-4c80-a857-dbbd5e7761db", + TelemetryFlagMetadata.FLAG_SET_ID: "proj-1", + TelemetryFlagMetadata.VERSION: "v1", + }, + ) + + # when + event = create_evaluation_event(hook_context=hook_context, details=details) + + # then + assert ( + event.attributes[TelemetryAttribute.CONTEXT_ID] + == "5157782b-2203-4c80-a857-dbbd5e7761db" + ) + assert event.attributes[TelemetryAttribute.SET_ID] == "proj-1" + assert event.attributes[TelemetryAttribute.VERSION] == "v1" + + +def test_create_evaluation_event_with_error(): + # given + hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext()) + details = FlagEvaluationDetails( + flag_key=hook_context.flag_key, + value=False, + reason=Reason.ERROR, + error_code=ErrorCode.FLAG_NOT_FOUND, + error_message="flag error", + ) + + # when + event = create_evaluation_event(hook_context=hook_context, details=details) + + # then + assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "error" + assert event.attributes[TelemetryAttribute.ERROR_TYPE] == "flag_not_found" + assert event.attributes[TelemetryAttribute.EVALUATION_ERROR_MESSAGE] == "flag error"