Skip to content

Commit 2d17828

Browse files
fix: Use exporter metadata in remote evaluations (#2983)
* fix: Use exporter metadata in remote evaluations Signed-off-by: Thomas Poignant <[email protected]> * fix(python): Add exporter metadata to the evaluation context Signed-off-by: Thomas Poignant <[email protected]> --------- Signed-off-by: Thomas Poignant <[email protected]>
1 parent 0772f01 commit 2d17828

File tree

12 files changed

+186
-44
lines changed

12 files changed

+186
-44
lines changed

Diff for: .pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ repos:
1111
entry: golangci-lint run --enable-only=gci --fix
1212

1313
- repo: https://github.com/psf/black-pre-commit-mirror
14-
rev: 23.11.0
14+
rev: 24.10.0
1515
hooks:
1616
- id: black
1717
language_version: python3.12

Diff for: exporter/data_exporter_test.go

+61-5
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import (
1010

1111
"github.com/stretchr/testify/assert"
1212
"github.com/thejerf/slogassert"
13+
ffclient "github.com/thomaspoignant/go-feature-flag"
1314
"github.com/thomaspoignant/go-feature-flag/exporter"
1415
"github.com/thomaspoignant/go-feature-flag/ffcontext"
16+
"github.com/thomaspoignant/go-feature-flag/retriever/fileretriever"
1517
"github.com/thomaspoignant/go-feature-flag/testutils/mock"
1618
"github.com/thomaspoignant/go-feature-flag/utils/fflog"
1719
)
@@ -27,7 +29,7 @@ func TestDataExporterScheduler_flushWithTime(t *testing.T) {
2729
inputEvents := []exporter.FeatureEvent{
2830
exporter.NewFeatureEvent(
2931
ffcontext.NewEvaluationContextBuilder("ABCD").AddCustom("anonymous", true).Build(), "random-key",
30-
"YO", "defaultVar", false, "", "SERVER"),
32+
"YO", "defaultVar", false, "", "SERVER", nil),
3133
}
3234

3335
for _, event := range inputEvents {
@@ -50,7 +52,7 @@ func TestDataExporterScheduler_flushWithNumberOfEvents(t *testing.T) {
5052
for i := 0; i <= 100; i++ {
5153
inputEvents = append(inputEvents, exporter.NewFeatureEvent(
5254
ffcontext.NewEvaluationContextBuilder("ABCD").AddCustom("anonymous", true).Build(),
53-
"random-key", "YO", "defaultVar", false, "", "SERVER"))
55+
"random-key", "YO", "defaultVar", false, "", "SERVER", nil))
5456
}
5557
for _, event := range inputEvents {
5658
dc.AddEvent(event)
@@ -70,7 +72,7 @@ func TestDataExporterScheduler_defaultFlush(t *testing.T) {
7072
for i := 0; i <= 100000; i++ {
7173
inputEvents = append(inputEvents, exporter.NewFeatureEvent(
7274
ffcontext.NewEvaluationContextBuilder("ABCD").AddCustom("anonymous", true).Build(),
73-
"random-key", "YO", "defaultVar", false, "", "SERVER"))
75+
"random-key", "YO", "defaultVar", false, "", "SERVER", nil))
7476
}
7577
for _, event := range inputEvents {
7678
dc.AddEvent(event)
@@ -97,7 +99,7 @@ func TestDataExporterScheduler_exporterReturnError(t *testing.T) {
9799
for i := 0; i <= 200; i++ {
98100
inputEvents = append(inputEvents, exporter.NewFeatureEvent(
99101
ffcontext.NewEvaluationContextBuilder("ABCD").AddCustom("anonymous", true).Build(),
100-
"random-key", "YO", "defaultVar", false, "", "SERVER"))
102+
"random-key", "YO", "defaultVar", false, "", "SERVER", nil))
101103
}
102104
for _, event := range inputEvents {
103105
dc.AddEvent(event)
@@ -117,7 +119,7 @@ func TestDataExporterScheduler_nonBulkExporter(t *testing.T) {
117119
for i := 0; i < 100; i++ {
118120
inputEvents = append(inputEvents, exporter.NewFeatureEvent(
119121
ffcontext.NewEvaluationContextBuilder("ABCD").AddCustom("anonymous", true).Build(),
120-
"random-key", "YO", "defaultVar", false, "", "SERVER"))
122+
"random-key", "YO", "defaultVar", false, "", "SERVER", nil))
121123
}
122124
for _, event := range inputEvents {
123125
dc.AddEvent(event)
@@ -127,3 +129,57 @@ func TestDataExporterScheduler_nonBulkExporter(t *testing.T) {
127129

128130
assert.Equal(t, inputEvents[:100], mockExporter.GetExportedEvents())
129131
}
132+
133+
func TestAddExporterMetadataFromContextToExporter(t *testing.T) {
134+
tests := []struct {
135+
name string
136+
ctx ffcontext.EvaluationContext
137+
want map[string]interface{}
138+
}{
139+
{
140+
name: "extract exporter metadata from context",
141+
ctx: ffcontext.NewEvaluationContextBuilder("targeting-key").AddCustom("gofeatureflag", map[string]interface{}{
142+
"exporterMetadata": map[string]interface{}{
143+
"key1": "value1",
144+
"key2": 123,
145+
"key3": true,
146+
"key4": 123.45,
147+
},
148+
}).Build(),
149+
want: map[string]interface{}{
150+
"key1": "value1",
151+
"key2": 123,
152+
"key3": true,
153+
"key4": 123.45,
154+
},
155+
},
156+
{
157+
name: "no exporter metadata in the context",
158+
ctx: ffcontext.NewEvaluationContextBuilder("targeting-key").Build(),
159+
want: nil,
160+
},
161+
}
162+
163+
for _, tt := range tests {
164+
t.Run(tt.name, func(t *testing.T) {
165+
mockExporter := &mock.Exporter{}
166+
config := ffclient.Config{
167+
Retriever: &fileretriever.Retriever{Path: "../testdata/flag-config.yaml"},
168+
DataExporter: ffclient.DataExporter{
169+
Exporter: mockExporter,
170+
FlushInterval: 100 * time.Millisecond,
171+
},
172+
}
173+
goff, err := ffclient.New(config)
174+
assert.NoError(t, err)
175+
176+
_, err = goff.BoolVariation("test-flag", tt.ctx, false)
177+
assert.NoError(t, err)
178+
179+
time.Sleep(120 * time.Millisecond)
180+
assert.Equal(t, 1, len(mockExporter.GetExportedEvents()))
181+
got := mockExporter.GetExportedEvents()[0].Metadata
182+
assert.Equal(t, tt.want, got)
183+
})
184+
}
185+
}

Diff for: exporter/feature_event.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ func NewFeatureEvent(
1717
failed bool,
1818
version string,
1919
source string,
20+
metadata FeatureEventMetadata,
2021
) FeatureEvent {
2122
contextKind := "user"
2223
if ctx.IsAnonymous() {
2324
contextKind = "anonymousUser"
2425
}
25-
2626
return FeatureEvent{
2727
Kind: "feature",
2828
ContextKind: contextKind,
@@ -34,6 +34,7 @@ func NewFeatureEvent(
3434
Default: failed,
3535
Version: version,
3636
Source: source,
37+
Metadata: metadata,
3738
}
3839
}
3940

Diff for: exporter/feature_event_test.go

+9-8
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ import (
1212

1313
func TestNewFeatureEvent(t *testing.T) {
1414
type args struct {
15-
user ffcontext.Context
16-
flagKey string
17-
value interface{}
18-
variation string
19-
failed bool
20-
version string
21-
source string
15+
user ffcontext.Context
16+
flagKey string
17+
value interface{}
18+
variation string
19+
failed bool
20+
version string
21+
source string
22+
exporterMetadata exporter.FeatureEventMetadata
2223
}
2324
tests := []struct {
2425
name string
@@ -44,7 +45,7 @@ func TestNewFeatureEvent(t *testing.T) {
4445
}
4546
for _, tt := range tests {
4647
t.Run(tt.name, func(t *testing.T) {
47-
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)
48+
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)
4849
})
4950
}
5051
}

Diff for: ffcontext/context.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,13 @@ func (u EvaluationContext) ExtractGOFFProtectedFields() GoffContextSpecifics {
8181
case map[string]string:
8282
goff.addCurrentDateTime(v["currentDateTime"])
8383
goff.addListFlags(v["flagList"])
84+
goff.addExporterMetadata(v["exporterMetadata"])
8485
case map[string]interface{}:
8586
goff.addCurrentDateTime(v["currentDateTime"])
8687
goff.addListFlags(v["flagList"])
88+
goff.addExporterMetadata(v["exporterMetadata"])
8789
case GoffContextSpecifics:
8890
return v
8991
}
90-
9192
return goff
9293
}

Diff for: ffcontext/context_test.go

+19
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,25 @@ func Test_ExtractGOFFProtectedFields(t *testing.T) {
142142
FlagList: []string{"flag1", "flag2"},
143143
},
144144
},
145+
{
146+
name: "context goff specifics with exporter metadata",
147+
ctx: ffcontext.NewEvaluationContextBuilder("my-key").AddCustom("gofeatureflag", map[string]interface{}{
148+
"exporterMetadata": map[string]interface{}{
149+
"toto": 123,
150+
"titi": 123.45,
151+
"tutu": true,
152+
"tata": "bonjour",
153+
},
154+
}).Build(),
155+
want: ffcontext.GoffContextSpecifics{
156+
ExporterMetadata: map[string]interface{}{
157+
"toto": 123,
158+
"titi": 123.45,
159+
"tutu": true,
160+
"tata": "bonjour",
161+
},
162+
},
163+
},
145164
}
146165

