diff --git a/src/main/java/dev/openfeature/sdk/EvaluationEvent.java b/src/main/java/dev/openfeature/sdk/EvaluationEvent.java new file mode 100644 index 000000000..09a3bb680 --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/EvaluationEvent.java @@ -0,0 +1,31 @@ +package dev.openfeature.sdk; + +import java.util.HashMap; +import java.util.Map; +import lombok.Builder; +import lombok.Getter; +import lombok.Singular; + +/** + * Represents an evaluation event. + */ +@Builder +@Getter +public class EvaluationEvent { + + private String name; + + @Singular("attribute") + private Map attributes; + + @Singular("bodyElement") + private Map body; + + public Map getAttributes() { + return new HashMap<>(attributes); + } + + public Map getBody() { + return new HashMap<>(body); + } +} diff --git a/src/main/java/dev/openfeature/sdk/Telemetry.java b/src/main/java/dev/openfeature/sdk/Telemetry.java new file mode 100644 index 000000000..0e99991fc --- /dev/null +++ b/src/main/java/dev/openfeature/sdk/Telemetry.java @@ -0,0 +1,98 @@ +package dev.openfeature.sdk; + +/** + * The Telemetry class provides constants and methods for creating OpenTelemetry compliant + * evaluation events. + */ +public class Telemetry { + + private Telemetry() {} + + /* + The OpenTelemetry compliant event attributes for flag evaluation. + Specification: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + */ + public static final String TELEMETRY_KEY = "feature_flag.key"; + public static final String TELEMETRY_ERROR_CODE = "error.type"; + public static final String TELEMETRY_VARIANT = "feature_flag.variant"; + public static final String TELEMETRY_CONTEXT_ID = "feature_flag.context.id"; + public static final String TELEMETRY_ERROR_MSG = "feature_flag.evaluation.error.message"; + public static final String TELEMETRY_REASON = "feature_flag.evaluation.reason"; + public static final String TELEMETRY_PROVIDER = "feature_flag.provider_name"; + public static final String TELEMETRY_FLAG_SET_ID = "feature_flag.set.id"; + public static final String TELEMETRY_VERSION = "feature_flag.version"; + + // Well-known flag metadata attributes for telemetry events. + // Specification: https://openfeature.dev/specification/appendix-d#flag-metadata + public static final String TELEMETRY_FLAG_META_CONTEXT_ID = "contextId"; + public static final String TELEMETRY_FLAG_META_FLAG_SET_ID = "flagSetId"; + public static final String TELEMETRY_FLAG_META_VERSION = "version"; + + // OpenTelemetry event body. + // Specification: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + public static final String TELEMETRY_BODY = "value"; + + public static final String FLAG_EVALUATION_EVENT_NAME = "feature_flag.evaluation"; + + /** + * Creates an EvaluationEvent using the provided HookContext and ProviderEvaluation. + * + * @param hookContext the context containing flag evaluation details + * @param providerEvaluation the evaluation result from the provider + * + * @return an EvaluationEvent populated with telemetry data + */ + public static EvaluationEvent createEvaluationEvent( + HookContext hookContext, ProviderEvaluation providerEvaluation) { + EvaluationEvent.EvaluationEventBuilder evaluationEventBuilder = EvaluationEvent.builder() + .name(FLAG_EVALUATION_EVENT_NAME) + .attribute(TELEMETRY_KEY, hookContext.getFlagKey()) + .attribute(TELEMETRY_PROVIDER, hookContext.getProviderMetadata().getName()); + + if (providerEvaluation.getReason() != null) { + evaluationEventBuilder.attribute( + TELEMETRY_REASON, providerEvaluation.getReason().toLowerCase()); + } else { + evaluationEventBuilder.attribute( + TELEMETRY_REASON, Reason.UNKNOWN.name().toLowerCase()); + } + + if (providerEvaluation.getVariant() != null) { + evaluationEventBuilder.attribute(TELEMETRY_VARIANT, providerEvaluation.getVariant()); + } else { + evaluationEventBuilder.bodyElement(TELEMETRY_BODY, providerEvaluation.getValue()); + } + + String contextId = providerEvaluation.getFlagMetadata().getString(TELEMETRY_FLAG_META_CONTEXT_ID); + if (contextId != null) { + evaluationEventBuilder.attribute(TELEMETRY_CONTEXT_ID, contextId); + } else { + evaluationEventBuilder.attribute( + TELEMETRY_CONTEXT_ID, hookContext.getCtx().getTargetingKey()); + } + + String setID = providerEvaluation.getFlagMetadata().getString(TELEMETRY_FLAG_META_FLAG_SET_ID); + if (setID != null) { + evaluationEventBuilder.attribute(TELEMETRY_FLAG_SET_ID, setID); + } + + String version = providerEvaluation.getFlagMetadata().getString(TELEMETRY_FLAG_META_VERSION); + if (version != null) { + evaluationEventBuilder.attribute(TELEMETRY_VERSION, version); + } + + if (Reason.ERROR.name().equals(providerEvaluation.getReason())) { + if (providerEvaluation.getErrorCode() != null) { + evaluationEventBuilder.attribute(TELEMETRY_ERROR_CODE, providerEvaluation.getErrorCode()); + } else { + evaluationEventBuilder.attribute(TELEMETRY_ERROR_CODE, ErrorCode.GENERAL); + } + + if (providerEvaluation.getErrorMessage() != null) { + evaluationEventBuilder.attribute(TELEMETRY_ERROR_MSG, providerEvaluation.getErrorMessage()); + } + } + + return evaluationEventBuilder.build(); + } +} diff --git a/src/test/java/dev/openfeature/sdk/TelemetryTest.java b/src/test/java/dev/openfeature/sdk/TelemetryTest.java new file mode 100644 index 000000000..1bd7ded8a --- /dev/null +++ b/src/test/java/dev/openfeature/sdk/TelemetryTest.java @@ -0,0 +1,227 @@ +package dev.openfeature.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +public class TelemetryTest { + + @Test + void testCreatesEvaluationEventWithMandatoryFields() { + // Arrange + String flagKey = "test-flag"; + String providerName = "test-provider"; + String reason = "static"; + + Metadata providerMetadata = mock(Metadata.class); + when(providerMetadata.getName()).thenReturn(providerName); + + HookContext hookContext = HookContext.builder() + .flagKey(flagKey) + .providerMetadata(providerMetadata) + .type(FlagValueType.BOOLEAN) + .defaultValue(false) + .ctx(new ImmutableContext()) + .build(); + + ProviderEvaluation evaluation = + ProviderEvaluation.builder().reason(reason).value(true).build(); + + EvaluationEvent event = Telemetry.createEvaluationEvent(hookContext, evaluation); + + assertEquals(Telemetry.FLAG_EVALUATION_EVENT_NAME, event.getName()); + assertEquals(flagKey, event.getAttributes().get(Telemetry.TELEMETRY_KEY)); + assertEquals(providerName, event.getAttributes().get(Telemetry.TELEMETRY_PROVIDER)); + assertEquals(reason.toLowerCase(), event.getAttributes().get(Telemetry.TELEMETRY_REASON)); + } + + @Test + void testHandlesNullReason() { + // Arrange + String flagKey = "test-flag"; + String providerName = "test-provider"; + + Metadata providerMetadata = mock(Metadata.class); + when(providerMetadata.getName()).thenReturn(providerName); + + HookContext hookContext = HookContext.builder() + .flagKey(flagKey) + .providerMetadata(providerMetadata) + .type(FlagValueType.BOOLEAN) + .defaultValue(false) + .ctx(new ImmutableContext()) + .build(); + + ProviderEvaluation evaluation = + ProviderEvaluation.builder().reason(null).value(true).build(); + + EvaluationEvent event = Telemetry.createEvaluationEvent(hookContext, evaluation); + + assertEquals(Reason.UNKNOWN.name().toLowerCase(), event.getAttributes().get(Telemetry.TELEMETRY_REASON)); + } + + @Test + void testSetsVariantAttributeWhenVariantExists() { + HookContext hookContext = HookContext.builder() + .flagKey("testFlag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(mock(EvaluationContext.class)) + .clientMetadata(mock(ClientMetadata.class)) + .providerMetadata(mock(Metadata.class)) + .build(); + + ProviderEvaluation providerEvaluation = ProviderEvaluation.builder() + .variant("testVariant") + .flagMetadata(ImmutableMetadata.builder().build()) + .build(); + + EvaluationEvent event = Telemetry.createEvaluationEvent(hookContext, providerEvaluation); + + assertEquals("testVariant", event.getAttributes().get(Telemetry.TELEMETRY_VARIANT)); + } + + @Test + void test_sets_value_in_body_when_variant_is_null() { + HookContext hookContext = HookContext.builder() + .flagKey("testFlag") + .type(FlagValueType.STRING) + .defaultValue("default") + .ctx(mock(EvaluationContext.class)) + .clientMetadata(mock(ClientMetadata.class)) + .providerMetadata(mock(Metadata.class)) + .build(); + + ProviderEvaluation providerEvaluation = ProviderEvaluation.builder() + .value("testValue") + .flagMetadata(ImmutableMetadata.builder().build()) + .build(); + + EvaluationEvent event = Telemetry.createEvaluationEvent(hookContext, providerEvaluation); + + assertEquals("testValue", event.getBody().get(Telemetry.TELEMETRY_BODY)); + } + + @Test + void testAllFieldsPopulated() { + EvaluationContext evaluationContext = mock(EvaluationContext.class); + when(evaluationContext.getTargetingKey()).thenReturn("realTargetingKey"); + + Metadata providerMetadata = mock(Metadata.class); + when(providerMetadata.getName()).thenReturn("realProviderName"); + + HookContext hookContext = HookContext.builder() + .flagKey("realFlag") + .type(FlagValueType.STRING) + .defaultValue("realDefault") + .ctx(evaluationContext) + .clientMetadata(mock(ClientMetadata.class)) + .providerMetadata(providerMetadata) + .build(); + + ProviderEvaluation providerEvaluation = ProviderEvaluation.builder() + .flagMetadata(ImmutableMetadata.builder() + .addString("contextId", "realContextId") + .addString("flagSetId", "realFlagSetId") + .addString("version", "realVersion") + .build()) + .reason(Reason.DEFAULT.name()) + .variant("realVariant") + .build(); + + EvaluationEvent event = Telemetry.createEvaluationEvent(hookContext, providerEvaluation); + + assertEquals("realFlag", event.getAttributes().get(Telemetry.TELEMETRY_KEY)); + assertEquals("realProviderName", event.getAttributes().get(Telemetry.TELEMETRY_PROVIDER)); + assertEquals("default", event.getAttributes().get(Telemetry.TELEMETRY_REASON)); + assertEquals("realContextId", event.getAttributes().get(Telemetry.TELEMETRY_CONTEXT_ID)); + assertEquals("realFlagSetId", event.getAttributes().get(Telemetry.TELEMETRY_FLAG_SET_ID)); + assertEquals("realVersion", event.getAttributes().get(Telemetry.TELEMETRY_VERSION)); + assertNull(event.getAttributes().get(Telemetry.TELEMETRY_ERROR_CODE)); + assertEquals("realVariant", event.getAttributes().get(Telemetry.TELEMETRY_VARIANT)); + } + + @Test + void testErrorEvaluation() { + EvaluationContext evaluationContext = mock(EvaluationContext.class); + when(evaluationContext.getTargetingKey()).thenReturn("realTargetingKey"); + + Metadata providerMetadata = mock(Metadata.class); + when(providerMetadata.getName()).thenReturn("realProviderName"); + + HookContext hookContext = HookContext.builder() + .flagKey("realFlag") + .type(FlagValueType.STRING) + .defaultValue("realDefault") + .ctx(evaluationContext) + .clientMetadata(mock(ClientMetadata.class)) + .providerMetadata(providerMetadata) + .build(); + + ProviderEvaluation providerEvaluation = ProviderEvaluation.builder() + .flagMetadata(ImmutableMetadata.builder() + .addString("contextId", "realContextId") + .addString("flagSetId", "realFlagSetId") + .addString("version", "realVersion") + .build()) + .reason(Reason.ERROR.name()) + .errorMessage("realErrorMessage") + .build(); + + EvaluationEvent event = Telemetry.createEvaluationEvent(hookContext, providerEvaluation); + + assertEquals("realFlag", event.getAttributes().get(Telemetry.TELEMETRY_KEY)); + assertEquals("realProviderName", event.getAttributes().get(Telemetry.TELEMETRY_PROVIDER)); + assertEquals("error", event.getAttributes().get(Telemetry.TELEMETRY_REASON)); + assertEquals("realContextId", event.getAttributes().get(Telemetry.TELEMETRY_CONTEXT_ID)); + assertEquals("realFlagSetId", event.getAttributes().get(Telemetry.TELEMETRY_FLAG_SET_ID)); + assertEquals("realVersion", event.getAttributes().get(Telemetry.TELEMETRY_VERSION)); + assertEquals(ErrorCode.GENERAL, event.getAttributes().get(Telemetry.TELEMETRY_ERROR_CODE)); + assertEquals("realErrorMessage", event.getAttributes().get(Telemetry.TELEMETRY_ERROR_MSG)); + assertNull(event.getAttributes().get(Telemetry.TELEMETRY_VARIANT)); + } + + @Test + void testErrorCodeEvaluation() { + EvaluationContext evaluationContext = mock(EvaluationContext.class); + when(evaluationContext.getTargetingKey()).thenReturn("realTargetingKey"); + + Metadata providerMetadata = mock(Metadata.class); + when(providerMetadata.getName()).thenReturn("realProviderName"); + + HookContext hookContext = HookContext.builder() + .flagKey("realFlag") + .type(FlagValueType.STRING) + .defaultValue("realDefault") + .ctx(evaluationContext) + .clientMetadata(mock(ClientMetadata.class)) + .providerMetadata(providerMetadata) + .build(); + + ProviderEvaluation providerEvaluation = ProviderEvaluation.builder() + .flagMetadata(ImmutableMetadata.builder() + .addString("contextId", "realContextId") + .addString("flagSetId", "realFlagSetId") + .addString("version", "realVersion") + .build()) + .reason(Reason.ERROR.name()) + .errorMessage("realErrorMessage") + .errorCode(ErrorCode.INVALID_CONTEXT) + .build(); + + EvaluationEvent event = Telemetry.createEvaluationEvent(hookContext, providerEvaluation); + + assertEquals("realFlag", event.getAttributes().get(Telemetry.TELEMETRY_KEY)); + assertEquals("realProviderName", event.getAttributes().get(Telemetry.TELEMETRY_PROVIDER)); + assertEquals("error", event.getAttributes().get(Telemetry.TELEMETRY_REASON)); + assertEquals("realContextId", event.getAttributes().get(Telemetry.TELEMETRY_CONTEXT_ID)); + assertEquals("realFlagSetId", event.getAttributes().get(Telemetry.TELEMETRY_FLAG_SET_ID)); + assertEquals("realVersion", event.getAttributes().get(Telemetry.TELEMETRY_VERSION)); + assertEquals(ErrorCode.INVALID_CONTEXT, event.getAttributes().get(Telemetry.TELEMETRY_ERROR_CODE)); + assertEquals("realErrorMessage", event.getAttributes().get(Telemetry.TELEMETRY_ERROR_MSG)); + assertNull(event.getAttributes().get(Telemetry.TELEMETRY_VARIANT)); + } +}