Skip to content

feat: add telemetry helper utils #1346

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

Merged
merged 7 commits into from
May 13, 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
24 changes: 24 additions & 0 deletions src/main/java/dev/openfeature/sdk/EvaluationEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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<String, Object> attributes;

public Map<String, Object> getAttributes() {
return new HashMap<>(attributes);
}
}
95 changes: 95 additions & 0 deletions src/main/java/dev/openfeature/sdk/Telemetry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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.result.variant";
public static final String TELEMETRY_VALUE = "feature_flag.result.value";
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.result.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";

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 evaluationDetails the evaluation result from the provider
*
* @return an EvaluationEvent populated with telemetry data
*/
public static EvaluationEvent createEvaluationEvent(
HookContext<?> hookContext, FlagEvaluationDetails<?> evaluationDetails) {
EvaluationEvent.EvaluationEventBuilder evaluationEventBuilder = EvaluationEvent.builder()
.name(FLAG_EVALUATION_EVENT_NAME)
.attribute(TELEMETRY_KEY, hookContext.getFlagKey())
.attribute(TELEMETRY_PROVIDER, hookContext.getProviderMetadata().getName());

if (evaluationDetails.getReason() != null) {
evaluationEventBuilder.attribute(
TELEMETRY_REASON, evaluationDetails.getReason().toLowerCase());
} else {
evaluationEventBuilder.attribute(
TELEMETRY_REASON, Reason.UNKNOWN.name().toLowerCase());
}

if (evaluationDetails.getVariant() != null) {
evaluationEventBuilder.attribute(TELEMETRY_VARIANT, evaluationDetails.getVariant());
} else {
evaluationEventBuilder.attribute(TELEMETRY_VALUE, evaluationDetails.getValue());
}

String contextId = evaluationDetails.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 = evaluationDetails.getFlagMetadata().getString(TELEMETRY_FLAG_META_FLAG_SET_ID);
if (setID != null) {
evaluationEventBuilder.attribute(TELEMETRY_FLAG_SET_ID, setID);
}

String version = evaluationDetails.getFlagMetadata().getString(TELEMETRY_FLAG_META_VERSION);
if (version != null) {
evaluationEventBuilder.attribute(TELEMETRY_VERSION, version);
}

if (Reason.ERROR.name().equals(evaluationDetails.getReason())) {
if (evaluationDetails.getErrorCode() != null) {
evaluationEventBuilder.attribute(TELEMETRY_ERROR_CODE, evaluationDetails.getErrorCode());
} else {
evaluationEventBuilder.attribute(TELEMETRY_ERROR_CODE, ErrorCode.GENERAL);
}

if (evaluationDetails.getErrorMessage() != null) {
evaluationEventBuilder.attribute(TELEMETRY_ERROR_MSG, evaluationDetails.getErrorMessage());
}
}

return evaluationEventBuilder.build();
}
}
231 changes: 231 additions & 0 deletions src/test/java/dev/openfeature/sdk/TelemetryTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
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<Boolean> hookContext = HookContext.<Boolean>builder()
.flagKey(flagKey)
.providerMetadata(providerMetadata)
.type(FlagValueType.BOOLEAN)
.defaultValue(false)
.ctx(new ImmutableContext())
.build();

FlagEvaluationDetails<Boolean> evaluation = FlagEvaluationDetails.<Boolean>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<Boolean> hookContext = HookContext.<Boolean>builder()
.flagKey(flagKey)
.providerMetadata(providerMetadata)
.type(FlagValueType.BOOLEAN)
.defaultValue(false)
.ctx(new ImmutableContext())
.build();

FlagEvaluationDetails<Boolean> evaluation = FlagEvaluationDetails.<Boolean>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<String> hookContext = HookContext.<String>builder()
.flagKey("testFlag")
.type(FlagValueType.STRING)
.defaultValue("default")
.ctx(mock(EvaluationContext.class))
.clientMetadata(mock(ClientMetadata.class))
.providerMetadata(mock(Metadata.class))
.build();

FlagEvaluationDetails<String> providerEvaluation = FlagEvaluationDetails.<String>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<String> hookContext = HookContext.<String>builder()
.flagKey("testFlag")
.type(FlagValueType.STRING)
.defaultValue("default")
.ctx(mock(EvaluationContext.class))
.clientMetadata(mock(ClientMetadata.class))
.providerMetadata(mock(Metadata.class))
.build();

FlagEvaluationDetails<String> providerEvaluation = FlagEvaluationDetails.<String>builder()
.value("testValue")
.flagMetadata(ImmutableMetadata.builder().build())
.build();

EvaluationEvent event = Telemetry.createEvaluationEvent(hookContext, providerEvaluation);

assertEquals("testValue", event.getAttributes().get(Telemetry.TELEMETRY_VALUE));
}

@Test
void testAllFieldsPopulated() {
EvaluationContext evaluationContext = mock(EvaluationContext.class);
when(evaluationContext.getTargetingKey()).thenReturn("realTargetingKey");

Metadata providerMetadata = mock(Metadata.class);
when(providerMetadata.getName()).thenReturn("realProviderName");

HookContext<String> hookContext = HookContext.<String>builder()
.flagKey("realFlag")
.type(FlagValueType.STRING)
.defaultValue("realDefault")
.ctx(evaluationContext)
.clientMetadata(mock(ClientMetadata.class))
.providerMetadata(providerMetadata)
.build();

FlagEvaluationDetails<String> providerEvaluation = FlagEvaluationDetails.<String>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<String> hookContext = HookContext.<String>builder()
.flagKey("realFlag")
.type(FlagValueType.STRING)
.defaultValue("realDefault")
.ctx(evaluationContext)
.clientMetadata(mock(ClientMetadata.class))
.providerMetadata(providerMetadata)
.build();

FlagEvaluationDetails<String> providerEvaluation = FlagEvaluationDetails.<String>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<String> hookContext = HookContext.<String>builder()
.flagKey("realFlag")
.type(FlagValueType.STRING)
.defaultValue("realDefault")
.ctx(evaluationContext)
.clientMetadata(mock(ClientMetadata.class))
.providerMetadata(providerMetadata)
.build();

FlagEvaluationDetails<String> providerEvaluation = FlagEvaluationDetails.<String>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));
}
}
Loading