147166
for _, tt := range tests {

Diff for: ffcontext/goff_context_specifics.go

+8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ type GoffContextSpecifics struct {
77
CurrentDateTime *time.Time `json:"currentDateTime"`
88
// FlagList is the list of flags to evaluate in a bulk evaluation.
99
FlagList []string `json:"flagList"`
10+
// ExporterMetadata is the metadata to be used by the exporter.
11+
ExporterMetadata map[string]interface{} `json:"exporterMetadata"`
1012
}
1113

1214
// addCurrentDateTime adds the current date time to the context.
@@ -40,3 +42,9 @@ func (g *GoffContextSpecifics) addListFlags(flagList any) {
4042
}
4143
}
4244
}
45+
46+
func (g *GoffContextSpecifics) addExporterMetadata(exporterMetadata any) {
47+
if value, ok := exporterMetadata.(map[string]interface{}); ok {
48+
g.ExporterMetadata = value
49+
}
50+
}

Diff for: openfeature/providers/python-provider/gofeatureflag_python_provider/provider.py

+25-18
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import json
2-
from http import HTTPStatus
3-
from threading import Thread
4-
from typing import List, Optional, Type, Union
5-
from urllib.parse import urljoin
6-
72
import pylru
83
import urllib3
94
import websocket
5+
from gofeatureflag_python_provider.data_collector_hook import DataCollectorHook
6+
from gofeatureflag_python_provider.metadata import GoFeatureFlagMetadata
7+
from gofeatureflag_python_provider.options import BaseModel, GoFeatureFlagOptions
8+
from gofeatureflag_python_provider.request_flag_evaluation import (
9+
RequestFlagEvaluation,
10+
convert_evaluation_context,
11+
)
12+
from gofeatureflag_python_provider.response_flag_evaluation import (
13+
JsonType,
14+
ResponseFlagEvaluation,
15+
)
16+
from http import HTTPStatus
1017
from openfeature.evaluation_context import EvaluationContext
1118
from openfeature.exception import (
1219
ErrorCode,
@@ -17,21 +24,11 @@
1724
)
1825
from openfeature.flag_evaluation import FlagResolutionDetails, Reason
1926
from openfeature.hook import Hook
20-
from openfeature.provider.metadata import Metadata
2127
from openfeature.provider import AbstractProvider
28+
from openfeature.provider.metadata import Metadata
2229
from pydantic import PrivateAttr, ValidationError
23-
24-
from gofeatureflag_python_provider.data_collector_hook import DataCollectorHook
25-
from gofeatureflag_python_provider.metadata import GoFeatureFlagMetadata
26-
from gofeatureflag_python_provider.options import BaseModel, GoFeatureFlagOptions
27-
from gofeatureflag_python_provider.request_flag_evaluation import (
28-
RequestFlagEvaluation,
29-
convert_evaluation_context,
30-
)
31-
from gofeatureflag_python_provider.response_flag_evaluation import (
32-
JsonType,
33-
ResponseFlagEvaluation,
34-
)
30+
from threading import Thread
31+
from typing import List, Optional, Type, Union
3532

