Repository: open-feature/go-sdk (go)

Issue:
Add OpenTelemetry-Compatible Telemetry Utility Function
## Description

Add a telemetry utility function to the SDK that can be used within the [finally stage](https://openfeature.dev/specification/sections/hooks/#requirement-442) of a hook to create OpenTelemetry-compatible telemetry signals. It must comply with the OpenTelemetry specification and meet the technical requirements, including those from [Appendix D](https://openfeature.dev/specification/appendix-d/) of the OpenFeature Specification and the [OpenTelemetry Semantic Conventions for Feature Flags](https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/).

## Requirements

- Must comply with the OpenTelemetry (OTel) specification.
- Must be included in the SDK.
- Must not include any third-party dependencies.
- Must adhere to the observability conformance requirements in Appendix D of the OpenFeature Specification.

## Example implementation

A JavaScript implementation can found [here](https://github.com/open-feature/js-sdk/pull/1120).


Pull request description:
feat: add OTel event creation util func
<!-- Please use this template for your pull request. -->
<!-- Please use the sections that you need and delete other sections -->

## This PR
<!-- add the description of the PR here -->

- it should be equivalent to the JS implementation
- added the logic to return the evaluation flag event 

### Related Issues
<!-- add here the GitHub issue that this PR resolves if applicable -->

Fixes #322

### How to test

- Unit, Fuzz & E2E tests all pass locally


Patch:
diff --git a/openfeature/telemetry/telemetry.go b/openfeature/telemetry/telemetry.go
new file mode 100644
index 0000000..82ff67c
--- /dev/null
+++ b/openfeature/telemetry/telemetry.go
@@ -0,0 +1,97 @@
+package telemetry
+
+import (
+	"strings"
+
+	"github.com/open-feature/go-sdk/openfeature"
+)
+
+type EvaluationEvent struct {
+	Name       string
+	Attributes map[string]any
+	Body       map[string]any
+}
+
+const (
+	// The OpenTelemetry compliant event attributes for flag evaluation.
+	// Specification: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
+
+	TelemetryKey string = "feature_flag.key"
+	TelemetryErrorCode string = "error.type"
+	TelemetryVariant string = "feature_flag.variant"
+	TelemetryContextID string = "feature_flag.context.id"
+	TelemetryErrorMsg string = "feature_flag.evaluation.error.message"
+	TelemetryReason string = "feature_flag.evaluation.reason"
+	TelemetryProvider string = "feature_flag.provider_name"
+	TelemetryFlagSetID string = "feature_flag.set.id"
+	TelemetryVersion string = "feature_flag.version"
+
+
+	// Well-known flag metadata attributes for telemetry events.
+	// Specification: https://openfeature.dev/specification/appendix-d#flag-metadata
+	TelemetryFlagMetaContextId string = "contextId"
+	TelemetryFlagMetaFlagSetId string = "flagSetId"
+	TelemetryFlagMetaVersion string = "version"
+
+	// OpenTelemetry event body.
+	// Specification: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/
+	TelemetryBody string = "value"
+
+	FlagEvaluationEventName string = "feature_flag.evaluation"
+)
+
+func CreateEvaluationEvent(hookContext openfeature.HookContext, details openfeature.InterfaceEvaluationDetails) EvaluationEvent {
+	attributes := map[string]any{
+		TelemetryKey: hookContext.FlagKey(),
+		TelemetryProvider: hookContext.ProviderMetadata().Name,
+	}
+
+	if details.EvaluationDetails.ResolutionDetail.Reason != "" {
+		attributes[TelemetryReason] = strings.ToLower(string(details.ResolutionDetail.Reason))
+	} else {
+		attributes[TelemetryReason] = strings.ToLower(string(openfeature.UnknownReason))
+	}
+
+	body := map[string]any{}
+
+	if details.Variant != "" {
+		attributes[TelemetryVariant] = details.EvaluationDetails.ResolutionDetail.Variant
+	} else {
+		body[TelemetryBody] = details.Value
+	}
+
+	contextID, exists := details.EvaluationDetails.ResolutionDetail.FlagMetadata[TelemetryFlagMetaContextId]
+	if !exists {
+		contextID = hookContext.EvaluationContext().TargetingKey()
+	} 
+	
+	attributes[TelemetryContextID] = contextID
+	
+	setID, exists := details.EvaluationDetails.ResolutionDetail.FlagMetadata[TelemetryFlagMetaFlagSetId]
+	if exists {
+		attributes[TelemetryFlagSetID] = setID
+	}
+
+	version, exists := details.EvaluationDetails.ResolutionDetail.FlagMetadata[TelemetryFlagMetaVersion]
+	if exists {
+		attributes[TelemetryVersion] = version
+	}
+
+	if details.EvaluationDetails.ResolutionDetail.Reason == openfeature.ErrorReason {
+		if details.ResolutionDetail.ErrorCode != "" {
+			attributes[TelemetryErrorCode] = details.ResolutionDetail.ErrorCode
+		} else {
+			attributes[TelemetryErrorCode] = openfeature.GeneralCode
+		}
+
+		if details.ResolutionDetail.ErrorMessage != "" {
+			attributes[TelemetryErrorMsg] = details.ResolutionDetail.ErrorMessage
+		}
+	}
+
+	return EvaluationEvent{
+		Name:       FlagEvaluationEventName,
+		Attributes: attributes,
+		Body:       body,
+	}
+}


Test patch:
diff --git a/openfeature/telemetry/telemetry_test.go b/openfeature/telemetry/telemetry_test.go
new file mode 100644
index 0000000..a0c7321
--- /dev/null
+++ b/openfeature/telemetry/telemetry_test.go
@@ -0,0 +1,258 @@
+package telemetry
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/open-feature/go-sdk/openfeature"
+)
+
+func TestCreateEvaluationEvent_1_3_1_BasicEvent(t *testing.T) {
+	flagKey := "test-flag"
+
+	mockProviderMetadata := openfeature.Metadata{
+		Name: "test-provider",
+	}
+
+	mockClientMetadata := openfeature.NewClientMetadata("test-client")
+
+	mockEvalCtx := openfeature.NewEvaluationContext(
+		"test-target-key", map[string]any{
+			"is": "a test",
+		})
+
+	mockHookContext := openfeature.NewHookContext(flagKey, openfeature.Boolean, true, mockClientMetadata, mockProviderMetadata, mockEvalCtx)
+
+	mockDetails := openfeature.InterfaceEvaluationDetails{
+		Value: true,
+		EvaluationDetails: openfeature.EvaluationDetails{
+			FlagKey:  flagKey,
+			FlagType: openfeature.Boolean,
+			ResolutionDetail: openfeature.ResolutionDetail{
+				Reason:       openfeature.StaticReason,
+				FlagMetadata: openfeature.FlagMetadata{},
+			},
+		},
+	}
+
+	event := CreateEvaluationEvent(mockHookContext, mockDetails)
+
+	if event.Name != "feature_flag.evaluation" {
+		t.Errorf("Expected event name to be 'feature_flag.evaluation', got '%s'", event.Name)
+	}
+
+	if event.Attributes[TelemetryKey] != flagKey {
+		t.Errorf("Expected event attribute 'KEY' to be '%s', got '%s'", flagKey, event.Attributes[TelemetryKey])
+	}
+
+	if event.Attributes[TelemetryReason] != strings.ToLower(string(openfeature.StaticReason)) {
+		t.Errorf("Expected evaluation reason to be '%s', got '%s'", strings.ToLower(string(openfeature.StaticReason)), event.Attributes[TelemetryReason])
+	}
+
+	if event.Attributes[TelemetryProvider] != "test-provider" {
+		t.Errorf("Expected provider name to be 'test-provider', got '%s'", event.Attributes[TelemetryProvider])
+	}
+
+	if event.Body[TelemetryBody] != true {
+		t.Errorf("Expected event body 'VALUE' to be 'true', got '%v'", event.Body[TelemetryBody])
+	}
+}
+
+func TestCreateEvaluationEvent_1_4_6_WithVariant(t *testing.T) {
+
+	flagKey := "test-flag"
+
+	mockProviderMetadata := openfeature.Metadata{
+		Name: "test-provider",
+	}
+
+	mockClientMetadata := openfeature.NewClientMetadata("test-client")
+
+	mockEvalCtx := openfeature.NewEvaluationContext(
+		"test-target-key", map[string]any{
+			"is": "a test",
+		})
+
+	mockHookContext := openfeature.NewHookContext(flagKey, openfeature.Boolean, true, mockClientMetadata, mockProviderMetadata, mockEvalCtx)
+
+	mockDetails := openfeature.InterfaceEvaluationDetails{
+		Value: true,
+		EvaluationDetails: openfeature.EvaluationDetails{
+			FlagKey:  flagKey,
+			FlagType: openfeature.Boolean,
+			ResolutionDetail: openfeature.ResolutionDetail{
+				Variant: "true",
+			},
+		},
+	}
+
+	event := CreateEvaluationEvent(mockHookContext, mockDetails)
+
+	if event.Name != "feature_flag.evaluation" {
+		t.Errorf("Expected event name to be 'feature_flag.evaluation', got '%s'", event.Name)
+	}
+
+	if event.Attributes[TelemetryKey] != flagKey {
+		t.Errorf("Expected event attribute 'KEY' to be '%s', got '%s'", flagKey, event.Attributes[TelemetryKey])
+	}
+
+	if event.Attributes[TelemetryVariant] != "true" {
+		t.Errorf("Expected event attribute 'VARIANT' to be 'true', got '%s'", event.Attributes[TelemetryVariant])
+	}
+
+}
+func TestCreateEvaluationEvent_1_4_14_WithFlagMetaData(t *testing.T) {
+	flagKey := "test-flag"
+
+	mockProviderMetadata := openfeature.Metadata{
+		Name: "test-provider",
+	}
+
+	mockClientMetadata := openfeature.NewClientMetadata("test-client")
+
+	mockEvalCtx := openfeature.NewEvaluationContext(
+		"test-target-key", map[string]any{
+			"is": "a test",
+		})
+
+	mockHookContext := openfeature.NewHookContext(flagKey, openfeature.Boolean, false, mockClientMetadata, mockProviderMetadata, mockEvalCtx)
+
+	mockDetails := openfeature.InterfaceEvaluationDetails{
+		Value: false,
+		EvaluationDetails: openfeature.EvaluationDetails{
+			FlagKey:  flagKey,
+			FlagType: openfeature.Boolean,
+			ResolutionDetail: openfeature.ResolutionDetail{
+				FlagMetadata: openfeature.FlagMetadata{
+					TelemetryFlagMetaFlagSetId: "test-set",
+					TelemetryFlagMetaContextId: "metadata-context",
+					TelemetryFlagMetaVersion:   "v1.0",
+				},
+			},
+		},
+	}
+
+	event := CreateEvaluationEvent(mockHookContext, mockDetails)
+
+	if event.Attributes[TelemetryFlagSetID] != "test-set" {
+		t.Errorf("Expected 'Flag SetID' in Flag Metadata name to be 'test-set', got '%s'", event.Attributes[TelemetryFlagMetaFlagSetId])
+	}
+
+	if event.Attributes[TelemetryContextID] != "metadata-context" {
+		t.Errorf("Expected 'Flag ContextID' in Flag Metadata name to be 'metadata-context', got '%s'", event.Attributes[TelemetryFlagMetaContextId])
+	}
+
+	if event.Attributes[TelemetryVersion] != "v1.0" {
+		t.Errorf("Expected 'Flag Version' in Flag Metadata name to be 'v1.0', got '%s'", event.Attributes[TelemetryFlagMetaVersion])
+	}
+}
+func TestCreateEvaluationEvent_1_4_8_WithErrors(t *testing.T) {
+	flagKey := "test-flag"
+
+	mockProviderMetadata := openfeature.Metadata{
+		Name: "test-provider",
+	}
+
+	mockClientMetadata := openfeature.NewClientMetadata("test-client")
+
+	mockEvalCtx := openfeature.NewEvaluationContext(
+		"test-target-key", map[string]any{
+			"is": "a test",
+		})
+
+	mockHookContext := openfeature.NewHookContext(flagKey, openfeature.Boolean, false, mockClientMetadata, mockProviderMetadata, mockEvalCtx)
+
+	mockDetails := openfeature.InterfaceEvaluationDetails{
+		Value: false,
+		EvaluationDetails: openfeature.EvaluationDetails{
+			FlagKey: flagKey,
+			ResolutionDetail: openfeature.ResolutionDetail{
+				Reason:       openfeature.ErrorReason,
+				ErrorCode:    openfeature.FlagNotFoundCode,
+				ErrorMessage: "a test error",
+				FlagMetadata: openfeature.FlagMetadata{},
+			},
+		},
+	}
+
+	event := CreateEvaluationEvent(mockHookContext, mockDetails)
+
+	if event.Attributes[TelemetryErrorCode] != openfeature.FlagNotFoundCode {
+		t.Errorf("Expected 'ERROR_CODE' to be 'GENERAL', got '%s'", event.Attributes[TelemetryErrorCode])
+	}
+
+	if event.Attributes[TelemetryErrorMsg] != "a test error" {
+		t.Errorf("Expected 'ERROR_MESSAGE' to be 'a test error', got '%s'", event.Attributes[TelemetryErrorMsg])
+	}
+}
+
+func TestCreateEvaluationEvent_1_4_8_WithGeneralErrors(t *testing.T) {
+	flagKey := "test-flag"
+
+	mockProviderMetadata := openfeature.Metadata{
+		Name: "test-provider",
+	}
+
+	mockClientMetadata := openfeature.NewClientMetadata("test-client")
+
+	mockEvalCtx := openfeature.NewEvaluationContext(
+		"test-target-key", map[string]any{
+			"is": "a test",
+		})
+
+	mockHookContext := openfeature.NewHookContext(flagKey, openfeature.Boolean, false, mockClientMetadata, mockProviderMetadata, mockEvalCtx)
+
+	mockDetails := openfeature.InterfaceEvaluationDetails{
+		Value: false,
+		EvaluationDetails: openfeature.EvaluationDetails{
+			FlagKey: flagKey,
+			ResolutionDetail: openfeature.ResolutionDetail{
+				Reason:       openfeature.ErrorReason,
+				ErrorMessage: "a test error",
+				FlagMetadata: openfeature.FlagMetadata{},
+			},
+		},
+	}
+
+	event := CreateEvaluationEvent(mockHookContext, mockDetails)
+
+	if event.Attributes[TelemetryErrorCode] != openfeature.GeneralCode {
+		t.Errorf("Expected 'ERROR_CODE' to be 'GENERAL', got '%s'", event.Attributes[TelemetryErrorCode])
+	}
+
+	if event.Attributes[TelemetryErrorMsg] != "a test error" {
+		t.Errorf("Expected 'ERROR_MESSAGE' to be 'a test error', got '%s'", event.Attributes[TelemetryErrorMsg])
+	}
+}
+func TestCreateEvaluationEvent_1_4_7_WithUnknownReason(t *testing.T) {
+	flagKey := "test-flag"
+
+	mockProviderMetadata := openfeature.Metadata{
+		Name: "test-provider",
+	}
+
+	mockClientMetadata := openfeature.NewClientMetadata("test-client")
+
+	mockEvalCtx := openfeature.NewEvaluationContext(
+		"test-target-key", map[string]any{
+			"is": "a test",
+		})
+
+	mockHookContext := openfeature.NewHookContext(flagKey, openfeature.Boolean, true, mockClientMetadata, mockProviderMetadata, mockEvalCtx)
+
+	mockDetails := openfeature.InterfaceEvaluationDetails{
+		Value: true,
+		EvaluationDetails: openfeature.EvaluationDetails{
+			FlagKey: flagKey,
+			ResolutionDetail: openfeature.ResolutionDetail{
+				FlagMetadata: openfeature.FlagMetadata{},
+			},
+		},
+	}
+
+	event := CreateEvaluationEvent(mockHookContext, mockDetails)
+
+	if event.Attributes[TelemetryReason] != strings.ToLower(string(openfeature.UnknownReason)) {
+		t.Errorf("Expected evaluation reason to be '%s', got '%s'", strings.ToLower(string(openfeature.UnknownReason)), event.Attributes[TelemetryReason])
+	}
+}