Skip to content

fix: Use exporter metadata in remote evaluations #2983

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 3 commits into from
Jan 27, 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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ repos:
entry: golangci-lint run --enable-only=gci --fix

- repo: https://github.com/psf/black-pre-commit-mirror
rev: 23.11.0
rev: 24.10.0
hooks:
- id: black
language_version: python3.12
66 changes: 61 additions & 5 deletions exporter/data_exporter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import (

"github.com/stretchr/testify/assert"
"github.com/thejerf/slogassert"
ffclient "github.com/thomaspoignant/go-feature-flag"
"github.com/thomaspoignant/go-feature-flag/exporter"
"github.com/thomaspoignant/go-feature-flag/ffcontext"
"github.com/thomaspoignant/go-feature-flag/retriever/fileretriever"
"github.com/thomaspoignant/go-feature-flag/testutils/mock"
"github.com/thomaspoignant/go-feature-flag/utils/fflog"
)
Expand All @@ -27,7 +29,7 @@ func TestDataExporterScheduler_flushWithTime(t *testing.T) {
inputEvents := []exporter.FeatureEvent{
exporter.NewFeatureEvent(
ffcontext.NewEvaluationContextBuilder("ABCD").AddCustom("anonymous", true).Build(), "random-key",
"YO", "defaultVar", false, "", "SERVER"),
"YO", "defaultVar", false, "", "SERVER", nil),
}

for _, event := range inputEvents {
Expand All @@ -50,7 +52,7 @@ func TestDataExporterScheduler_flushWithNumberOfEvents(t *testing.T) {
for i := 0; i <= 100; i++ {
inputEvents = append(inputEvents, exporter.NewFeatureEvent(
ffcontext.NewEvaluationContextBuilder("ABCD").AddCustom("anonymous", true).Build(),
"random-key", "YO", "defaultVar", false, "", "SERVER"))
"random-key", "YO", "defaultVar", false, "", "SERVER", nil))
}
for _, event := range inputEvents {
dc.AddEvent(event)
Expand All @@ -70,7 +72,7 @@ func TestDataExporterScheduler_defaultFlush(t *testing.T) {
for i := 0; i <= 100000; i++ {
inputEvents = append(inputEvents, exporter.NewFeatureEvent(
ffcontext.NewEvaluationContextBuilder("ABCD").AddCustom("anonymous", true).Build(),
"random-key", "YO", "defaultVar", false, "", "SERVER"))
"random-key", "YO", "defaultVar", false, "", "SERVER", nil))
}
for _, event := range inputEvents {
dc.AddEvent(event)
Expand All @@ -97,7 +99,7 @@ func TestDataExporterScheduler_exporterReturnError(t *testing.T) {
for i := 0; i <= 200; i++ {
inputEvents = append(inputEvents, exporter.NewFeatureEvent(
ffcontext.NewEvaluationContextBuilder("ABCD").AddCustom("anonymous", true).Build(),
"random-key", "YO", "defaultVar", false, "", "SERVER"))
"random-key", "YO", "defaultVar", false, "", "SERVER", nil))
}
for _, event := range inputEvents {
dc.AddEvent(event)
Expand All @@ -117,7 +119,7 @@ func TestDataExporterScheduler_nonBulkExporter(t *testing.T) {
for i := 0; i < 100; i++ {
inputEvents = append(inputEvents, exporter.NewFeatureEvent(
ffcontext.NewEvaluationContextBuilder("ABCD").AddCustom("anonymous", true).Build(),
"random-key", "YO", "defaultVar", false, "", "SERVER"))
"random-key", "YO", "defaultVar", false, "", "SERVER", nil))
}
for _, event := range inputEvents {
dc.AddEvent(event)
Expand All @@ -127,3 +129,57 @@ func TestDataExporterScheduler_nonBulkExporter(t *testing.T) {

assert.Equal(t, inputEvents[:100], mockExporter.GetExportedEvents())
}

func TestAddExporterMetadataFromContextToExporter(t *testing.T) {
tests := []struct {
name string
ctx ffcontext.EvaluationContext
want map[string]interface{}
}{
{
name: "extract exporter metadata from context",
ctx: ffcontext.NewEvaluationContextBuilder("targeting-key").AddCustom("gofeatureflag", map[string]interface{}{
"exporterMetadata": map[string]interface{}{
"key1": "value1",
"key2": 123,
"key3": true,
"key4": 123.45,
},
}).Build(),
want: map[string]interface{}{
"key1": "value1",
"key2": 123,
"key3": true,
"key4": 123.45,
},
},
{
name: "no exporter metadata in the context",
ctx: ffcontext.NewEvaluationContextBuilder("targeting-key").Build(),
want: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockExporter := &mock.Exporter{}
config := ffclient.Config{
Retriever: &fileretriever.Retriever{Path: "../testdata/flag-config.yaml"},
DataExporter: ffclient.DataExporter{
Exporter: mockExporter,
FlushInterval: 100 * time.Millisecond,
},
}
goff, err := ffclient.New(config)
assert.NoError(t, err)

_, err = goff.BoolVariation("test-flag", tt.ctx, false)
assert.NoError(t, err)

time.Sleep(120 * time.Millisecond)
assert.Equal(t, 1, len(mockExporter.GetExportedEvents()))
got := mockExporter.GetExportedEvents()[0].Metadata
assert.Equal(t, tt.want, got)
})
}
}
3 changes: 2 additions & 1 deletion exporter/feature_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ func NewFeatureEvent(
failed bool,
version string,
source string,
metadata FeatureEventMetadata,
) FeatureEvent {
contextKind := "user"
if ctx.IsAnonymous() {
contextKind = "anonymousUser"
}

return FeatureEvent{
Kind: "feature",
ContextKind: contextKind,
Expand All @@ -34,6 +34,7 @@ func NewFeatureEvent(
Default: failed,
Version: version,
Source: source,
Metadata: metadata,
}
}

Expand Down
17 changes: 9 additions & 8 deletions exporter/feature_event_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ import (

func TestNewFeatureEvent(t *testing.T) {
type args struct {
user ffcontext.Context
flagKey string
value interface{}
variation string
failed bool
version string
source string
user ffcontext.Context
flagKey string
value interface{}
variation string
failed bool
version string
source string
exporterMetadata exporter.FeatureEventMetadata
}
tests := []struct {
name string
Expand All @@ -44,7 +45,7 @@ func TestNewFeatureEvent(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, exporter.NewFeatureEvent(tt.args.user, tt.args.flagKey, tt.args.value, tt.args.variation, tt.args.failed, tt.args.version, tt.args.source), "NewFeatureEvent(%v, %v, %v, %v, %v, %v, %V)", tt.args.user, tt.args.flagKey, tt.args.value, tt.args.variation, tt.args.failed, tt.args.version, tt.args.source)
assert.Equalf(t, tt.want, exporter.NewFeatureEvent(tt.args.user, tt.args.flagKey, tt.args.value, tt.args.variation, tt.args.failed, tt.args.version, tt.args.source, tt.args.exporterMetadata), "NewFeatureEvent(%v, %v, %v, %v, %v, %v, %V)", tt.args.user, tt.args.flagKey, tt.args.value, tt.args.variation, tt.args.failed, tt.args.version, tt.args.source)
})
}
}
Expand Down
3 changes: 2 additions & 1 deletion ffcontext/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,13 @@ func (u EvaluationContext) ExtractGOFFProtectedFields() GoffContextSpecifics {
case map[string]string:
goff.addCurrentDateTime(v["currentDateTime"])
goff.addListFlags(v["flagList"])
goff.addExporterMetadata(v["exporterMetadata"])
case map[string]interface{}:
goff.addCurrentDateTime(v["currentDateTime"])
goff.addListFlags(v["flagList"])
goff.addExporterMetadata(v["exporterMetadata"])
case GoffContextSpecifics:
return v
}

