Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add OTel utility function #451

Merged
merged 3 commits into from
Feb 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions openfeature/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import typing
from collections.abc import Mapping
from enum import Enum

from openfeature._backports.strenum import StrEnum

__all__ = [
"ErrorCode",
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion openfeature/flag_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
75 changes: 75 additions & 0 deletions openfeature/telemetry/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
)
19 changes: 19 additions & 0 deletions openfeature/telemetry/attributes.py
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 11 additions & 0 deletions openfeature/telemetry/body.py
Original file line number Diff line number Diff line change
@@ -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"
13 changes: 13 additions & 0 deletions openfeature/telemetry/metadata.py
Original file line number Diff line number Diff line change
@@ -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"
Empty file added tests/telemetry/__init__.py
Empty file.
101 changes: 101 additions & 0 deletions tests/telemetry/test_evaluation_event.py
Original file line number Diff line number Diff line change
@@ -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"