Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,77 +1,4 @@
import json
from .metric import MetricsHook
from .trace import TracingHook

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.evaluation"


class EventAttributes:
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):
def __init__(self, exclude_exceptions: bool = False):
self.exclude_exceptions = exclude_exceptions

def finally_after(
self,
hook_context: HookContext,
details: FlagEvaluationDetails,
hints: HookHints,
) -> None:
current_span = trace.get_current_span()

event_attributes = {
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
)

current_span.add_event(OTEL_EVENT_NAME, event_attributes)

def error(
self, hook_context: HookContext, exception: Exception, hints: HookHints
) -> None:
if self.exclude_exceptions:
return
attributes = {
EventAttributes.KEY: hook_context.flag_key,
EventAttributes.RESULT_VALUE: json.dumps(hook_context.default_value),
}
if hook_context.provider_metadata:
attributes[EventAttributes.PROVIDER_NAME] = (
hook_context.provider_metadata.name
)
current_span = trace.get_current_span()
current_span.record_exception(exception, attributes)
__all__ = ["MetricsHook", "TracingHook"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
class Attributes:
OTEL_CONTEXT_ID = "feature_flag.context.id"
OTEL_EVENT_NAME = "feature_flag.evaluation"
OTEL_ERROR_TYPE = "error.type"
OTEL_ERROR_MESSAGE = "error.message"
OTEL_FLAG_KEY = "feature_flag.key"
OTEL_FLAG_VARIANT = "feature_flag.result.variant"
OTEL_PROVIDER_NAME = "feature_flag.provider.name"
OTEL_RESULT_VALUE = "feature_flag.result.value"
OTEL_RESULT_REASON = "feature_flag.result.reason"
OTEL_SET_ID = "feature_flag.set.id"
OTEL_VERSION = "feature_flag.version"


class Metrics:
ACTIVE_COUNT = "feature_flag.evaluation.active_count"
SUCCESS_TOTAL = "feature_flag.evaluation.success_total"
REQUEST_TOTAL = "feature_flag.evaluation.request_total"
ERROR_TOTAL = "feature_flag.evaluation.error_total"
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import typing

from openfeature.flag_evaluation import FlagEvaluationDetails, FlagMetadata, Reason
from openfeature.hook import Hook, HookContext, HookHints
from opentelemetry import metrics
from opentelemetry.util.types import AttributeValue

from .constants import Attributes, Metrics


class MetricsHook(Hook):
def __init__(self, extra_attributes: typing.Optional[list[str]] = None) -> None:
self.extra_attributes = extra_attributes or []
meter: metrics.Meter = metrics.get_meter("openfeature.hooks.opentelemetry")
self.evaluation_active_count = meter.create_up_down_counter(
Metrics.ACTIVE_COUNT, "active flag evaluations"
)
self.evaluation_error_total = meter.create_counter(
Metrics.ERROR_TOTAL, "error flag evaluations"
)
self.evaluation_success_total = meter.create_counter(
Metrics.SUCCESS_TOTAL, "success flag evaluations"
)
self.evaluation_request_total = meter.create_counter(
Metrics.REQUEST_TOTAL, "request flag evaluations"
)

def before(self, hook_context: HookContext, hints: HookHints) -> None:
attributes: dict[str, AttributeValue] = {
Attributes.OTEL_FLAG_KEY: hook_context.flag_key,
}
if hook_context.provider_metadata:
attributes[Attributes.OTEL_PROVIDER_NAME] = (
hook_context.provider_metadata.name
)
self.evaluation_active_count.add(1, attributes)
self.evaluation_request_total.add(1, attributes)

def after(
self,
hook_context: HookContext,
details: FlagEvaluationDetails,
hints: HookHints,
) -> None:
attributes: dict[str, AttributeValue] = {
Attributes.OTEL_FLAG_KEY: details.flag_key,
Attributes.OTEL_RESULT_REASON: str(
details.reason or Reason.UNKNOWN
).lower(),
}
if details.variant:
attributes[Attributes.OTEL_FLAG_VARIANT] = details.variant
if hook_context.provider_metadata:
attributes[Attributes.OTEL_PROVIDER_NAME] = (
hook_context.provider_metadata.name
)
attributes = attributes | get_extra_attributes(
self.extra_attributes, details.flag_metadata
)
self.evaluation_success_total.add(1, attributes)

def error(
self, hook_context: HookContext, exception: Exception, hints: HookHints
) -> None:
attributes: dict[str, AttributeValue] = {
Attributes.OTEL_FLAG_KEY: hook_context.flag_key,
"exception": str(exception).lower(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using str(exception) as a metric attribute value can lead to high cardinality, which is an anti-pattern for metrics and can cause issues with your monitoring backend (e.g., performance, cost). The OpenFeature Enhancement Proposal (OFEP) for metric hooks suggests using error.type with a low-cardinality value.

To align with best practices and the OFEP, I suggest using the exception's type name instead of its string representation. This will keep the cardinality of the error.type attribute low, addressing the concern you raised in the PR description.

Suggested change
"exception": str(exception).lower(),
Attributes.OTEL_ERROR_TYPE: type(exception).__name__,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this in principle, but the OFEP specifically states to use the message

@toddbaert @beeme1mr can you confirm?

}
if hook_context.provider_metadata:
attributes[Attributes.OTEL_PROVIDER_NAME] = (
hook_context.provider_metadata.name
)
self.evaluation_error_total.add(1, attributes)

def finally_after(
self,
hook_context: HookContext,
details: FlagEvaluationDetails,
hints: HookHints,
) -> None:
attributes: dict[str, AttributeValue] = {
Attributes.OTEL_FLAG_KEY: hook_context.flag_key,
}
if hook_context.provider_metadata:
attributes[Attributes.OTEL_PROVIDER_NAME] = (
hook_context.provider_metadata.name
)
self.evaluation_active_count.add(-1, attributes)


def get_extra_attributes(
extra_attributes: list[str], metadata: FlagMetadata
) -> dict[str, AttributeValue]:
attributes: dict[str, AttributeValue] = {}
for attribute in extra_attributes:
if (attr := metadata.get(attribute)) is not None:
attributes[attribute] = attr
return attributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import json

from openfeature.exception import ErrorCode
from openfeature.flag_evaluation import FlagEvaluationDetails, Reason
from openfeature.hook import Hook, HookContext, HookHints
from opentelemetry import trace

from .constants import Attributes


class TracingHook(Hook):
def __init__(self, exclude_exceptions: bool = False):
self.exclude_exceptions = exclude_exceptions

def finally_after(
self,
hook_context: HookContext,
details: FlagEvaluationDetails,
hints: HookHints,
) -> None:
current_span = trace.get_current_span()

event_attributes = {
Attributes.OTEL_FLAG_KEY: details.flag_key,
Attributes.OTEL_RESULT_VALUE: json.dumps(details.value),
Attributes.OTEL_RESULT_REASON: str(
details.reason or Reason.UNKNOWN
).lower(),
}

if details.variant:
event_attributes[Attributes.OTEL_FLAG_VARIANT] = details.variant

if details.reason == Reason.ERROR:
error_type = str(details.error_code or ErrorCode.GENERAL).lower()
event_attributes[Attributes.OTEL_ERROR_TYPE] = error_type
if details.error_message:
event_attributes[Attributes.OTEL_ERROR_MESSAGE] = details.error_message

context = hook_context.evaluation_context
if context.targeting_key:
event_attributes[Attributes.OTEL_CONTEXT_ID] = context.targeting_key

if hook_context.provider_metadata:
event_attributes[Attributes.OTEL_PROVIDER_NAME] = (
hook_context.provider_metadata.name
)

current_span.add_event(Attributes.OTEL_EVENT_NAME, event_attributes)

def error(
self, hook_context: HookContext, exception: Exception, hints: HookHints
) -> None:
if self.exclude_exceptions:
return
attributes = {
Attributes.OTEL_FLAG_KEY: hook_context.flag_key,
Attributes.OTEL_RESULT_VALUE: json.dumps(hook_context.default_value),
}
if hook_context.provider_metadata:
attributes[Attributes.OTEL_PROVIDER_NAME] = (
hook_context.provider_metadata.name
)
current_span = trace.get_current_span()
current_span.record_exception(exception, attributes)
Loading