return goff
}
19 changes: 19 additions & 0 deletions ffcontext/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,25 @@ func Test_ExtractGOFFProtectedFields(t *testing.T) {
FlagList: []string{"flag1", "flag2"},
},
},
{
name: "context goff specifics with exporter metadata",
ctx: ffcontext.NewEvaluationContextBuilder("my-key").AddCustom("gofeatureflag", map[string]interface{}{
"exporterMetadata": map[string]interface{}{
"toto": 123,
"titi": 123.45,
"tutu": true,
"tata": "bonjour",
},
}).Build(),
want: ffcontext.GoffContextSpecifics{
ExporterMetadata: map[string]interface{}{
"toto": 123,
"titi": 123.45,
"tutu": true,
"tata": "bonjour",
},
},
},
}

for _, tt := range tests {
Expand Down
8 changes: 8 additions & 0 deletions ffcontext/goff_context_specifics.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ type GoffContextSpecifics struct {
CurrentDateTime *time.Time `json:"currentDateTime"`
// FlagList is the list of flags to evaluate in a bulk evaluation.
FlagList []string `json:"flagList"`
// ExporterMetadata is the metadata to be used by the exporter.
ExporterMetadata map[string]interface{} `json:"exporterMetadata"`
}

// addCurrentDateTime adds the current date time to the context.
Expand Down Expand Up @@ -40,3 +42,9 @@ func (g *GoffContextSpecifics) addListFlags(flagList any) {
}
}
}

