Skip to content
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
2 changes: 1 addition & 1 deletion openfeature/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func evaluateFlag(flag *flag, defaultValue any, context map[string]any) evaluati
return evaluationResult{
Value: defaultValue,
Reason: of.ErrorReason,
Error: fmt.Errorf("variant type mismatch: %w", err),
Error: fmt.Errorf("%w: variant type mismatch: %v", errParseError, err),
}
}

Expand Down
69 changes: 69 additions & 0 deletions openfeature/evaluator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package openfeature

import (
"encoding/json"
"errors"
"fmt"
"maps"
"os"
Expand Down Expand Up @@ -635,6 +636,74 @@ func TestValidateVariantType(t *testing.T) {
}
}

func TestEvaluateFlag_VariantTypeMismatchReturnsParseError(t *testing.T) {
// When the configuration declares a flag type (e.g., INTEGER) but the variant
// value doesn't match (e.g., a string), we should return errParseError so that
// toResolutionError maps it to PARSE_ERROR.
tests := []struct {
name string
variationType valueType
variantValue any
}{
{
name: "INTEGER flag with string value",
variationType: valueTypeInteger,
variantValue: "not-an-integer",
},
{
name: "BOOLEAN flag with string value",
variationType: valueTypeBoolean,
variantValue: "true",
},
{
name: "NUMERIC flag with string value",
variationType: valueTypeNumeric,
variantValue: "42.5",
},
{
name: "STRING flag with integer value",
variationType: valueTypeString,
variantValue: 123,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
flag := &flag{
Key: "test-flag",
Enabled: true,
VariationType: tt.variationType,
Variations: map[string]*variant{
"v1": {Key: "v1", Value: tt.variantValue},
},
Allocations: []*allocation{
{
Key: "allocation1",
Splits: []*split{
{
VariationKey: "v1",
},
},
},
},
}

result := evaluateFlag(flag, nil, map[string]any{"targetingKey": "user-123"})

if result.Reason != of.ErrorReason {
t.Errorf("expected ErrorReason, got %s", result.Reason)
}
if result.Error == nil {
t.Fatal("expected error, got nil")
}
// Verify the error wraps errParseError so toResolutionError maps to PARSE_ERROR
if !errors.Is(result.Error, errParseError) {
t.Errorf("expected error to wrap errParseError, got: %v", result.Error)
}
})
}
}

func TestEvaluateFlag_JSONFixtures(t *testing.T) {
configData, err := os.ReadFile(filepath.Join("testdata", "ufc-config.json"))
if err != nil {
Expand Down
27 changes: 11 additions & 16 deletions openfeature/flageval_metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,23 @@ func (m *flagEvalMetrics) record(
flagKey string,
details of.InterfaceEvaluationDetails,
) {
// Use "unknown" as fallback for missing reason (matches OpenFeature SDK telemetry convention)
reason := string(details.Reason)
if reason == "" {
reason = "unknown"
} else {
reason = strings.ToLower(reason)
}

attrs := []attribute.KeyValue{
attrFlagKey.String(flagKey),
attrVariant.String(details.Variant),
attrReason.String(strings.ToLower(string(details.Reason))),
attrReason.String(reason),
}

// Use raw lowercase error code directly (no conversion function needed)
if details.ErrorCode != "" {
attrs = append(attrs, attrErrorType.String(errorCodeToTag(details.ErrorCode)))
attrs = append(attrs, attrErrorType.String(strings.ToLower(string(details.ErrorCode))))
}

if ak, ok := details.FlagMetadata[metadataAllocationKey].(string); ok && ak != "" {
Expand All @@ -118,20 +127,6 @@ func (m *flagEvalMetrics) record(
m.counter.Add(ctx, 1, otelmetric.WithAttributes(attrs...))
}

// errorCodeToTag maps OpenFeature ErrorCode values to low-cardinality metric tag values.
func errorCodeToTag(code of.ErrorCode) string {
switch code {
case of.FlagNotFoundCode:
return "flag_not_found"
case of.TypeMismatchCode:
return "type_mismatch"
case of.ParseErrorCode:
return "parse_error"
default:
return "general"
}
}

// shutdown gracefully shuts down the meter provider.
func (m *flagEvalMetrics) shutdown(ctx context.Context) error {
return ddmetric.Shutdown(ctx, m.meterProvider)
Expand Down
20 changes: 20 additions & 0 deletions openfeature/flageval_metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ func TestRecordAllErrorTypes(t *testing.T) {
{of.TypeMismatchCode, "type_mismatch"},
{of.ParseErrorCode, "parse_error"},
{of.GeneralCode, "general"},
{of.ProviderNotReadyCode, "provider_not_ready"},
}

for _, tc := range errorCases {
Expand All @@ -286,6 +287,25 @@ func TestRecordAllErrorTypes(t *testing.T) {
}
}

func TestRecordUnknownReasonFallback(t *testing.T) {
m, reader := setupTestMetrics(t)
ctx := context.Background()

// Record with empty reason - should fall back to "unknown"
m.record(ctx, "test-flag", makeDetails("variant-a", "", ""))

rm := collectMetrics(t, reader)
dps := findCounter(t, rm)

if len(dps) != 1 {
t.Fatalf("expected 1 data point, got %d", len(dps))
}

if got := getAttr(dps[0], attrReason); got != "unknown" {
t.Errorf("reason: got %q, want %q", got, "unknown")
}
}

// TestIntegrationEvaluate tests that the flag evaluation hook correctly records
// metrics when evaluations flow through the full OpenFeature client lifecycle.
func TestIntegrationEvaluate(t *testing.T) {
Expand Down
6 changes: 3 additions & 3 deletions openfeature/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,11 +358,11 @@ func (p *DatadogProvider) IntEvaluation(
case int8:
intValue = int64(v)
case float64:
// Accept float64 if it's a whole number
// Accept float64 if it's a whole number (e.g., -5.0 → -5)
if v == float64(int64(v)) {
intValue = int64(v)
} else {
conversionErr = fmt.Errorf("%w: flag %q returned float with decimal part: %v", errParseError, flagKey, v)
conversionErr = fmt.Errorf("%w: flag %q returned float with decimal part: %v", errTypeMismatch, flagKey, v)
}
default:
if result.Error == nil {
Expand Down Expand Up @@ -486,7 +486,7 @@ func toResolutionError(err error) openfeature.ResolutionError {
case errors.Is(err, errParseError):
return openfeature.NewParseErrorResolutionError(errMsg)
case errors.Is(err, errNoConfiguration):
return openfeature.NewGeneralResolutionError(errMsg)
return openfeature.NewProviderNotReadyResolutionError(errMsg)
default:
return openfeature.NewGeneralResolutionError(errMsg)
}
Expand Down
Loading