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 telemetry helper utils #1346

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
31 changes: 31 additions & 0 deletions src/main/java/dev/openfeature/sdk/EvaluationEvent.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> attributes;

@Singular("bodyElement")
private Map<String, Object> body;

public Map<String, Object> getAttributes() {
return new HashMap<>(attributes);
}

public Map<String, Object> getBody() {
return new HashMap<>(body);
}
}
98 changes: 98 additions & 0 deletions src/main/java/dev/openfeature/sdk/Telemetry.java
Original file line number Diff line number Diff line change
@@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't we want to use FlagEvaluationDetails here instead of the ProviderEvaluation, as pointed out here?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, good catch. The finally hook was updated in this PR.

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

ProviderEvaluation<Boolean> evaluation =
ProviderEvaluation.<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();

ProviderEvaluation<Boolean> evaluation =
ProviderEvaluation.<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();

ProviderEvaluation<String> providerEvaluation = ProviderEvaluation.<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();

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

ProviderEvaluation<String> providerEvaluation = ProviderEvaluation.<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();

ProviderEvaluation<String> providerEvaluation = ProviderEvaluation.<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();

ProviderEvaluation<String> providerEvaluation = ProviderEvaluation.<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