func (g *GoffContextSpecifics) addExporterMetadata(exporterMetadata any) {
if value, ok := exporterMetadata.(map[string]interface{}); ok {
g.ExporterMetadata = value
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import json
from http import HTTPStatus
from threading import Thread
from typing import List, Optional, Type, Union
from urllib.parse import urljoin

import pylru
import urllib3
import websocket
from gofeatureflag_python_provider.data_collector_hook import DataCollectorHook
from gofeatureflag_python_provider.metadata import GoFeatureFlagMetadata
from gofeatureflag_python_provider.options import BaseModel, GoFeatureFlagOptions
from gofeatureflag_python_provider.request_flag_evaluation import (
RequestFlagEvaluation,
convert_evaluation_context,
)
from gofeatureflag_python_provider.response_flag_evaluation import (
JsonType,
ResponseFlagEvaluation,
)
from http import HTTPStatus
from openfeature.evaluation_context import EvaluationContext
from openfeature.exception import (
ErrorCode,
Expand All @@ -17,21 +24,11 @@
)
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
from openfeature.hook import Hook
from openfeature.provider.metadata import Metadata
from openfeature.provider import AbstractProvider
from openfeature.provider.metadata import Metadata
from pydantic import PrivateAttr, ValidationError

from gofeatureflag_python_provider.data_collector_hook import DataCollectorHook
from gofeatureflag_python_provider.metadata import GoFeatureFlagMetadata
from gofeatureflag_python_provider.options import BaseModel, GoFeatureFlagOptions
from gofeatureflag_python_provider.request_flag_evaluation import (
RequestFlagEvaluation,
convert_evaluation_context,
)
from gofeatureflag_python_provider.response_flag_evaluation import (
JsonType,
ResponseFlagEvaluation,
)
from threading import Thread
from typing import List, Optional, Type, Union

AbstractProviderMetaclass = type(AbstractProvider)
BaseModelMetaclass = type(BaseModel)
Expand Down Expand Up @@ -196,6 +193,16 @@ def generic_go_feature_flag_resolver(
"/v1/feature/{}/eval".format(flag_key),
)

# add exporter metadata to the context if it exists
if self.options.exporter_metadata:
goff_request.gofeatureflag["exporterMetadata"] = (
self.options.exporter_metadata
)
goff_request.gofeatureflag["exporterMetadata"]["openfeature"] = True
goff_request.gofeatureflag["exporterMetadata"][
"provider"
] = "python"

response = self._http_client.request(
method="POST",
url=url,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import hashlib
import json
from typing import Optional, Any

from gofeatureflag_python_provider.options import BaseModel
from openfeature.evaluation_context import EvaluationContext
from openfeature.exception import (
TargetingKeyMissingError,
InvalidContextError,
)
from pydantic import SkipValidation

from gofeatureflag_python_provider.options import BaseModel
from typing import Optional, Any, Dict


class GoFeatureFlagEvaluationContext(BaseModel):
Expand Down Expand Up @@ -56,3 +54,4 @@ def convert_evaluation_context(
class RequestFlagEvaluation(BaseModel):
user: GoFeatureFlagEvaluationContext
defaultValue: SkipValidation[Any] = None
gofeatureflag: Optional[Dict] = {}
6 changes: 3 additions & 3 deletions openfeature/providers/python-provider/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,50 @@ def test_url_parsing(mock_request):
assert got == want


@patch("urllib3.poolmanager.PoolManager.request")
def test_should_call_evaluation_api_with_exporter_metadata(
mock_request: Mock,
):
flag_key = "bool_targeting_match"
default_value = False
mock_request.side_effect = [
Mock(
status="200", data=_read_mock_file(flag_key)
), # first call to get the flag
Mock(status="200", data={}), # second call to send the data
Mock(status="200", data={}),
]
goff_provider = GoFeatureFlagProvider(
options=GoFeatureFlagOptions(
endpoint="https://gofeatureflag.org/",
data_flush_interval=100,
disable_cache_invalidation=True,
exporter_metadata={"version": "1.0.0", "name": "myapp", "id": 123},
)
)
api.set_provider(goff_provider)
client = api.get_client(domain="test-client")

client.get_boolean_details(
flag_key=flag_key,
default_value=default_value,
evaluation_context=_default_evaluation_ctx,
)

api.shutdown()
got = json.loads(mock_request.call_args.kwargs["body"])["gofeatureflag"]
want = {
"exporterMetadata": {
"version": "1.0.0",
"name": "myapp",
"id": 123,
"provider": "python",
"openfeature": True,
},
}
assert got == want


def _read_mock_file(flag_key: str) -> str:
# This hacky if is here to make test run inside pycharm and from the root of the project
if os.getcwd().endswith("/tests"):
Expand Down
Loading
Loading