diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 824a59a6..1897d144 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - name: SonarQube Scan (Push) if: ${{ github.event_name == 'push' }} - uses: SonarSource/sonarqube-scan-action@v5.0.0 + uses: SonarSource/sonarqube-scan-action@v6.0.0 env: SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -46,7 +46,7 @@ jobs: - name: SonarQube Scan (Pull Request) if: ${{ github.event_name == 'pull_request' }} - uses: SonarSource/sonarqube-scan-action@v5.0.0 + uses: SonarSource/sonarqube-scan-action@v6.0.0 env: SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGES b/CHANGES index 0457ccf8..fa6d93cc 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,6 @@ +9.1.0 (Dec 17, 2025) +- Added impression properties. + 9.0.0 (Nov 21, 2025) - BREAKING CHANGE: - Changed new evaluator. diff --git a/dtos/impression.go b/dtos/impression.go index f58d5a9f..6133359a 100644 --- a/dtos/impression.go +++ b/dtos/impression.go @@ -11,6 +11,7 @@ type Impression struct { Time int64 `json:"m"` Pt int64 `json:"pt,omitempty"` Disabled bool `json:"-"` + Properties string `json:"properties,omitempty"` } // ImpressionQueueObject struct mapping impressions @@ -28,6 +29,7 @@ type ImpressionDTO struct { Label string `json:"r"` BucketingKey string `json:"b,omitempty"` Pt int64 `json:"pt,omitempty"` + Properties string `json:"properties,omitempty"` } // ImpressionsDTO struct mapping impressions to post @@ -47,3 +49,7 @@ type ImpressionsInTimeFrameDTO struct { type ImpressionsCountDTO struct { PerFeature []ImpressionsInTimeFrameDTO `json:"pf"` } + +type EvaluationOptions struct { + Properties map[string]interface{} +} diff --git a/engine/evaluator/mocks/mocks.go b/engine/evaluator/mocks/mocks.go index 2d5e7cff..55a8cf60 100644 --- a/engine/evaluator/mocks/mocks.go +++ b/engine/evaluator/mocks/mocks.go @@ -1,25 +1,29 @@ package mocks -import "github.com/splitio/go-split-commons/v9/engine/evaluator" +import ( + "github.com/splitio/go-split-commons/v9/engine/evaluator" + "github.com/stretchr/testify/mock" +) // MockEvaluator mock evaluator type MockEvaluator struct { - EvaluateFeatureCall func(key string, bucketingKey *string, feature string, attributes map[string]interface{}) *evaluator.Result - EvaluateFeaturesCall func(key string, bucketingKey *string, features []string, attributes map[string]interface{}) evaluator.Results - EvaluateFeatureByFlagSetsCall func(key string, bucketingKey *string, flagSets []string, attributes map[string]interface{}) evaluator.Results + mock.Mock } // EvaluateFeature mock func (m MockEvaluator) EvaluateFeature(key string, bucketingKey *string, feature string, attributes map[string]interface{}) *evaluator.Result { - return m.EvaluateFeatureCall(key, bucketingKey, feature, attributes) + args := m.Called(key, bucketingKey, feature, attributes) + return args.Get(0).(*evaluator.Result) } // EvaluateFeatures mock func (m MockEvaluator) EvaluateFeatures(key string, bucketingKey *string, features []string, attributes map[string]interface{}) evaluator.Results { - return m.EvaluateFeaturesCall(key, bucketingKey, features, attributes) + args := m.Called(key, bucketingKey, features, attributes) + return args.Get(0).(evaluator.Results) } // EvaluateFeaturesByFlagSets mock func (m MockEvaluator) EvaluateFeatureByFlagSets(key string, bucketingKey *string, flagSets []string, attributes map[string]interface{}) evaluator.Results { - return m.EvaluateFeatureByFlagSetsCall(key, bucketingKey, flagSets, attributes) + args := m.Called(key, bucketingKey, flagSets, attributes) + return args.Get(0).(evaluator.Results) } diff --git a/provisional/strategy/debug.go b/provisional/strategy/debug.go index 185b6aa8..af32de70 100644 --- a/provisional/strategy/debug.go +++ b/provisional/strategy/debug.go @@ -17,7 +17,9 @@ func NewDebugImpl(impressionObserver ImpressionObserver, listenerEnabled bool) P } func (s *DebugImpl) apply(impression *dtos.Impression) bool { - impression.Pt, _ = s.impressionObserver.TestAndSet(impression.FeatureName, impression) + if len(impression.Properties) == 0 { + impression.Pt, _ = s.impressionObserver.TestAndSet(impression.FeatureName, impression) + } return true } diff --git a/provisional/strategy/debug_test.go b/provisional/strategy/debug_test.go index 0933292f..c0a80db4 100644 --- a/provisional/strategy/debug_test.go +++ b/provisional/strategy/debug_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/splitio/go-split-commons/v9/dtos" + "github.com/stretchr/testify/assert" ) func TestDebugMode(t *testing.T) { @@ -22,15 +23,39 @@ func TestDebugMode(t *testing.T) { toLog, toListener := debug.Apply([]dtos.Impression{imp}) - if len(toLog) != 1 || len(toListener) != 1 { - t.Error("Should have 1 to log") - } + assert.Equal(t, 1, len(toLog), "Should have 1 to log") + assert.Equal(t, 1, len(toListener), "Should have 1 to listener") toLog, toListener = debug.Apply([]dtos.Impression{imp}) - if len(toLog) != 1 || len(toListener) != 1 { - t.Error("Should have 1 to log") + assert.Equal(t, 1, len(toLog), "Should have 1 to log") + assert.Equal(t, 1, len(toListener), "Should have 1 to listener") +} + +func TestDebugModeWithProperties(t *testing.T) { + observer, _ := NewImpressionObserver(5000) + debug := NewDebugImpl(observer, true) + + imp := dtos.Impression{ + BucketingKey: "someBuck", + ChangeNumber: 123, + KeyName: "someKey", + Label: "someLabel", + Time: 123456, + Treatment: "on", + FeatureName: "feature-test", + Properties: "{'hello':'world'}", } + + toLog, toListener := debug.Apply([]dtos.Impression{imp}) + + assert.Equal(t, 1, len(toLog), "Should have 1 to log") + assert.Equal(t, 1, len(toListener), "Should have 1 to listener") + + toLog, toListener = debug.Apply([]dtos.Impression{imp}) + + assert.Equal(t, 1, len(toLog), "Should have 1 to log") + assert.Equal(t, 1, len(toListener), "Should have 1 to listener") } func TestApplySingleDebug(t *testing.T) { @@ -48,13 +73,9 @@ func TestApplySingleDebug(t *testing.T) { toLog := debug.ApplySingle(&imp) - if !toLog { - t.Error("Should be true") - } + assert.True(t, toLog, "Should be true") toLog = debug.ApplySingle(&imp) - if !toLog { - t.Error("Should be true") - } + assert.True(t, toLog, "Should be true") } diff --git a/provisional/strategy/optimized.go b/provisional/strategy/optimized.go index 02215290..ad6bb683 100644 --- a/provisional/strategy/optimized.go +++ b/provisional/strategy/optimized.go @@ -28,6 +28,9 @@ func NewOptimizedImpl(impressionObserver ImpressionObserver, impressionCounter * } func (s *OptimizedImpl) apply(impression *dtos.Impression, now int64) bool { + if len(impression.Properties) != 0 { + return true + } impression.Pt, _ = s.impressionObserver.TestAndSet(impression.FeatureName, impression) if impression.Pt != 0 { s.impressionsCounter.Inc(impression.FeatureName, now, 1) diff --git a/provisional/strategy/optimized_test.go b/provisional/strategy/optimized_test.go index be249dad..aca1b446 100644 --- a/provisional/strategy/optimized_test.go +++ b/provisional/strategy/optimized_test.go @@ -6,6 +6,7 @@ import ( "github.com/splitio/go-split-commons/v9/dtos" "github.com/splitio/go-split-commons/v9/storage/inmemory" + "github.com/stretchr/testify/assert" ) func TestOptimizedMode(t *testing.T) { @@ -24,32 +25,55 @@ func TestOptimizedMode(t *testing.T) { } toLog, toListener := optimized.Apply([]dtos.Impression{imp}) + assert.Equal(t, 1, len(toLog), "Should have 1 to log") + assert.Equal(t, 1, len(toListener), "Should have 1 to listener") - if len(toLog) != 1 || len(toListener) != 1 { - t.Error("Should have 1 to log") + assert.Equal(t, 0, len(counter.impressionsCounts), "Should not have counts") + + toLog, toListener = optimized.Apply([]dtos.Impression{imp}) + + assert.Equal(t, 0, len(toLog), "Should have 0 to log") + assert.Equal(t, 1, len(toListener), "Should have 1 to listener") + + rawCounts := counter.PopAll() + assert.Equal(t, 1, len(rawCounts), "Should have counts") + for key, counts := range counter.PopAll() { + assert.Equal(t, "feature-test", key.FeatureName, "Feature should be feature-test") + assert.Equal(t, 1, counts, "It should be tracked only once") } +} - if len(counter.impressionsCounts) != 0 { - t.Error("Should not have counts") +func TestOptimizedModeWithProperties(t *testing.T) { + observer, _ := NewImpressionObserver(5000) + counter := NewImpressionsCounter() + runtimeTelemetry, _ := inmemory.NewTelemetryStorage() + optimized := NewOptimizedImpl(observer, counter, runtimeTelemetry, true) + imp := dtos.Impression{ + BucketingKey: "someBuck", + ChangeNumber: 123, + KeyName: "someKey", + Label: "someLabel", + Time: time.Now().UTC().UnixNano(), + Treatment: "on", + FeatureName: "feature-test", + Properties: "{'hello':'world'}", } + toLog, toListener := optimized.Apply([]dtos.Impression{imp}) + + assert.Equal(t, 1, len(toLog), "Should have 1 to log") + assert.Equal(t, 1, len(toListener), "Should have 1 to listener") + toLog, toListener = optimized.Apply([]dtos.Impression{imp}) - if len(toLog) != 0 || len(toListener) != 1 { - t.Error("Should not have to log") - } + assert.Equal(t, 1, len(toLog), "toLog should be 1") + assert.Equal(t, 1, len(toListener), "toListener should be 1") rawCounts := counter.PopAll() - if len(rawCounts) != 1 { - t.Error("Should have counts") - } + assert.Equal(t, 0, len(rawCounts), "Should doesn't have counts") for key, counts := range counter.PopAll() { - if key.FeatureName != "feature-test" { - t.Error("Feature should be feature-test") - } - if counts != 1 { - t.Error("It should be tracked only once") - } + assert.Equal(t, "feature-test", key.FeatureName, "Feature should be feature-test") + assert.Equal(t, 1, counts, "It should be tracked empty") } } @@ -70,13 +94,8 @@ func TestApplySingleOptimized(t *testing.T) { toLog := optimized.ApplySingle(&imp) - if !toLog { - t.Error("Should be true") - } - + assert.True(t, toLog, "Should be true") toLog = optimized.ApplySingle(&imp) - if toLog { - t.Error("Should be false") - } + assert.False(t, toLog, "Should be false") } diff --git a/service/api/http_recorders_test.go b/service/api/http_recorders_test.go index a21c6e25..0551a6e6 100644 --- a/service/api/http_recorders_test.go +++ b/service/api/http_recorders_test.go @@ -34,6 +34,27 @@ func TestImpressionRecord(t *testing.T) { } } +func TestImpressionRecordWithProperties(t *testing.T) { + impressionTXT := `{"k":"some_key","t":"off","m":1234567890,"c":55555555,"r":"some label","b":"some_bucket_key","properties":"value"}` + impressionRecord := &dtos.ImpressionDTO{ + KeyName: "some_key", + Treatment: "off", + Time: 1234567890, + ChangeNumber: 55555555, + Label: "some label", + BucketingKey: "some_bucket_key", + Properties: "value"} + + marshalImpression, err := json.Marshal(impressionRecord) + if err != nil { + t.Error(err) + } + + if string(marshalImpression) != impressionTXT { + t.Error("Error marshaling impression") + } +} + func TestImpressionRecordBulk(t *testing.T) { impressionTXT := `{"f":"some_feature","i":[{"k":"some_key","t":"off","m":1234567890,"c":55555555,"r":"some label","b":"some_bucket_key"}]}` impressionRecords := &dtos.ImpressionsDTO{ diff --git a/synchronizer/worker/impression/single.go b/synchronizer/worker/impression/single.go index 12021f32..a63d7c12 100644 --- a/synchronizer/worker/impression/single.go +++ b/synchronizer/worker/impression/single.go @@ -74,6 +74,7 @@ func (i *RecorderSingle) SynchronizeImpressions(bulkSize int64) error { Label: impression.Label, BucketingKey: impression.BucketingKey, Pt: impression.Pt, + Properties: impression.Properties, } v, ok := impressionsToPost[impression.FeatureName] if ok { diff --git a/synchronizer/worker/impression/single_test.go b/synchronizer/worker/impression/single_test.go index fdea8c68..c4eb73b5 100644 --- a/synchronizer/worker/impression/single_test.go +++ b/synchronizer/worker/impression/single_test.go @@ -19,6 +19,7 @@ import ( "github.com/splitio/go-split-commons/v9/storage/mocks" "github.com/splitio/go-split-commons/v9/telemetry" "github.com/splitio/go-toolkit/v5/logging" + "github.com/stretchr/testify/assert" ) func TestImpressionRecorderError(t *testing.T) { @@ -63,7 +64,7 @@ func TestImpressionRecorderWithoutImpressions(t *testing.T) { func TestSynhronizeEventErrorRecorder(t *testing.T) { impression := dtos.Impression{ - BucketingKey: "someBucketingKey1", ChangeNumber: 123456789, FeatureName: "someFeature1", + BucketingKey: "someBucketingKey1", ChangeNumber: 123456789, FeatureName: "someFeature1", Properties: "{'prop':'val'}", KeyName: "someKey1", Label: "someLabel", Time: 123456789, Treatment: "someTreatment1", } @@ -104,7 +105,7 @@ func TestImpressionRecorder(t *testing.T) { before := time.Now().UTC() impression1 := dtos.Impression{ BucketingKey: "someBucketingKey1", ChangeNumber: 123456789, FeatureName: "someFeature1", - KeyName: "someKey1", Label: "someLabel", Time: 123456789, Treatment: "someTreatment1", + KeyName: "someKey1", Label: "someLabel", Time: 123456789, Treatment: "someTreatment1", Properties: "{'prop':'val'}", } impression2 := dtos.Impression{ BucketingKey: "someBucketingKey2", ChangeNumber: 123456789, FeatureName: "someFeature2", @@ -215,6 +216,7 @@ func TestImpressionRecorderSync(t *testing.T) { if !ok || len(imp1.KeyImpressions) != 2 { t.Error("Incorrect impressions received") } + assert.Equal(t, "{'prop':'val'}", imp1.KeyImpressions[0].Properties) imp2, ok := result["someFeature2"] if !ok || len(imp2.KeyImpressions) != 1 { t.Error("Incorrect impressions received") @@ -227,7 +229,7 @@ func TestImpressionRecorderSync(t *testing.T) { impression1 := dtos.Impression{ BucketingKey: "someBucketingKey1", ChangeNumber: 123456789, FeatureName: "someFeature1", - KeyName: "someKey1", Label: "someLabel", Time: 123456789, Treatment: "someTreatment1", + KeyName: "someKey1", Label: "someLabel", Time: 123456789, Treatment: "someTreatment1", Properties: "{'prop':'val'}", } impression2 := dtos.Impression{ BucketingKey: "someBucketingKey2", ChangeNumber: 123456789, FeatureName: "someFeature2", diff --git a/telemetry/constants.go b/telemetry/constants.go index baaccfdd..3f76ff64 100644 --- a/telemetry/constants.go +++ b/telemetry/constants.go @@ -60,6 +60,14 @@ func IsMethodValid(method *string) bool { case "getTreatmentsByFlagSets", "get_treatments_by_flag_sets", "treatmentsByFlagSets", "TreatmentsByFlagSets": case "getTreatmentsWithConfigByFlagSet", "get_treatments_with_config_by_flag_set", "treatmentsWithConfigByFlagSet", "TreatmentsWithConfigByFlagSet": case "getTreatmentsWithConfigByFlagSets", "get_treatments_with_config_by_flag_sets", "treatmentsWithConfigByFlagSets", "TreatmentsWithConfigByFlagSets": + case "getTreatmentWithEvaluationOptions", "get_treatment_with_evaluation_options", "treatmentWithEvaluationOptions", "TreatmentWithEvaluationOptions": + case "getTreatmentsWithEvaluationOptions", "get_treatments_with_evaluation_options", "treatmentsWithEvaluationOptions", "TreatmentsWithEvaluationOptions": + case "getTreatmentWithConfigAndEvaluationOptions", "get_treatment_with_config_and_evaluation_options", "treatment_with_config_and_evaluation_options", "treatmentWithConfigAndEvaluationOptions", "TreatmentWithConfigAndEvaluationOptions": + case "getTreatmentsWithConfigAndEvaluationOption", "get_treatments_with_config_and_evaluation_options", "treatments_with_config_and_evaluation_options", "treatmentsWithConfigAndEvaluationOptions", "TreatmentsWithConfigAndEvaluationOptions": + case "getTreatmentsByFlagSetWithEvaluationOptions", "get_treatments_by_flag_set_with_evaluation_options", "treatments_by_flag_set_with_evaluation_options", "treatmentsByFlagSetWithEvaluationOptions", "TreatmentsByFlagSetWithEvaluationOptions": + case "getTreatmentsByFlagSetsWithEvaluationOptions", "get_treatments_by_flag_sets_with_evaluation_options", "treatments_by_flag_sets_with_evaluation_options", "treatmentsByFlagSetsWithEvaluationOptions", "TreatmentsByFlagSetsWithEvaluationOptions": + case "getTreatmentsWithConfigByFlagSetAndEvaluationOptions", "get_treatments_with_config_by_flag_set_and_evaluation_options", "treatments_with_config_by_flag_set_and_evaluation_options", "treatmentsWithConfigByFlagSetAndEvaluationOptions", "TreatmentsWithConfigByFlagSetAndEvaluationOptions": + case "getTreatmentsWithConfigByFlagSetsAndEvaluationOptions", "get_treatments_with_config_by_flag_sets_and_evaluation_options", "treatments_with_config_by_flag_sets_and_evaluation_options", "treatmentsWithConfigByFlagSetsAndEvaluationOptions", "TreatmentsWithConfigByFlagSetsAndEvaluationOptions": case "track", "Track": default: return false