Skip to content

Commit 2d1ba85

Browse files
gruebelbeeme1mr
andauthored
feat: add OTel utility function (#451)
add OTel utility function Signed-off-by: gruebel <[email protected]> Co-authored-by: Michael Beemer <[email protected]>
1 parent 613388d commit 2d1ba85

File tree

8 files changed

+224
-3
lines changed

8 files changed

+224
-3
lines changed

openfeature/exception.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import typing
44
from collections.abc import Mapping
5-
from enum import Enum
5+
6+
from openfeature._backports.strenum import StrEnum
67

78
__all__ = [
89
"ErrorCode",
@@ -163,7 +164,7 @@ def __init__(self, error_message: typing.Optional[str]):
163164
super().__init__(ErrorCode.INVALID_CONTEXT, error_message)
164165

165166

166-
class ErrorCode(Enum):
167+
class ErrorCode(StrEnum):
167168
PROVIDER_NOT_READY = "PROVIDER_NOT_READY"
168169
PROVIDER_FATAL = "PROVIDER_FATAL"
169170
FLAG_NOT_FOUND = "FLAG_NOT_FOUND"

openfeature/flag_evaluation.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ class Reason(StrEnum):
3434
DEFAULT = "DEFAULT"
3535
DISABLED = "DISABLED"
3636
ERROR = "ERROR"
37-
STATIC = "STATIC"
3837
SPLIT = "SPLIT"
38+
STATIC = "STATIC"
39+
STALE = "STALE"
3940
TARGETING_MATCH = "TARGETING_MATCH"
4041
UNKNOWN = "UNKNOWN"
4142

openfeature/telemetry/__init__.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import typing
2+
from collections.abc import Mapping
3+
from dataclasses import dataclass
4+
5+
from openfeature.exception import ErrorCode
6+
from openfeature.flag_evaluation import FlagEvaluationDetails, Reason
7+
from openfeature.hook import HookContext
8+
from openfeature.telemetry.attributes import TelemetryAttribute
9+
from openfeature.telemetry.body import TelemetryBodyField
10+
from openfeature.telemetry.metadata import TelemetryFlagMetadata
11+
12+
__all__ = [
13+
"EvaluationEvent",
14+
"TelemetryAttribute",
15+
"TelemetryBodyField",
16+
"TelemetryFlagMetadata",
17+
"create_evaluation_event",
18+
]
19+
20+
FLAG_EVALUATION_EVENT_NAME = "feature_flag.evaluation"
21+
22+
T_co = typing.TypeVar("T_co", covariant=True)
23+
24+
25+
@dataclass
26+
class EvaluationEvent(typing.Generic[T_co]):
27+
name: str
28+
attributes: Mapping[TelemetryAttribute, typing.Union[str, T_co]]
29+
body: Mapping[TelemetryBodyField, T_co]
30+
31+
32+
def create_evaluation_event(
33+
hook_context: HookContext, details: FlagEvaluationDetails[T_co]
34+
) -> EvaluationEvent[T_co]:
35+
attributes = {
36+
TelemetryAttribute.KEY: details.flag_key,
37+
TelemetryAttribute.EVALUATION_REASON: (
38+
details.reason or Reason.UNKNOWN
39+
).lower(),
40+
}
41+
body = {}
42+
43+
if variant := details.variant:
44+
attributes[TelemetryAttribute.VARIANT] = variant
45+
else:
46+
body[TelemetryBodyField.VALUE] = details.value
47+
48+
context_id = details.flag_metadata.get(
49+
TelemetryFlagMetadata.CONTEXT_ID, hook_context.evaluation_context.targeting_key
50+
)
51+
if context_id:
52+
attributes[TelemetryAttribute.CONTEXT_ID] = context_id
53+
54+
if set_id := details.flag_metadata.get(TelemetryFlagMetadata.FLAG_SET_ID):
55+
attributes[TelemetryAttribute.SET_ID] = set_id
56+
57+
if version := details.flag_metadata.get(TelemetryFlagMetadata.VERSION):
58+
attributes[TelemetryAttribute.VERSION] = version
59+
60+
if metadata := hook_context.provider_metadata:
61+
attributes[TelemetryAttribute.PROVIDER_NAME] = metadata.name
62+
63+
if details.reason == Reason.ERROR:
64+
attributes[TelemetryAttribute.ERROR_TYPE] = (
65+
details.error_code or ErrorCode.GENERAL
66+
).lower()
67+
68+
if err_msg := details.error_message:
69+
attributes[TelemetryAttribute.EVALUATION_ERROR_MESSAGE] = err_msg
70+
71+
return EvaluationEvent(
72+
name=FLAG_EVALUATION_EVENT_NAME,
73+
attributes=attributes,
74+
body=body,
75+
)

openfeature/telemetry/attributes.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from openfeature._backports.strenum import StrEnum
2+
3+
4+
class TelemetryAttribute(StrEnum):
5+
"""
6+
The attributes of an OpenTelemetry compliant event for flag evaluation.
7+
8+
See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
9+
"""
10+
11+
CONTEXT_ID = "feature_flag.context.id"
12+
ERROR_TYPE = "error.type"
13+
EVALUATION_ERROR_MESSAGE = "feature_flag.evaluation.error.message"
14+
EVALUATION_REASON = "feature_flag.evaluation.reason"
15+
KEY = "feature_flag.key"
16+
PROVIDER_NAME = "feature_flag.provider_name"
17+
SET_ID = "feature_flag.set.id"
18+
VARIANT = "feature_flag.variant"
19+
VERSION = "feature_flag.version"

openfeature/telemetry/body.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from openfeature._backports.strenum import StrEnum
2+
3+
4+
class TelemetryBodyField(StrEnum):
5+
"""
6+
OpenTelemetry event body fields.
7+
8+
See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
9+
"""
10+
11+
VALUE = "value"

openfeature/telemetry/metadata.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from openfeature._backports.strenum import StrEnum
2+
3+
4+
class TelemetryFlagMetadata(StrEnum):
5+
"""
6+
Well-known flag metadata attributes for telemetry events.
7+
8+
See: https://openfeature.dev/specification/appendix-d/#flag-metadata
9+
"""
10+
11+
CONTEXT_ID = "contextId"
12+
FLAG_SET_ID = "flagSetId"
13+
VERSION = "version"

tests/telemetry/__init__.py

Whitespace-only changes.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from openfeature.evaluation_context import EvaluationContext
2+
from openfeature.exception import ErrorCode
3+
from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, Reason
4+
from openfeature.hook import HookContext
5+
from openfeature.provider import Metadata
6+
from openfeature.telemetry import (
7+
TelemetryAttribute,
8+
TelemetryBodyField,
9+
TelemetryFlagMetadata,
10+
create_evaluation_event,
11+
)
12+
13+
14+
def test_create_evaluation_event():
15+
# given
16+
hook_context = HookContext(
17+
flag_key="flag_key",
18+
flag_type=FlagType.BOOLEAN,
19+
default_value=True,
20+
evaluation_context=EvaluationContext(),
21+
provider_metadata=Metadata(name="test_provider"),
22+
)
23+
details = FlagEvaluationDetails(
24+
flag_key=hook_context.flag_key,
25+
value=False,
26+
reason=Reason.CACHED,
27+
)
28+
29+
# when
30+
event = create_evaluation_event(hook_context=hook_context, details=details)
31+
32+
# then
33+
assert event.name == "feature_flag.evaluation"
34+
assert event.attributes[TelemetryAttribute.KEY] == "flag_key"
35+
assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "cached"
36+
assert event.attributes[TelemetryAttribute.PROVIDER_NAME] == "test_provider"
37+
assert event.body[TelemetryBodyField.VALUE] is False
38+
39+
40+
def test_create_evaluation_event_with_variant():
41+
# given
42+
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext())
43+
details = FlagEvaluationDetails(
44+
flag_key=hook_context.flag_key,
45+
value=True,
46+
variant="true",
47+
)
48+
49+
# when
50+
event = create_evaluation_event(hook_context=hook_context, details=details)
51+
52+
# then
53+
assert event.name == "feature_flag.evaluation"
54+
assert event.attributes[TelemetryAttribute.KEY] == "flag_key"
55+
assert event.attributes[TelemetryAttribute.VARIANT] == "true"
56+
assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "unknown"
57+
58+
59+
def test_create_evaluation_event_with_metadata():
60+
# given
61+
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext())
62+
details = FlagEvaluationDetails(
63+
flag_key=hook_context.flag_key,
64+
value=False,
65+
flag_metadata={
66+
TelemetryFlagMetadata.CONTEXT_ID: "5157782b-2203-4c80-a857-dbbd5e7761db",
67+
TelemetryFlagMetadata.FLAG_SET_ID: "proj-1",
68+
TelemetryFlagMetadata.VERSION: "v1",
69+
},
70+
)
71+
72+
# when
73+
event = create_evaluation_event(hook_context=hook_context, details=details)
74+
75+
# then
76+
assert (
77+
event.attributes[TelemetryAttribute.CONTEXT_ID]
78+
== "5157782b-2203-4c80-a857-dbbd5e7761db"
79+
)
80+
assert event.attributes[TelemetryAttribute.SET_ID] == "proj-1"
81+
assert event.attributes[TelemetryAttribute.VERSION] == "v1"
82+
83+
84+
def test_create_evaluation_event_with_error():
85+
# given
86+
hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext())
87+
details = FlagEvaluationDetails(
88+
flag_key=hook_context.flag_key,
89+
value=False,
90+
reason=Reason.ERROR,
91+
error_code=ErrorCode.FLAG_NOT_FOUND,
92+
error_message="flag error",
93+
)
94+
95+
# when
96+
event = create_evaluation_event(hook_context=hook_context, details=details)
97+
98+
# then
99+
assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "error"
100+
assert event.attributes[TelemetryAttribute.ERROR_TYPE] == "flag_not_found"
101+
assert event.attributes[TelemetryAttribute.EVALUATION_ERROR_MESSAGE] == "flag error"

0 commit comments

Comments
 (0)