3633
AbstractProviderMetaclass = type(AbstractProvider)
3734
BaseModelMetaclass = type(BaseModel)
@@ -196,6 +193,16 @@ def generic_go_feature_flag_resolver(
196193
"/v1/feature/{}/eval".format(flag_key),
197194
)
198195

196+
# add exporter metadata to the context if it exists
197+
if self.options.exporter_metadata:
198+
goff_request.gofeatureflag["exporterMetadata"] = (
199+
self.options.exporter_metadata
200+
)
201+
goff_request.gofeatureflag["exporterMetadata"]["openfeature"] = True
202+
goff_request.gofeatureflag["exporterMetadata"][
203+
"provider"
204+
] = "python"
205+
199206
response = self._http_client.request(
200207
method="POST",
201208
url=url,

Diff for: openfeature/providers/python-provider/gofeatureflag_python_provider/request_flag_evaluation.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import hashlib
22
import json
3-
from typing import Optional, Any
4-
3+
from gofeatureflag_python_provider.options import BaseModel
54
from openfeature.evaluation_context import EvaluationContext
65
from openfeature.exception import (
76
TargetingKeyMissingError,
87
InvalidContextError,
98
)
109
from pydantic import SkipValidation
11-
12-
from gofeatureflag_python_provider.options import BaseModel
10+
from typing import Optional, Any, Dict
1311

1412

1513
class GoFeatureFlagEvaluationContext(BaseModel):
@@ -56,3 +54,4 @@ def convert_evaluation_context(
5654
class RequestFlagEvaluation(BaseModel):
5755
user: GoFeatureFlagEvaluationContext
5856
defaultValue: SkipValidation[Any] = None
57+
gofeatureflag: Optional[Dict] = {}

Diff for: openfeature/providers/python-provider/tests/test_gofeatureflag_python_provider.py

+44
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,50 @@ def test_url_parsing(mock_request):
689689
assert got == want
690690

691691

692+
@patch("urllib3.poolmanager.PoolManager.request")
693+
def test_should_call_evaluation_api_with_exporter_metadata(
694+
mock_request: Mock,
695+
):
696+
flag_key = "bool_targeting_match"
697+
default_value = False
698+
mock_request.side_effect = [
699+
Mock(
700+
status="200", data=_read_mock_file(flag_key)
701+
), # first call to get the flag
702+
Mock(status="200", data={}), # second call to send the data
703+
Mock(status="200", data={}),
704+
]
705+
goff_provider = GoFeatureFlagProvider(
706+
options=GoFeatureFlagOptions(
707+
endpoint="https://gofeatureflag.org/",
708+
data_flush_interval=100,
709+
disable_cache_invalidation=True,
710+
exporter_metadata={"version": "1.0.0", "name": "myapp", "id": 123},
711+
)
712+
)
713+
api.set_provider(goff_provider)
714+
client = api.get_client(domain="test-client")
715+
716+
client.get_boolean_details(
717+
flag_key=flag_key,
718+
default_value=default_value,
719+
evaluation_context=_default_evaluation_ctx,
720+
)
721+
722+
api.shutdown()
723+
got = json.loads(mock_request.call_args.kwargs["body"])["gofeatureflag"]
724+
want = {
725+
"exporterMetadata": {
726+
"version": "1.0.0",
727+
"name": "myapp",
728+
"id": 123,
729+
"provider": "python",
730+
"openfeature": True,
731+
},
732+
}
733+
assert got == want
734+
735+
692736
def _read_mock_file(flag_key: str) -> str:
693737
# This hacky if is here to make test run inside pycharm and from the root of the project
694738
if os.getcwd().endswith("/tests"):

Diff for: variation.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ func notifyVariation[T model.JSONType](
270270
) {
271271
if result.TrackEvents {
272272
event := exporter.NewFeatureEvent(ctx, flagKey, result.Value, result.VariationType, result.Failed, result.Version,
273-
"SERVER")
273+
"SERVER", ctx.ExtractGOFFProtectedFields().ExporterMetadata)
274274
g.CollectEventData(event)
275275
}
276276
}

0 commit comments

Comments
 (0)