From b1245009df844f5ead0432a92f7051c346a96ac4 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 25 Jun 2026 20:54:26 +0200 Subject: [PATCH 01/13] v3 --- Makefile | 6 +- README.md | 99 ++++++---- api_compatibility_test.go | 40 ++-- child.go | 8 +- cmd/commands/assess_distribution.go | 2 +- cmd/commands/benchmark.go | 2 +- cmd/commands/commands.go | 7 +- cmd/commands/commands_test.go | 18 +- cmd/commands/test.go | 231 ++++------------------ cmd/commands/test_types.go | 3 +- conditions_test.go | 4 +- datafile_reader_test.go | 10 +- diagnostics.go | 39 ++++ evaluate.go | 38 ++-- events.go | 7 +- events_parity_test.go | 5 +- hooks.go | 98 ---------- instance.go | 293 +++++++++++++++++++++++----- instance_mutually_exclusive_test.go | 2 +- instance_test.go | 147 +++++++++++++- modules.go | 167 ++++++++++++++++ sdk_types.go | 48 +---- 22 files changed, 789 insertions(+), 485 deletions(-) create mode 100644 diagnostics.go delete mode 100644 hooks.go create mode 100644 modules.go diff --git a/Makefile b/Makefile index 530e47c..f541f71 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test clean setup-monorepo update-monorepo +.PHONY: build test test-example-1 clean setup-monorepo update-monorepo build: mkdir -p build @@ -7,6 +7,10 @@ build: test: go test ./... -v +test-example-1: + go test ./... + go run cmd/main.go test --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 --onlyFailures + clean: rm -rf build diff --git a/README.md b/README.md index 365cbd8..b9f5426 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Featurevisor Go SDK -This is a port of Featurevisor [Javascript SDK](https://featurevisor.com/docs/sdks/javascript/) v2.x to Go, providing a way to evaluate feature flags, variations, and variables in your Go applications. +This is a port of Featurevisor [Javascript SDK](https://featurevisor.com/docs/sdks/javascript/) v3.x to Go, providing a way to evaluate feature flags, variations, and variables in your Go applications. -This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 projects and above. +This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 projects and v2 datafiles. See example application [here](https://github.com/featurevisor/featurevisor-example-go). @@ -31,14 +31,15 @@ See example application [here](https://github.com/featurevisor/featurevisor-exam - [Levels](#levels) - [Customizing levels](#customizing-levels) - [Handler](#handler) +- [Diagnostics](#diagnostics) - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - [`sticky_set`](#sticky_set) - [Evaluation details](#evaluation-details) -- [Hooks](#hooks) - - [Defining a hook](#defining-a-hook) - - [Registering hooks](#registering-hooks) +- [Modules](#modules) + - [Defining a module](#defining-a-module) + - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) - [CLI usage](#cli-usage) @@ -370,7 +371,11 @@ You may also initialize the SDK without passing `datafile`, and set it later on: f.SetDatafile(datafileContent) ``` -`SetDatafile` accepts either parsed `featurevisor.DatafileContent` or a raw JSON string. +`SetDatafile` accepts either parsed `featurevisor.DatafileContent` or a raw JSON string. By default, it merges the incoming datafile into the SDK's stored datafile. Pass `true` as the second argument to replace the stored datafile instead: + +```go +f.SetDatafile(datafileContent, true) // replace existing datafile +``` ### Updating datafile @@ -493,6 +498,20 @@ f := featurevisor.CreateInstance(featurevisor.Options{ Further log levels like `info` and `debug` will help you understand how the feature variations and variables are evaluated in the runtime against given context. +## Diagnostics + +You can observe SDK and module diagnostics with `OnDiagnostic`: + +```go +f := featurevisor.CreateInstance(featurevisor.Options{ + OnDiagnostic: func(diagnostic featurevisor.FeaturevisorDiagnostic) { + fmt.Println(diagnostic.Level, diagnostic.Code, diagnostic.Message) + }, +}) +``` + +Modules can also subscribe to diagnostics or report their own from `Setup` via the provided module API. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. @@ -581,24 +600,31 @@ And optionally these properties depending on whether you are evaluating a featur - `VariableSchema`: the variable schema - `VariableOverrideIndex`: index of matched variable override when applicable -## Hooks +## Modules -Hooks allow you to intercept the evaluation process and customize it further as per your needs. +Modules allow you to intercept the evaluation process and customize it further as per your needs. -### Defining a hook +### Defining a module -A hook is a simple struct with a unique required `Name` and optional functions: +A module is a simple struct with a recommended unique `Name` and optional functions: ```go import ( "github.com/featurevisor/featurevisor-go" ) -myCustomHook := &featurevisor.Hook{ - // only required property - Name: "my-custom-hook", - - // rest of the properties below are all optional per hook +myCustomModule := &featurevisor.FeaturevisorModule{ + // recommended for diagnostics and removal + Name: "my-custom-module", + + // rest of the properties below are all optional per module + Setup: func(api featurevisor.FeaturevisorModuleApi) { + api.ReportDiagnostic(featurevisor.FeaturevisorModuleReportedDiagnostic{ + Level: featurevisor.LogLevelInfo, + Code: "module_ready", + Message: "Module is ready", + }) + }, // before evaluation Before: func(options featurevisor.EvaluateOptions) featurevisor.EvaluateOptions { @@ -611,30 +637,35 @@ myCustomHook := &featurevisor.Hook{ }, // after evaluation - After: func(evaluation featurevisor.Evaluation, options featurevisor.EvaluateOptions) { + After: func(evaluation featurevisor.Evaluation, options featurevisor.EvaluateOptions) featurevisor.Evaluation { if evaluation.Reason == "error" { // log error - return + return evaluation } + return evaluation }, // configure bucket key - BucketKey: func(options featurevisor.EvaluateOptions) string { + BucketKey: func(options featurevisor.ConfigureBucketKeyOptions) featurevisor.BucketKey { // return custom bucket key return options.BucketKey }, // configure bucket value (between 0 and 100,000) - BucketValue: func(options featurevisor.EvaluateOptions) int { + BucketValue: func(options featurevisor.ConfigureBucketValueOptions) featurevisor.BucketValue { // return custom bucket value return options.BucketValue }, + + Close: func() { + // clean up module resources + }, } ``` -### Registering hooks +### Registering modules -You can register hooks at the time of SDK initialization: +You can register modules at the time of SDK initialization: ```go import ( @@ -642,8 +673,8 @@ import ( ) f := featurevisor.CreateInstance(featurevisor.Options{ - Hooks: []*featurevisor.Hook{ - myCustomHook, + Modules: []*featurevisor.FeaturevisorModule{ + myCustomModule, }, }) ``` @@ -651,8 +682,8 @@ f := featurevisor.CreateInstance(featurevisor.Options{ Or after initialization: ```go -removeHook := f.AddHook(myCustomHook) -removeHook() +removeModule := f.AddModule(myCustomModule) +removeModule() ``` ## Child instance @@ -725,25 +756,23 @@ go run cmd/main.go test \ --projectDirectoryPath="/absolute/path/to/your/featurevisor/project" \ --quiet|verbose \ --onlyFailures \ - --with-scopes \ - --with-tags \ --keyPattern="myFeatureKey" \ --assertionPattern="#1" ``` -`--with-scopes` and `--with-tags` match Featurevisor CLI behavior by generating and testing against scoped/tagged datafiles via `npx featurevisor build`. - If you want to validate parity locally against the JavaScript SDK runner, you can use the bundled example project: ```bash -cd monorepo/examples/example-1 -npx featurevisor test --with-scopes --with-tags +cd /Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 +npx featurevisor test -# from repository root: +# from this Go SDK repository root: go run cmd/main.go test \ - --projectDirectoryPath="/absolute/path/to/featurevisor-go/monorepo/examples/example-1" \ - --with-scopes \ - --with-tags + --projectDirectoryPath="/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1" \ + --onlyFailures + +# or: +make test-example-1 ``` ### Benchmark diff --git a/api_compatibility_test.go b/api_compatibility_test.go index 3f4bc48..c8f34ae 100644 --- a/api_compatibility_test.go +++ b/api_compatibility_test.go @@ -671,9 +671,9 @@ func TestProductionDatafileFeatures(t *testing.T) { "country": "nl", "deviceId": "test-device-123", }, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { - Name: "test-hook", + Name: "test-module", BucketValue: func(options ConfigureBucketValueOptions) int { // Force bucket value to 60000 (60%) to get treatment variation return 60000 @@ -718,9 +718,9 @@ func TestProductionDatafileFeatures(t *testing.T) { "country": "ch", "deviceId": "test-device-ch", }, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { - Name: "test-hook", + Name: "test-module", BucketValue: func(options ConfigureBucketValueOptions) int { // Force bucket value to 60000 (60%) to get treatment variation return 60000 @@ -741,9 +741,9 @@ func TestProductionDatafileFeatures(t *testing.T) { "country": "de", "deviceId": "test-device-de", }, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { - Name: "test-hook", + Name: "test-module", BucketValue: func(options ConfigureBucketValueOptions) int { // Force bucket value to 40000 (40%) to get control variation return 40000 @@ -768,9 +768,9 @@ func TestProductionDatafileFeatures(t *testing.T) { "country": "us", "userId": "test-user-15", }, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { - Name: "test-hook", + Name: "test-module", BucketValue: func(options ConfigureBucketValueOptions) int { // Force bucket value to 15000 (15%) to get control variation return 15000 @@ -808,9 +808,9 @@ func TestProductionDatafileFeatures(t *testing.T) { "country": "de", "userId": "test-user-de", }, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { - Name: "test-hook", + Name: "test-module", BucketValue: func(options ConfigureBucketValueOptions) int { // Force bucket value to 20000 (20%) to get variation 'b' return 20000 @@ -836,9 +836,9 @@ func TestProductionDatafileFeatures(t *testing.T) { "device": "mobile", "userId": "test-user-foo", }, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { - Name: "test-hook", + Name: "test-module", BucketValue: func(options ConfigureBucketValueOptions) int { // Force bucket value to 60000 (60%) to get treatment variation return 60000 @@ -911,9 +911,9 @@ func TestProductionDatafileFeatures(t *testing.T) { "country": "nl", "userId": "test-user-nl", }, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { - Name: "test-hook", + Name: "test-module", BucketValue: func(options ConfigureBucketValueOptions) int { // Force bucket value to 90000 (90%) to get treatment variation return 90000 @@ -964,9 +964,9 @@ func TestProductionDatafileFeatures(t *testing.T) { "country": "de", "userId": "test-user-de", }, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { - Name: "test-hook", + Name: "test-module", BucketValue: func(options ConfigureBucketValueOptions) int { // Force bucket value to 90000 (90%) to get treatment variation return 90000 @@ -996,9 +996,9 @@ func TestProductionDatafileFeatures(t *testing.T) { "country": "nl", "userId": "test-user-qux", }, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { - Name: "test-hook", + Name: "test-module", BucketValue: func(options ConfigureBucketValueOptions) int { // Force bucket value to 70000 (70%) to get variation 'b' return 70000 @@ -1031,9 +1031,9 @@ func TestProductionDatafileFeatures(t *testing.T) { "country": "de", "userId": "test-user-qux-de", }, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { - Name: "test-hook", + Name: "test-module", BucketValue: func(options ConfigureBucketValueOptions) int { // Force bucket value to 70000 (70%) to get variation 'b' return 70000 diff --git a/child.go b/child.go index 110d335..dc4f8cf 100644 --- a/child.go +++ b/child.go @@ -131,7 +131,7 @@ func (c *FeaturevisorChild) getEvaluationDependencies(context Context, options O return EvaluateDependencies{ Context: c.GetContext(context), Logger: c.parent.logger, - HooksManager: c.parent.hooksManager, + ModulesManager: c.parent.modulesManager, DatafileReader: c.parent.datafileReader, Sticky: sticky, DefaultVariationValue: options.DefaultVariationValue, @@ -141,7 +141,7 @@ func (c *FeaturevisorChild) getEvaluationDependencies(context Context, options O // EvaluateFlag evaluates a feature flag func (c *FeaturevisorChild) EvaluateFlag(featureKey string, context Context, options OverrideOptions) Evaluation { - return EvaluateWithHooks(EvaluateOptions{ + return EvaluateWithModules(EvaluateOptions{ EvaluateParams: EvaluateParams{ Type: EvaluationTypeFlag, FeatureKey: FeatureKey(featureKey), @@ -186,7 +186,7 @@ func (c *FeaturevisorChild) IsEnabled(featureKey string, args ...interface{}) bo // EvaluateVariation evaluates a feature variation func (c *FeaturevisorChild) EvaluateVariation(featureKey string, context Context, options OverrideOptions) Evaluation { - return EvaluateWithHooks(EvaluateOptions{ + return EvaluateWithModules(EvaluateOptions{ EvaluateParams: EvaluateParams{ Type: EvaluationTypeVariation, FeatureKey: FeatureKey(featureKey), @@ -239,7 +239,7 @@ func (c *FeaturevisorChild) GetVariation(featureKey string, args ...interface{}) // EvaluateVariable evaluates a feature variable func (c *FeaturevisorChild) EvaluateVariable(featureKey string, variableKey VariableKey, context Context, options OverrideOptions) Evaluation { - return EvaluateWithHooks(EvaluateOptions{ + return EvaluateWithModules(EvaluateOptions{ EvaluateParams: EvaluateParams{ Type: EvaluationTypeVariable, FeatureKey: FeatureKey(featureKey), diff --git a/cmd/commands/assess_distribution.go b/cmd/commands/assess_distribution.go index 0e90326..fc21311 100644 --- a/cmd/commands/assess_distribution.go +++ b/cmd/commands/assess_distribution.go @@ -106,7 +106,7 @@ func runAssessDistribution(opts CLIOptions) { levelStr := getLoggerLevel(opts) level := featurevisor.LogLevel(levelStr) - datafilesByEnvironment := buildDatafiles(featurevisorProjectPath, []string{opts.Environment}, "", 0) + datafilesByEnvironment := buildDatafiles(featurevisorProjectPath, []string{opts.Environment}, 0) // Create SDK instance datafile := datafilesByEnvironment[opts.Environment] diff --git a/cmd/commands/benchmark.go b/cmd/commands/benchmark.go index 7907b5d..d339b53 100644 --- a/cmd/commands/benchmark.go +++ b/cmd/commands/benchmark.go @@ -155,7 +155,7 @@ func runBenchmark(opts CLIOptions) { fmt.Printf("Building datafile containing all features for \"%s\"...\n", opts.Environment) datafileBuildStart := time.Now() - datafilesByEnvironment := buildDatafiles(featurevisorProjectPath, []string{opts.Environment}, "", 0) + datafilesByEnvironment := buildDatafiles(featurevisorProjectPath, []string{opts.Environment}, 0) datafileBuildDuration := time.Since(datafileBuildStart) // Convert to milliseconds to match TypeScript behavior datafileBuildDurationMs := datafileBuildDuration.Milliseconds() diff --git a/cmd/commands/commands.go b/cmd/commands/commands.go index 729b186..e56acf6 100644 --- a/cmd/commands/commands.go +++ b/cmd/commands/commands.go @@ -61,10 +61,11 @@ func ParseCLIOptions(args []string) CLIOptions { fs.BoolVar(&opts.Variation, "variation", false, "Variation mode") fs.BoolVar(&opts.Verbose, "verbose", false, "Verbose mode") fs.IntVar(&opts.Inflate, "inflate", 0, "Inflate mode") - fs.BoolVar(&opts.WithScopes, "with-scopes", false, "Test with scoped datafiles") - fs.BoolVar(&opts.WithTags, "with-tags", false, "Test with tagged datafiles") + fs.BoolVar(&opts.WithScopes, "with-scopes", false, "Legacy option accepted for compatibility and ignored") + fs.BoolVar(&opts.WithTags, "with-tags", false, "Legacy option accepted for compatibility and ignored") fs.BoolVar(&opts.ShowDatafile, "showDatafile", false, "Show datafile") - fs.StringVar(&opts.SchemaVersion, "schemaVersion", "", "Schema version") + fs.StringVar(&opts.SchemaVersion, "schemaVersion", "", "Legacy option accepted for compatibility and ignored") + fs.StringVar(&opts.SchemaVersion, "schema-version", "", "Legacy option accepted for compatibility and ignored") fs.StringVar(&opts.ProjectDirectoryPath, "projectDirectoryPath", "", "Project directory path") // Parse the filtered flags diff --git a/cmd/commands/commands_test.go b/cmd/commands/commands_test.go index ee868fb..7909c91 100644 --- a/cmd/commands/commands_test.go +++ b/cmd/commands/commands_test.go @@ -2,8 +2,8 @@ package commands import "testing" -func TestParseCLIOptionsWithScopesAndTags(t *testing.T) { - opts := ParseCLIOptions([]string{"--with-scopes", "--with-tags"}) +func TestParseCLIOptionsAcceptsLegacyIgnoredFlags(t *testing.T) { + opts := ParseCLIOptions([]string{"--with-scopes", "--with-tags", "--schemaVersion=1", "--schema-version=2"}) if !opts.WithScopes { t.Fatalf("expected WithScopes to be true") @@ -11,4 +11,18 @@ func TestParseCLIOptionsWithScopesAndTags(t *testing.T) { if !opts.WithTags { t.Fatalf("expected WithTags to be true") } + if opts.SchemaVersion != "2" { + t.Fatalf("expected SchemaVersion to be parsed for compatibility") + } +} + +func TestTargetDatafileCacheKey(t *testing.T) { + if got := targetDatafileCacheKey(nil, "checkout"); got != "false-target-checkout" { + t.Fatalf("expected false-target-checkout, got %s", got) + } + + environment := "production" + if got := targetDatafileCacheKey(&environment, "checkout"); got != "production-target-checkout" { + t.Fatalf("expected production-target-checkout, got %s", got) + } } diff --git a/cmd/commands/test.go b/cmd/commands/test.go index 48df232..2f6030b 100644 --- a/cmd/commands/test.go +++ b/cmd/commands/test.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "os/exec" - "path/filepath" "strings" "time" @@ -638,99 +637,55 @@ func getSegments(featurevisorProjectPath string) map[string]interface{} { return segmentsByKey } -func buildDatafileJSON(featurevisorProjectPath string, environment *string, schemaVersion string, inflate int, tag *string) interface{} { - args := []string{"build"} - if environment != nil { - args = append(args, fmt.Sprintf("--environment=%s", *environment)) - } - if schemaVersion != "" { - args = append(args, fmt.Sprintf("--schema-version=%s", schemaVersion)) - } - if inflate > 0 { - args = append(args, fmt.Sprintf("--inflate=%d", inflate)) - } - if tag != nil { - args = append(args, fmt.Sprintf("--tag=%s", *tag)) +func getTargets(featurevisorProjectPath string) []string { + fmt.Println("Getting targets...") + targetsOutput := mustExecuteFeaturevisorCommand(featurevisorProjectPath, "list", "--targets", "--json") + var targets []map[string]interface{} + if err := json.Unmarshal([]byte(targetsOutput), &targets); err != nil { + fmt.Printf("failed to parse targets json: %v\n", err) + os.Exit(1) } - args = append(args, "--json") - datafileOutput := mustExecuteFeaturevisorCommand(featurevisorProjectPath, args...) - var datafile interface{} - if err := json.Unmarshal([]byte(datafileOutput), &datafile); err != nil { - fmt.Printf("failed to parse datafile json: %v\n", err) - os.Exit(1) + targetKeys := []string{} + for _, target := range targets { + if key, ok := target["key"].(string); ok { + targetKeys = append(targetKeys, key) + } } - return datafile + return targetKeys } -func ensureDatafilesBuilt(featurevisorProjectPath string, environment *string, schemaVersion string, inflate int) { +func buildDatafileJSON(featurevisorProjectPath string, environment *string, inflate int, target *string) interface{} { args := []string{"build"} if environment != nil { args = append(args, fmt.Sprintf("--environment=%s", *environment)) } - if schemaVersion != "" { - args = append(args, fmt.Sprintf("--schema-version=%s", schemaVersion)) - } if inflate > 0 { args = append(args, fmt.Sprintf("--inflate=%d", inflate)) } - args = append(args, "--no-state-files") - - _, err := executeFeaturevisorCommand(featurevisorProjectPath, args...) - if err != nil { - fmt.Println(err.Error()) - os.Exit(1) - } -} - -func getDatafilesDirectoryPath(featurevisorProjectPath string, config map[string]interface{}) string { - datafilesDirectoryPath := "datafiles" - if raw, ok := config["datafilesDirectoryPath"].(string); ok && raw != "" { - datafilesDirectoryPath = raw - } - - if filepath.IsAbs(datafilesDirectoryPath) { - return datafilesDirectoryPath - } - - return filepath.Join(featurevisorProjectPath, datafilesDirectoryPath) -} - -func getScopedDatafileFromDisk(featurevisorProjectPath string, config map[string]interface{}, environment *string, scopeName string) interface{} { - filename := fmt.Sprintf("featurevisor-scope-%s.json", scopeName) - datafilesDirectoryPath := getDatafilesDirectoryPath(featurevisorProjectPath, config) - - var fullPath string - if environment != nil { - fullPath = filepath.Join(datafilesDirectoryPath, *environment, filename) - } else { - fullPath = filepath.Join(datafilesDirectoryPath, filename) - } - - content, err := os.ReadFile(fullPath) - if err != nil { - fmt.Printf("failed to read scoped datafile: %s (%v)\n", fullPath, err) - os.Exit(1) + if target != nil { + args = append(args, fmt.Sprintf("--target=%s", *target)) } + args = append(args, "--json") + datafileOutput := mustExecuteFeaturevisorCommand(featurevisorProjectPath, args...) var datafile interface{} - if err := json.Unmarshal(content, &datafile); err != nil { - fmt.Printf("failed to parse scoped datafile json: %s (%v)\n", fullPath, err) + if err := json.Unmarshal([]byte(datafileOutput), &datafile); err != nil { + fmt.Printf("failed to parse datafile json: %v\n", err) os.Exit(1) } return datafile } -func buildDatafiles(featurevisorProjectPath string, environments []string, schemaVersion string, inflate int) map[string]interface{} { +func buildDatafiles(featurevisorProjectPath string, environments []string, inflate int) map[string]interface{} { datafilesByEnvironment := make(map[string]interface{}) for _, environment := range environments { envCopy := environment datafilesByEnvironment[environment] = buildDatafileJSON( featurevisorProjectPath, &envCopy, - schemaVersion, inflate, nil, ) @@ -746,33 +701,22 @@ func datafileCacheKey(environment *string) string { return *environment } -func scopedDatafileCacheKey(environment *string, scope string) string { - base := "scope" +func targetDatafileCacheKey(environment *string, target string) string { + base := "false" if environment != nil { - base = *environment + "-scope" + base = *environment } - return fmt.Sprintf("%s-%s", base, scope) -} - -func taggedDatafileCacheKey(environment *string, tag string) string { - base := "tag" - if environment != nil { - base = *environment + "-tag" - } - - return fmt.Sprintf("%s-%s", base, tag) + return fmt.Sprintf("%s-target-%s", base, target) } func buildDatafileCache( featurevisorProjectPath string, config map[string]interface{}, - schemaVersion string, inflate int, - withScopes bool, - withTags bool, ) map[string]interface{} { cache := make(map[string]interface{}) + targetKeys := getTargets(featurevisorProjectPath) environments := []*string{nil} if envList, ok := config["environments"].([]interface{}); ok { @@ -787,54 +731,16 @@ func buildDatafileCache( for _, environment := range environments { baseKey := datafileCacheKey(environment) - cache[baseKey] = buildDatafileJSON(featurevisorProjectPath, environment, schemaVersion, inflate, nil) + cache[baseKey] = buildDatafileJSON(featurevisorProjectPath, environment, inflate, nil) - if withTags { - if tags, ok := config["tags"].([]interface{}); ok { - for _, rawTag := range tags { - tag, ok := rawTag.(string) - if !ok { - continue - } - - tagCopy := tag - cache[taggedDatafileCacheKey(environment, tag)] = buildDatafileJSON( - featurevisorProjectPath, - environment, - schemaVersion, - inflate, - &tagCopy, - ) - } - } - } - - if withScopes { - scopesRaw, ok := config["scopes"].([]interface{}) - if !ok || len(scopesRaw) == 0 { - continue - } - - ensureDatafilesBuilt(featurevisorProjectPath, environment, schemaVersion, inflate) - - for _, rawScope := range scopesRaw { - scopeMap, ok := rawScope.(map[string]interface{}) - if !ok { - continue - } - - scopeName, ok := scopeMap["name"].(string) - if !ok || scopeName == "" { - continue - } - - cache[scopedDatafileCacheKey(environment, scopeName)] = getScopedDatafileFromDisk( - featurevisorProjectPath, - config, - environment, - scopeName, - ) - } + for _, targetKey := range targetKeys { + targetCopy := targetKey + cache[targetDatafileCacheKey(environment, targetKey)] = buildDatafileJSON( + featurevisorProjectPath, + environment, + inflate, + &targetCopy, + ) } } @@ -885,9 +791,9 @@ func buildInstanceForAssertion(datafile interface{}, level string, assertion map return featurevisor.CreateInstance(featurevisor.Options{ Datafile: datafileContent, LogLevel: &levelStr, - Hooks: []*featurevisor.Hook{ + Modules: []*featurevisor.FeaturevisorModule{ { - Name: "tester-hook", + Name: "tester-module", BucketValue: func(options featurevisor.ConfigureBucketValueOptions) int { if at, ok := assertion["at"].(float64); ok { return int(at * 1000) @@ -909,31 +815,6 @@ func toContextMap(value interface{}) map[string]interface{} { return map[string]interface{}{} } -func getScopesByName(config map[string]interface{}) map[string]map[string]interface{} { - result := map[string]map[string]interface{}{} - scopesRaw, ok := config["scopes"].([]interface{}) - if !ok { - return result - } - - for _, rawScope := range scopesRaw { - scopeMap, ok := rawScope.(map[string]interface{}) - if !ok { - continue - } - - scopeName, ok := scopeMap["name"].(string) - if !ok || scopeName == "" { - continue - } - - scopeContext := toContextMap(scopeMap["context"]) - result[scopeName] = scopeContext - } - - return result -} - func cloneAssertion(assertion map[string]interface{}) map[string]interface{} { cloned := make(map[string]interface{}, len(assertion)) for key, value := range assertion { @@ -947,23 +828,11 @@ func runTest(opts CLIOptions) { config := getConfig(featurevisorProjectPath) segmentsByKey := getSegments(featurevisorProjectPath) - scopesByName := getScopesByName(config) - - // Use CLI schemaVersion option or fallback to config - schemaVersion := opts.SchemaVersion - if schemaVersion == "" { - if configSchemaVersion, ok := config["schemaVersion"].(string); ok { - schemaVersion = configSchemaVersion - } - } datafileCache := buildDatafileCache( featurevisorProjectPath, config, - schemaVersion, opts.Inflate, - opts.WithScopes, - opts.WithTags, ) fmt.Println() @@ -1003,15 +872,10 @@ func runTest(opts CLIOptions) { selectedDatafileKey := datafileCacheKey(environment) - if scopeValue, ok := assertionMap["scope"].(string); ok && scopeValue != "" { - if _, exists := datafileCache[scopedDatafileCacheKey(environment, scopeValue)]; exists { - selectedDatafileKey = scopedDatafileCacheKey(environment, scopeValue) - } - } - - if tagValue, ok := assertionMap["tag"].(string); ok && tagValue != "" { - if _, exists := datafileCache[taggedDatafileCacheKey(environment, tagValue)]; exists { - selectedDatafileKey = taggedDatafileCacheKey(environment, tagValue) + if targetValue, ok := assertionMap["target"].(string); ok && targetValue != "" { + targetKey := targetDatafileCacheKey(environment, targetValue) + if _, exists := datafileCache[targetKey]; exists { + selectedDatafileKey = targetKey } } @@ -1022,19 +886,6 @@ func runTest(opts CLIOptions) { } effectiveAssertion := cloneAssertion(assertionMap) - if scopeValue, ok := assertionMap["scope"].(string); ok && scopeValue != "" && !opts.WithScopes { - if scopeContext, exists := scopesByName[scopeValue]; exists { - mergedContext := map[string]interface{}{} - for key, value := range scopeContext { - mergedContext[key] = value - } - for key, value := range toContextMap(assertionMap["context"]) { - mergedContext[key] = value - } - effectiveAssertion["context"] = mergedContext - } - } - instance := buildInstanceForAssertion(datafile, level, effectiveAssertion) // Show datafile if requested (matching TypeScript implementation) diff --git a/cmd/commands/test_types.go b/cmd/commands/test_types.go index 9cdca00..6461870 100644 --- a/cmd/commands/test_types.go +++ b/cmd/commands/test_types.go @@ -29,8 +29,7 @@ type FeatureAssertion struct { Matrix *AssertionMatrix `json:"matrix,omitempty"` Description *string `json:"description,omitempty"` Environment featurevisor.EnvironmentKey `json:"environment"` - Scope *string `json:"scope,omitempty"` - Tag *string `json:"tag,omitempty"` + Target *string `json:"target,omitempty"` At *featurevisor.Weight `json:"at,omitempty"` Sticky *featurevisor.StickyFeatures `json:"sticky,omitempty"` Context *featurevisor.Context `json:"context,omitempty"` diff --git a/conditions_test.go b/conditions_test.go index 87553e9..ee52f00 100644 --- a/conditions_test.go +++ b/conditions_test.go @@ -317,7 +317,7 @@ func TestConditionIsMatchedDate(t *testing.T) { func TestConditionIsMatchedComprehensive(t *testing.T) { logger := NewLogger(CreateLoggerOptions{}) jsonDatafile := `{ - "schemaVersion": "2.0", + "schemaVersion": "2", "revision": "1", "segments": {}, "features": {} @@ -857,7 +857,7 @@ func TestConditionIsMatchedEdgeCases(t *testing.T) { func TestConditionIsMatchedComplexNested(t *testing.T) { logger := NewLogger(CreateLoggerOptions{}) jsonDatafile := `{ - "schemaVersion": "2.0", + "schemaVersion": "2", "revision": "1", "segments": {}, "features": {} diff --git a/datafile_reader_test.go b/datafile_reader_test.go index 27dcf35..f85e5c2 100644 --- a/datafile_reader_test.go +++ b/datafile_reader_test.go @@ -7,7 +7,7 @@ import ( func TestNewDatafileReader(t *testing.T) { logger := NewLogger(CreateLoggerOptions{}) jsonDatafile := `{ - "schemaVersion": "1.0.0", + "schemaVersion": "2", "revision": "test-revision", "segments": {}, "features": {} @@ -31,15 +31,15 @@ func TestNewDatafileReader(t *testing.T) { t.Errorf("Expected revision 'test-revision', got '%s'", reader.GetRevision()) } - if reader.GetSchemaVersion() != "1.0.0" { - t.Errorf("Expected schema version '1.0.0', got '%s'", reader.GetSchemaVersion()) + if reader.GetSchemaVersion() != "2" { + t.Errorf("Expected schema version '2', got '%s'", reader.GetSchemaVersion()) } } func TestDatafileReaderGetRegex(t *testing.T) { logger := NewLogger(CreateLoggerOptions{}) jsonDatafile := `{ - "schemaVersion": "1.0.0", + "schemaVersion": "2", "revision": "test-revision", "segments": {}, "features": {} @@ -73,7 +73,7 @@ func TestDatafileReaderGetRegex(t *testing.T) { func TestDatafileReaderAllConditionsAreMatched(t *testing.T) { logger := NewLogger(CreateLoggerOptions{}) jsonDatafile := `{ - "schemaVersion": "1.0.0", + "schemaVersion": "2", "revision": "test-revision", "segments": {}, "features": {} diff --git a/diagnostics.go b/diagnostics.go new file mode 100644 index 0000000..da1be26 --- /dev/null +++ b/diagnostics.go @@ -0,0 +1,39 @@ +package featurevisor + +const FeaturevisorDiagnosticPrefix = "[Featurevisor]" + +// FeaturevisorDiagnostic is emitted by the SDK and modules for logs/errors. +type FeaturevisorDiagnostic struct { + Level LogLevel `json:"level"` + Code string `json:"code,omitempty"` + Message string `json:"message"` + Module string `json:"module,omitempty"` + ModuleName string `json:"moduleName,omitempty"` + OriginalError interface{} `json:"originalError,omitempty"` + Details map[string]interface{} `json:"details,omitempty"` +} + +// FeaturevisorModuleReportedDiagnostic is a diagnostic reported by a module. +type FeaturevisorModuleReportedDiagnostic = FeaturevisorDiagnostic + +// FeaturevisorDiagnosticHandler handles diagnostics. +type FeaturevisorDiagnosticHandler func(diagnostic FeaturevisorDiagnostic) + +// FeaturevisorDiagnosticReporter reports diagnostics, optionally from a source module. +type FeaturevisorDiagnosticReporter func( + diagnostic FeaturevisorDiagnostic, + sourceModule *FeaturevisorModule, +) + +// FeaturevisorModuleDiagnosticOptions configures module diagnostic subscriptions. +type FeaturevisorModuleDiagnosticOptions struct { + LogLevel LogLevel `json:"logLevel,omitempty"` +} + +// FeaturevisorUnsubscribe unsubscribes from an SDK/module subscription. +type FeaturevisorUnsubscribe func() + +func shouldLogDiagnostic(currentLevel LogLevel, targetLevel LogLevel) bool { + logger := NewLogger(CreateLoggerOptions{Level: ¤tLevel}) + return logger.shouldHandle(targetLevel) +} diff --git a/evaluate.go b/evaluate.go index 61daa59..218366b 100644 --- a/evaluate.go +++ b/evaluate.go @@ -16,7 +16,7 @@ type EvaluateParams struct { type EvaluateDependencies struct { Context Context Logger *Logger - HooksManager *HooksManager + ModulesManager *ModulesManager DatafileReader *DatafileReader // OverrideOptions @@ -32,8 +32,8 @@ type EvaluateOptions struct { EvaluateDependencies } -// EvaluateWithHooks evaluates a feature with hooks -func EvaluateWithHooks(opts EvaluateOptions) Evaluation { +// EvaluateWithModules evaluates a feature with modules. +func EvaluateWithModules(opts EvaluateOptions) Evaluation { var evaluation Evaluation defer func() { @@ -53,14 +53,14 @@ func EvaluateWithHooks(opts EvaluateOptions) Evaluation { } }() - hooksManager := opts.HooksManager - hooks := hooksManager.GetAll() + modulesManager := opts.ModulesManager + modules := modulesManager.GetAll() - // run before hooks + // run before modules options := opts - for _, hook := range hooks { - if hook.Before != nil { - options = hook.Before(options) + for _, module := range modules { + if module.Before != nil { + options = module.Before(options) } } @@ -81,10 +81,10 @@ func EvaluateWithHooks(opts EvaluateOptions) Evaluation { evaluation.VariableValue = opts.DefaultVariableValue } - // run after hooks - for _, hook := range hooks { - if hook.After != nil { - evaluation = hook.After(evaluation, options) + // run after modules + for _, module := range modules { + if module.After != nil { + evaluation = module.After(evaluation, options) } } @@ -474,9 +474,9 @@ func Evaluate(options EvaluateOptions) Evaluation { Logger: options.Logger, }) - for _, hook := range options.HooksManager.GetAll() { - if hook.BucketKey != nil { - bucketKey = hook.BucketKey(ConfigureBucketKeyOptions{ + for _, module := range options.ModulesManager.GetAll() { + if module.BucketKey != nil { + bucketKey = module.BucketKey(ConfigureBucketKeyOptions{ FeatureKey: options.FeatureKey, Context: options.Context, BucketBy: feature.BucketBy, @@ -488,9 +488,9 @@ func Evaluate(options EvaluateOptions) Evaluation { // bucketValue bucketValue := GetBucketedNumber(bucketKey) - for _, hook := range options.HooksManager.GetAll() { - if hook.BucketValue != nil { - bucketValue = hook.BucketValue(ConfigureBucketValueOptions{ + for _, module := range options.ModulesManager.GetAll() { + if module.BucketValue != nil { + bucketValue = module.BucketValue(ConfigureBucketValueOptions{ FeatureKey: options.FeatureKey, BucketKey: bucketKey, Context: options.Context, diff --git a/events.go b/events.go index cd4c1f5..a612328 100644 --- a/events.go +++ b/events.go @@ -1,7 +1,11 @@ package featurevisor // getParamsForDatafileSetEvent gets parameters for datafile set event -func getParamsForDatafileSetEvent(previousDatafileReader *DatafileReader, newDatafileReader *DatafileReader) LogDetails { +func getParamsForDatafileSetEvent( + previousDatafileReader *DatafileReader, + newDatafileReader *DatafileReader, + replace bool, +) LogDetails { previousRevision := "" if previousDatafileReader != nil { previousRevision = previousDatafileReader.GetRevision() @@ -80,6 +84,7 @@ func getParamsForDatafileSetEvent(previousDatafileReader *DatafileReader, newDat "previousRevision": previousRevision, "revisionChanged": previousRevision != newRevision, "features": allAffectedFeatures, + "replaced": replace, } } diff --git a/events_parity_test.go b/events_parity_test.go index 618323c..3c79471 100644 --- a/events_parity_test.go +++ b/events_parity_test.go @@ -28,7 +28,7 @@ func TestGetParamsForDatafileSetEventShape(t *testing.T) { }, }) - params := getParamsForDatafileSetEvent(previousReader, newReader) + params := getParamsForDatafileSetEvent(previousReader, newReader, true) if _, exists := params["removedFeatures"]; exists { t.Fatalf("did not expect removedFeatures field in event details") @@ -39,6 +39,9 @@ func TestGetParamsForDatafileSetEventShape(t *testing.T) { if _, exists := params["addedFeatures"]; exists { t.Fatalf("did not expect addedFeatures field in event details") } + if params["replaced"] != true { + t.Fatalf("expected replaced=true in event details") + } } func parityStringPtr(value string) *string { diff --git a/hooks.go b/hooks.go deleted file mode 100644 index a7d1243..0000000 --- a/hooks.go +++ /dev/null @@ -1,98 +0,0 @@ -package featurevisor - -// ConfigureBucketKeyOptions contains options for configuring bucket key -type ConfigureBucketKeyOptions struct { - FeatureKey FeatureKey `json:"featureKey"` - Context Context `json:"context"` - BucketBy BucketBy `json:"bucketBy"` - BucketKey string `json:"bucketKey"` // the initial bucket key, which can be modified by hooks -} - -// ConfigureBucketKey is a function type for configuring bucket key -type ConfigureBucketKey func(options ConfigureBucketKeyOptions) BucketKey - -// ConfigureBucketValueOptions contains options for configuring bucket value -type ConfigureBucketValueOptions struct { - FeatureKey FeatureKey `json:"featureKey"` - BucketKey string `json:"bucketKey"` - Context Context `json:"context"` - BucketValue int `json:"bucketValue"` // the initial bucket value, which can be modified by hooks -} - -// ConfigureBucketValue is a function type for configuring bucket value -type ConfigureBucketValue func(options ConfigureBucketValueOptions) BucketValue - -// Hook represents a hook that can be executed during evaluation -type Hook struct { - Name string `json:"name"` - - Before func(options EvaluateOptions) EvaluateOptions `json:"before,omitempty"` - BucketKey ConfigureBucketKey `json:"bucketKey,omitempty"` - BucketValue ConfigureBucketValue `json:"bucketValue,omitempty"` - After func(evaluation Evaluation, options EvaluateOptions) Evaluation `json:"after,omitempty"` -} - -// HooksManagerOptions contains options for creating a hooks manager -type HooksManagerOptions struct { - Hooks []*Hook `json:"hooks,omitempty"` - Logger *Logger `json:"logger"` -} - -// HooksManager manages hooks for evaluation -type HooksManager struct { - hooks []*Hook - logger *Logger -} - -// NewHooksManager creates a new hooks manager instance -func NewHooksManager(options HooksManagerOptions) *HooksManager { - hm := &HooksManager{ - hooks: make([]*Hook, 0), - logger: options.Logger, - } - - if options.Hooks != nil { - for _, hook := range options.Hooks { - hm.Add(hook) - } - } - - return hm -} - -// Add adds a hook to the hooks manager -func (hm *HooksManager) Add(hook *Hook) func() { - // Check if hook with same name already exists - for _, existingHook := range hm.hooks { - if existingHook.Name == hook.Name { - hm.logger.Error("Hook with name already exists", LogDetails{ - "name": hook.Name, - "hook": hook, - }) - return nil - } - } - - hm.hooks = append(hm.hooks, hook) - - // Return a function to remove the hook - return func() { - hm.Remove(hook.Name) - } -} - -// Remove removes a hook by name -func (hm *HooksManager) Remove(name string) { - newHooks := make([]*Hook, 0) - for _, hook := range hm.hooks { - if hook.Name != name { - newHooks = append(newHooks, hook) - } - } - hm.hooks = newHooks -} - -// GetAll returns all hooks -func (hm *HooksManager) GetAll() []*Hook { - return hm.hooks -} diff --git a/instance.go b/instance.go index 045bd96..3aac8d9 100644 --- a/instance.go +++ b/instance.go @@ -15,25 +15,39 @@ type OverrideOptions struct { // Options contains options for creating an instance type Options struct { - Datafile interface{} // DatafileContent | string - Context Context - LogLevel *LogLevel - Logger *Logger - Sticky *StickyFeatures - Hooks []*Hook + Datafile interface{} // DatafileContent | string + Context Context + LogLevel *LogLevel + Logger *Logger + OnDiagnostic FeaturevisorDiagnosticHandler + Sticky *StickyFeatures + Modules []*FeaturevisorModule +} + +type moduleDiagnosticSubscription struct { + id int + module *FeaturevisorModule + handler FeaturevisorDiagnosticHandler + logLevel LogLevel } // Featurevisor represents a Featurevisor SDK instance type Featurevisor struct { // from options - context Context - logger *Logger - sticky *StickyFeatures + context Context + logger *Logger + logLevel LogLevel + onDiagnostic FeaturevisorDiagnosticHandler + sticky *StickyFeatures // internally created - datafileReader *DatafileReader - hooksManager *HooksManager - emitter *Emitter + datafile DatafileContent + datafileReader *DatafileReader + modulesManager *ModulesManager + moduleDiagnosticSubscriptions []moduleDiagnosticSubscription + nextModuleDiagnosticID int + emitter *Emitter + closed bool } // NewFeaturevisor creates a new Featurevisor instance @@ -56,16 +70,9 @@ func NewFeaturevisor(options Options) *Featurevisor { logger = NewLogger(CreateLoggerOptions{Level: &level}) } - // Create hooks manager - hooksManager := NewHooksManager(HooksManagerOptions{ - Logger: logger, - Hooks: options.Hooks, - }) - // Create emitter emitter := NewEmitter() - // Create datafile reader emptyDatafile := DatafileContent{ SchemaVersion: "2", Revision: "unknown", @@ -78,61 +85,95 @@ func NewFeaturevisor(options Options) *Featurevisor { Logger: logger, }) - // If datafile is provided, set it - if options.Datafile != nil { - datafileContent, err := parseDatafileInput(options.Datafile) - if err != nil { - logger.Error("could not parse datafile", LogDetails{"error": err}) - } else { - datafileReader = NewDatafileReader(DatafileReaderOptions{ - Datafile: datafileContent, - Logger: logger, - }) - } - } - instance := &Featurevisor{ context: context, logger: logger, - hooksManager: hooksManager, + logLevel: logger.GetLevel(), + onDiagnostic: options.OnDiagnostic, emitter: emitter, + datafile: emptyDatafile, datafileReader: datafileReader, sticky: options.Sticky, } - logger.Info("Featurevisor SDK initialized", LogDetails{}) + instance.modulesManager = NewModulesManager(ModulesManagerOptions{ + Modules: options.Modules, + ReportDiagnostic: instance.reportDiagnostic, + GetModuleApi: instance.getModuleApi, + ClearModuleDiagnosticSubscriptions: instance.clearModuleDiagnosticSubscriptions, + }) + + if options.Datafile != nil { + instance.SetDatafile(options.Datafile, true) + } + + instance.reportDiagnostic(FeaturevisorDiagnostic{ + Level: LogLevelInfo, + Code: "sdk_initialized", + Message: "SDK initialized", + }, nil) return instance } // SetLogLevel sets the log level func (i *Featurevisor) SetLogLevel(level LogLevel) { + i.logLevel = level i.logger.SetLevel(level) } // SetDatafile sets the datafile -func (i *Featurevisor) SetDatafile(datafile interface{}) { +func (i *Featurevisor) SetDatafile(datafile interface{}, replace ...bool) { + if i.closed { + return + } + + replaceValue := false + if len(replace) > 0 { + replaceValue = replace[0] + } + datafileContent, err := parseDatafileInput(datafile) if err != nil { - i.logger.Error("could not parse datafile", LogDetails{"error": err}) + i.reportDiagnostic(FeaturevisorDiagnostic{ + Level: LogLevelError, + Code: "invalid_datafile", + Message: "Could not parse datafile", + OriginalError: err, + }, nil) return } + storedDatafile := datafileContent + if !replaceValue { + storedDatafile = mergeStoredDatafile(i.datafile, datafileContent) + } + newDatafileReader := NewDatafileReader(DatafileReaderOptions{ - Datafile: datafileContent, + Datafile: storedDatafile, Logger: i.logger, }) - details := getParamsForDatafileSetEvent(i.datafileReader, newDatafileReader) + details := getParamsForDatafileSetEvent(i.datafileReader, newDatafileReader, replaceValue) + i.datafile = storedDatafile i.datafileReader = newDatafileReader - i.logger.Info("datafile set", details) + i.reportDiagnostic(FeaturevisorDiagnostic{ + Level: LogLevelInfo, + Code: "datafile_set", + Message: "Datafile set", + Details: details, + }, nil) i.emitter.Trigger(EventNameDatafileSet, EventDetails(details)) } // SetSticky sets sticky features func (i *Featurevisor) SetSticky(sticky StickyFeatures, replace ...bool) { + if i.closed { + return + } + replaceValue := false if len(replace) > 0 { replaceValue = replace[0] @@ -159,7 +200,12 @@ func (i *Featurevisor) SetSticky(sticky StickyFeatures, replace ...bool) { params := getParamsForStickySetEvent(previousStickyFeatures, *i.sticky, replaceValue) - i.logger.Info("sticky features set", params) + i.reportDiagnostic(FeaturevisorDiagnostic{ + Level: LogLevelInfo, + Code: "sticky_set", + Message: "Sticky features set", + Details: params, + }, nil) i.emitter.Trigger(EventNameStickySet, EventDetails(params)) } @@ -173,23 +219,150 @@ func (i *Featurevisor) GetFeature(featureKey string) *Feature { return i.datafileReader.GetFeature(FeatureKey(featureKey)) } -// AddHook adds a hook -func (i *Featurevisor) AddHook(hook *Hook) func() { - return i.hooksManager.Add(hook) +// AddModule adds a module. +func (i *Featurevisor) AddModule(module *FeaturevisorModule) FeaturevisorUnsubscribe { + if i.closed { + return nil + } + + return i.modulesManager.Add(module) +} + +// RemoveModule removes modules by name. +func (i *Featurevisor) RemoveModule(name string) { + if i.closed { + return + } + + i.modulesManager.Remove(name) } // On adds an event listener func (i *Featurevisor) On(eventName EventName, callback EventCallback) Unsubscribe { + if i.closed { + return func() {} + } + return i.emitter.On(eventName, callback) } // Close closes the instance func (i *Featurevisor) Close() { + if i.closed { + return + } + + i.closed = true + i.modulesManager.CloseAll() + i.moduleDiagnosticSubscriptions = nil i.emitter.ClearAll() } +func (i *Featurevisor) reportDiagnostic( + diagnostic FeaturevisorDiagnostic, + sourceModule *FeaturevisorModule, +) { + for _, subscription := range append([]moduleDiagnosticSubscription{}, i.moduleDiagnosticSubscriptions...) { + if subscription.module == sourceModule { + continue + } + if !shouldLogDiagnostic(subscription.logLevel, diagnostic.Level) { + continue + } + subscription.handler(diagnostic) + } + + if shouldLogDiagnostic(i.logLevel, diagnostic.Level) { + if i.onDiagnostic != nil { + i.onDiagnostic(diagnostic) + } else { + details := LogDetails{} + if diagnostic.Details != nil { + for key, value := range diagnostic.Details { + details[key] = value + } + } + if diagnostic.Code != "" { + details["code"] = diagnostic.Code + } + if diagnostic.Module != "" { + details["module"] = diagnostic.Module + } + if diagnostic.ModuleName != "" { + details["moduleName"] = diagnostic.ModuleName + } + if diagnostic.OriginalError != nil { + details["originalError"] = diagnostic.OriginalError + } + i.logger.Log(diagnostic.Level, LogMessage(diagnostic.Message), details) + } + } + + if diagnostic.Level == LogLevelError { + i.emitter.Trigger("error", EventDetails{"diagnostic": diagnostic}) + } +} + +func (i *Featurevisor) getModuleApi(module *FeaturevisorModule) FeaturevisorModuleApi { + return FeaturevisorModuleApi{ + GetRevision: func() string { + return i.GetRevision() + }, + OnDiagnostic: func( + handler FeaturevisorDiagnosticHandler, + options ...FeaturevisorModuleDiagnosticOptions, + ) FeaturevisorUnsubscribe { + logLevel := LogLevelInfo + if len(options) > 0 && options[0].LogLevel != "" { + logLevel = options[0].LogLevel + } + + subscription := moduleDiagnosticSubscription{ + id: i.nextModuleDiagnosticID, + module: module, + handler: handler, + logLevel: logLevel, + } + i.nextModuleDiagnosticID++ + + i.moduleDiagnosticSubscriptions = append(i.moduleDiagnosticSubscriptions, subscription) + subscriptionID := subscription.id + + return func() { + filtered := []moduleDiagnosticSubscription{} + for _, currentSubscription := range i.moduleDiagnosticSubscriptions { + if currentSubscription.id != subscriptionID { + filtered = append(filtered, currentSubscription) + } + } + i.moduleDiagnosticSubscriptions = filtered + } + }, + ReportDiagnostic: func(diagnostic FeaturevisorModuleReportedDiagnostic) { + if module != nil && module.Name != "" { + diagnostic.Module = module.Name + } + i.reportDiagnostic(diagnostic, module) + }, + } +} + +func (i *Featurevisor) clearModuleDiagnosticSubscriptions(module *FeaturevisorModule) { + filtered := []moduleDiagnosticSubscription{} + for _, subscription := range i.moduleDiagnosticSubscriptions { + if subscription.module != module { + filtered = append(filtered, subscription) + } + } + i.moduleDiagnosticSubscriptions = filtered +} + // SetContext sets the context func (i *Featurevisor) SetContext(context Context, replace ...bool) { + if i.closed { + return + } + replaceValue := false if len(replace) > 0 { replaceValue = replace[0] @@ -281,7 +454,7 @@ func (i *Featurevisor) getEvaluationDependencies(context Context, options Overri return EvaluateDependencies{ Context: i.GetContext(context), Logger: i.logger, - HooksManager: i.hooksManager, + ModulesManager: i.modulesManager, DatafileReader: i.datafileReader, Sticky: sticky, DefaultVariationValue: options.DefaultVariationValue, @@ -291,7 +464,7 @@ func (i *Featurevisor) getEvaluationDependencies(context Context, options Overri // EvaluateFlag evaluates a feature flag func (i *Featurevisor) EvaluateFlag(featureKey string, context Context, options OverrideOptions) Evaluation { - return EvaluateWithHooks(EvaluateOptions{ + return EvaluateWithModules(EvaluateOptions{ EvaluateParams: EvaluateParams{ Type: EvaluationTypeFlag, FeatureKey: FeatureKey(featureKey), @@ -336,7 +509,7 @@ func (i *Featurevisor) IsEnabled(featureKey string, args ...interface{}) bool { // EvaluateVariation evaluates a feature variation func (i *Featurevisor) EvaluateVariation(featureKey string, context Context, options OverrideOptions) Evaluation { - return EvaluateWithHooks(EvaluateOptions{ + return EvaluateWithModules(EvaluateOptions{ EvaluateParams: EvaluateParams{ Type: EvaluationTypeVariation, FeatureKey: FeatureKey(featureKey), @@ -389,7 +562,7 @@ func (i *Featurevisor) GetVariation(featureKey string, args ...interface{}) *str // EvaluateVariable evaluates a feature variable func (i *Featurevisor) EvaluateVariable(featureKey string, variableKey VariableKey, context Context, options OverrideOptions) Evaluation { - return EvaluateWithHooks(EvaluateOptions{ + return EvaluateWithModules(EvaluateOptions{ EvaluateParams: EvaluateParams{ Type: EvaluationTypeVariable, FeatureKey: FeatureKey(featureKey), @@ -647,6 +820,32 @@ func CreateInstance(options Options) *Featurevisor { return NewFeaturevisor(options) } +func mergeStoredDatafile(existing DatafileContent, incoming DatafileContent) DatafileContent { + mergedSegments := map[SegmentKey]Segment{} + for key, value := range existing.Segments { + mergedSegments[key] = value + } + for key, value := range incoming.Segments { + mergedSegments[key] = value + } + + mergedFeatures := map[FeatureKey]Feature{} + for key, value := range existing.Features { + mergedFeatures[key] = value + } + for key, value := range incoming.Features { + mergedFeatures[key] = value + } + + return DatafileContent{ + SchemaVersion: incoming.SchemaVersion, + Revision: incoming.Revision, + FeaturevisorVersion: incoming.FeaturevisorVersion, + Segments: mergedSegments, + Features: mergedFeatures, + } +} + func parseDatafileInput(datafile interface{}) (DatafileContent, error) { var datafileContent DatafileContent diff --git a/instance_mutually_exclusive_test.go b/instance_mutually_exclusive_test.go index da4d612..3a8052a 100644 --- a/instance_mutually_exclusive_test.go +++ b/instance_mutually_exclusive_test.go @@ -34,7 +34,7 @@ func TestMutuallyExclusiveFeatures(t *testing.T) { } sdk := CreateInstance(Options{ - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { Name: "unit-test", BucketValue: func(options ConfigureBucketValueOptions) int { diff --git a/instance_test.go b/instance_test.go index ce449ad..3f5cb14 100644 --- a/instance_test.go +++ b/instance_test.go @@ -80,7 +80,7 @@ func TestPlainBucketBy(t *testing.T) { instance := CreateInstance(Options{ Datafile: datafile, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { Name: "unit-test", BucketKey: func(options ConfigureBucketKeyOptions) string { @@ -144,7 +144,7 @@ func TestAndBucketBy(t *testing.T) { instance := CreateInstance(Options{ Datafile: datafile, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { Name: "unit-test", BucketKey: func(options ConfigureBucketKeyOptions) string { @@ -209,7 +209,7 @@ func TestOrBucketBy(t *testing.T) { instance := CreateInstance(Options{ Datafile: datafile, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { Name: "unit-test", BucketKey: func(options ConfigureBucketKeyOptions) string { @@ -251,7 +251,7 @@ func TestOrBucketBy(t *testing.T) { } } -func TestBeforeHook(t *testing.T) { +func TestBeforeModule(t *testing.T) { var intercepted bool var interceptedFeatureKey string var interceptedVariableKey string @@ -290,7 +290,7 @@ func TestBeforeHook(t *testing.T) { instance := CreateInstance(Options{ Datafile: datafile, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { Name: "unit-test", Before: func(options EvaluateOptions) EvaluateOptions { @@ -313,7 +313,7 @@ func TestBeforeHook(t *testing.T) { } if !intercepted { - t.Error("Expected before hook to be called") + t.Error("Expected before module to be called") } if interceptedFeatureKey != "test" { @@ -325,7 +325,7 @@ func TestBeforeHook(t *testing.T) { } } -func TestAfterHook(t *testing.T) { +func TestAfterModule(t *testing.T) { var intercepted bool var interceptedFeatureKey string var interceptedVariableKey string @@ -364,7 +364,7 @@ func TestAfterHook(t *testing.T) { instance := CreateInstance(Options{ Datafile: datafile, - Hooks: []*Hook{ + Modules: []*FeaturevisorModule{ { Name: "unit-test", After: func(evaluation Evaluation, options EvaluateOptions) Evaluation { @@ -390,7 +390,7 @@ func TestAfterHook(t *testing.T) { } if !intercepted { - t.Error("Expected after hook to be called") + t.Error("Expected after module to be called") } if interceptedFeatureKey != "test" { @@ -407,6 +407,135 @@ func stringPtr(s string) *string { return &s } +func TestModulesSetupDiagnosticsAndClose(t *testing.T) { + var setupCalled bool + var closeCalled bool + var setupRevision string + var moduleSawDatafileSet bool + var diagnostics []FeaturevisorDiagnostic + + instance := CreateInstance(Options{ + Datafile: DatafileContent{ + SchemaVersion: "2", + Revision: "module-test", + Segments: map[SegmentKey]Segment{}, + Features: map[FeatureKey]Feature{}, + }, + OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { + diagnostics = append(diagnostics, diagnostic) + }, + Modules: []*FeaturevisorModule{ + { + Name: "observer", + Setup: func(api FeaturevisorModuleApi) { + setupCalled = true + setupRevision = api.GetRevision() + api.OnDiagnostic(func(diagnostic FeaturevisorDiagnostic) { + if diagnostic.Code == "datafile_set" { + moduleSawDatafileSet = true + } + }) + api.ReportDiagnostic(FeaturevisorModuleReportedDiagnostic{ + Level: LogLevelInfo, + Code: "module_ready", + Message: "Module ready", + }) + }, + Close: func() { + closeCalled = true + }, + }, + }, + }) + + if !setupCalled { + t.Fatal("expected module setup to be called") + } + if setupRevision != "unknown" { + t.Fatalf("expected setup API revision to be unknown before initial datafile is set, got %s", setupRevision) + } + if !moduleSawDatafileSet { + t.Fatal("expected module diagnostic subscription to observe initial datafile_set") + } + + foundModuleDiagnostic := false + for _, diagnostic := range diagnostics { + if diagnostic.Code == "module_ready" && diagnostic.Module == "observer" { + foundModuleDiagnostic = true + break + } + } + if !foundModuleDiagnostic { + t.Fatal("expected module reported diagnostic") + } + + instance.AddModule(&FeaturevisorModule{Name: "observer"}) + + foundDuplicateDiagnostic := false + for _, diagnostic := range diagnostics { + if diagnostic.Code == "duplicate_module" && diagnostic.Level == LogLevelError { + foundDuplicateDiagnostic = true + break + } + } + + if !foundDuplicateDiagnostic { + t.Fatal("expected duplicate module diagnostic") + } + + instance.Close() + + if !closeCalled { + t.Fatal("expected module close to be called") + } +} + +func TestSetDatafileMergesByDefaultAndReplacesWhenRequested(t *testing.T) { + instance := CreateInstance(Options{ + Datafile: DatafileContent{ + SchemaVersion: "2", + Revision: "1", + Segments: map[SegmentKey]Segment{}, + Features: map[FeatureKey]Feature{ + "first": { + BucketBy: "userId", + Traffic: []Traffic{}, + }, + }, + }, + }) + + secondDatafile := DatafileContent{ + SchemaVersion: "2", + Revision: "2", + Segments: map[SegmentKey]Segment{}, + Features: map[FeatureKey]Feature{ + "second": { + BucketBy: "userId", + Traffic: []Traffic{}, + }, + }, + } + + instance.SetDatafile(secondDatafile) + + if instance.GetFeature("first") == nil { + t.Fatal("expected default SetDatafile to preserve existing features") + } + if instance.GetFeature("second") == nil { + t.Fatal("expected default SetDatafile to add incoming features") + } + + instance.SetDatafile(secondDatafile, true) + + if instance.GetFeature("first") != nil { + t.Fatal("expected replace SetDatafile to remove existing features") + } + if instance.GetFeature("second") == nil { + t.Fatal("expected replace SetDatafile to keep incoming features") + } +} + func TestGetAllEvaluations(t *testing.T) { jsonDatafile := `{ "schemaVersion": "2", diff --git a/modules.go b/modules.go new file mode 100644 index 0000000..36244d4 --- /dev/null +++ b/modules.go @@ -0,0 +1,167 @@ +package featurevisor + +// ConfigureBucketKeyOptions contains options for configuring bucket key +type ConfigureBucketKeyOptions struct { + FeatureKey FeatureKey `json:"featureKey"` + Context Context `json:"context"` + BucketBy BucketBy `json:"bucketBy"` + BucketKey string `json:"bucketKey"` // the initial bucket key, which can be modified by modules +} + +// ConfigureBucketKey is a function type for configuring bucket key +type ConfigureBucketKey func(options ConfigureBucketKeyOptions) BucketKey + +// ConfigureBucketValueOptions contains options for configuring bucket value +type ConfigureBucketValueOptions struct { + FeatureKey FeatureKey `json:"featureKey"` + BucketKey string `json:"bucketKey"` + Context Context `json:"context"` + BucketValue int `json:"bucketValue"` // the initial bucket value, which can be modified by modules +} + +// ConfigureBucketValue is a function type for configuring bucket value +type ConfigureBucketValue func(options ConfigureBucketValueOptions) BucketValue + +// FeaturevisorModuleApi is passed to modules during setup. +type FeaturevisorModuleApi struct { + GetRevision func() string + OnDiagnostic func(handler FeaturevisorDiagnosticHandler, options ...FeaturevisorModuleDiagnosticOptions) FeaturevisorUnsubscribe + ReportDiagnostic func(diagnostic FeaturevisorModuleReportedDiagnostic) +} + +// FeaturevisorModule represents a module that can participate in evaluation lifecycle. +type FeaturevisorModule struct { + Name string `json:"name,omitempty"` + + Setup func(api FeaturevisorModuleApi) `json:"setup,omitempty"` + Before func(options EvaluateOptions) EvaluateOptions `json:"before,omitempty"` + BucketKey ConfigureBucketKey `json:"bucketKey,omitempty"` + BucketValue ConfigureBucketValue `json:"bucketValue,omitempty"` + After func(evaluation Evaluation, options EvaluateOptions) Evaluation `json:"after,omitempty"` + Close func() `json:"close,omitempty"` +} + +// ModulesManagerOptions contains options for creating a modules manager. +type ModulesManagerOptions struct { + Modules []*FeaturevisorModule + ReportDiagnostic FeaturevisorDiagnosticReporter + GetModuleApi func(module *FeaturevisorModule) FeaturevisorModuleApi + ClearModuleDiagnosticSubscriptions func(module *FeaturevisorModule) +} + +// ModulesManager manages Featurevisor modules. +type ModulesManager struct { + modules []*FeaturevisorModule + reportDiagnostic FeaturevisorDiagnosticReporter + getModuleApi func(module *FeaturevisorModule) FeaturevisorModuleApi + clearModuleDiagnosticSubscriptions func(module *FeaturevisorModule) +} + +// NewModulesManager creates a new modules manager instance. +func NewModulesManager(options ModulesManagerOptions) *ModulesManager { + mm := &ModulesManager{ + modules: make([]*FeaturevisorModule, 0), + reportDiagnostic: options.ReportDiagnostic, + getModuleApi: options.GetModuleApi, + clearModuleDiagnosticSubscriptions: options.ClearModuleDiagnosticSubscriptions, + } + + if options.Modules != nil { + for _, module := range options.Modules { + mm.Add(module) + } + } + + return mm +} + +// Add adds a module to the modules manager. +func (mm *ModulesManager) Add(module *FeaturevisorModule) FeaturevisorUnsubscribe { + if module == nil { + return nil + } + + if module.Name != "" { + for _, existingModule := range mm.modules { + if existingModule.Name == module.Name { + mm.reportDiagnostic(FeaturevisorDiagnostic{ + Level: LogLevelError, + Code: "duplicate_module", + Message: "Duplicate module name", + ModuleName: module.Name, + }, nil) + return nil + } + } + } + + if module.Setup != nil && mm.getModuleApi != nil { + module.Setup(mm.getModuleApi(module)) + } + + mm.modules = append(mm.modules, module) + + return func() { + mm.modules = filterModules(mm.modules, func(existingModule *FeaturevisorModule) bool { + return existingModule != module + }) + if mm.clearModuleDiagnosticSubscriptions != nil { + mm.clearModuleDiagnosticSubscriptions(module) + } + } +} + +// Remove removes modules by name. +func (mm *ModulesManager) Remove(name string) { + removedModules := []*FeaturevisorModule{} + keptModules := []*FeaturevisorModule{} + + for _, module := range mm.modules { + if module.Name == name { + removedModules = append(removedModules, module) + } else { + keptModules = append(keptModules, module) + } + } + + mm.modules = keptModules + + if mm.clearModuleDiagnosticSubscriptions != nil { + for _, module := range removedModules { + mm.clearModuleDiagnosticSubscriptions(module) + } + } +} + +// GetAll returns all modules. +func (mm *ModulesManager) GetAll() []*FeaturevisorModule { + return mm.modules +} + +// CloseAll closes and removes all modules. +func (mm *ModulesManager) CloseAll() { + modules := append([]*FeaturevisorModule{}, mm.modules...) + mm.modules = []*FeaturevisorModule{} + + for _, module := range modules { + if mm.clearModuleDiagnosticSubscriptions != nil { + mm.clearModuleDiagnosticSubscriptions(module) + } + if module.Close != nil { + module.Close() + } + } +} + +func filterModules( + modules []*FeaturevisorModule, + keep func(module *FeaturevisorModule) bool, +) []*FeaturevisorModule { + result := []*FeaturevisorModule{} + for _, module := range modules { + if keep(module) { + result = append(result, module) + } + } + return result +} diff --git a/sdk_types.go b/sdk_types.go index d37156e..2b8e7ad 100644 --- a/sdk_types.go +++ b/sdk_types.go @@ -139,10 +139,11 @@ type Context map[string]interface{} */ // DatafileContent represents the content of a datafile type DatafileContent struct { - SchemaVersion string `json:"schemaVersion"` - Revision string `json:"revision"` - Segments map[SegmentKey]Segment `json:"segments"` - Features map[FeatureKey]Feature `json:"features"` + SchemaVersion string `json:"schemaVersion"` + Revision string `json:"revision"` + FeaturevisorVersion string `json:"featurevisorVersion,omitempty"` + Segments map[SegmentKey]Segment `json:"segments"` + Features map[FeatureKey]Feature `json:"features"` } // FromJSON parses a JSON string and returns a DatafileContent @@ -159,15 +160,6 @@ func (dc *DatafileContent) ToJSON() (string, error) { return string(bytes), nil } -// DatafileContentV1 represents the content of a v1 datafile -type DatafileContentV1 struct { - SchemaVersion string `json:"schemaVersion"` - Revision string `json:"revision"` - Attributes []Attribute `json:"attributes"` - Segments []Segment `json:"segments"` - Features []FeatureV1 `json:"features"` -} - /** * Feature */ @@ -189,20 +181,6 @@ type Feature struct { Ranges []Range `json:"ranges,omitempty"` } -// FeatureV1 represents a feature in v1 format -type FeatureV1 struct { - Key *FeatureKey `json:"key,omitempty"` - Hash *string `json:"hash,omitempty"` - Deprecated *bool `json:"deprecated,omitempty"` - Required []Required `json:"required,omitempty"` - BucketBy BucketBy `json:"bucketBy"` - Traffic []Traffic `json:"traffic"` - Force []Force `json:"force,omitempty"` - Ranges []Range `json:"ranges,omitempty"` - VariablesSchema []VariableSchema `json:"variablesSchema,omitempty"` - Variations []VariationV1 `json:"variations,omitempty"` -} - // ParsedFeature represents a parsed feature type ParsedFeature struct { Key FeatureKey `json:"key"` @@ -422,14 +400,6 @@ type VariableOverride struct { Segments interface{} `json:"segments,omitempty"` // GroupSegment | GroupSegment[] } -// VariableV1 represents a variable in v1 format -type VariableV1 struct { - Key VariableKey `json:"key"` - Value VariableValue `json:"value"` - Description *string `json:"description,omitempty"` - Overrides []VariableOverride `json:"overrides,omitempty"` -} - // VariableSchema represents the schema of a variable type VariableSchema struct { Deprecated *bool `json:"deprecated,omitempty"` @@ -463,14 +433,6 @@ type VariableSchema struct { // VariationValue represents the value of a variation type VariationValue = string -// VariationV1 represents a variation in v1 format -type VariationV1 struct { - Description *string `json:"description,omitempty"` - Value VariationValue `json:"value"` - Weight *Weight `json:"weight,omitempty"` - Variables []VariableV1 `json:"variables,omitempty"` -} - // Variation represents a variation type Variation struct { Description *string `json:"description,omitempty"` From e07da2d1285057303a2753b3c30ff6a3d0a62f7e Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 25 Jun 2026 21:29:30 +0200 Subject: [PATCH 02/13] updates --- README.md | 60 ++++++++++++++++++++++++++++++++++- cmd/commands/commands_test.go | 46 +++++++++++++++++++++++++++ cmd/commands/test.go | 38 ++++++++++++---------- emitter.go | 1 + emitter_test.go | 1 + instance.go | 2 +- 6 files changed, 130 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b9f5426..efbf8b9 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ See example application [here](https://github.com/featurevisor/featurevisor-exam - [Initialize with sticky](#initialize-with-sticky) - [Set sticky afterwards](#set-sticky-afterwards) - [Setting datafile](#setting-datafile) + - [Merging by default](#merging-by-default) + - [Replacing](#replacing) + - [Loading datafiles on demand](#loading-datafiles-on-demand) - [Updating datafile](#updating-datafile) - [Interval-based update](#interval-based-update) - [Logging](#logging) @@ -36,6 +39,7 @@ See example application [here](https://github.com/featurevisor/featurevisor-exam - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - [`sticky_set`](#sticky_set) + - [`error`](#error) - [Evaluation details](#evaluation-details) - [Modules](#modules) - [Defining a module](#defining-a-module) @@ -371,12 +375,57 @@ You may also initialize the SDK without passing `datafile`, and set it later on: f.SetDatafile(datafileContent) ``` -`SetDatafile` accepts either parsed `featurevisor.DatafileContent` or a raw JSON string. By default, it merges the incoming datafile into the SDK's stored datafile. Pass `true` as the second argument to replace the stored datafile instead: +`SetDatafile` accepts either parsed `featurevisor.DatafileContent` or a raw JSON string. + +### Merging by default + +By default, `SetDatafile(datafile)` merges the incoming datafile with the SDK instance's existing datafile: + +- incoming `Features` and `Segments` override matching keys +- existing `Features` and `Segments` that are missing from the incoming datafile are kept +- `Revision`, `SchemaVersion`, and `FeaturevisorVersion` are taken from the incoming datafile + +This means you can call `SetDatafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together. + +### Replacing + +Pass `true` as the second argument to replace the stored datafile entirely: ```go f.SetDatafile(datafileContent, true) // replace existing datafile ``` +### Loading datafiles on demand + +Because merging is the default, a single SDK instance can start with a small datafile and load more datafiles later as your application needs them, instead of downloading every feature upfront. + +This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application: + +```go +f := featurevisor.CreateInstance(featurevisor.Options{}) + +func loadDatafile(target string) { + url := fmt.Sprintf("https://cdn.yoursite.com/production/featurevisor-%s.json", target) + resp, err := http.Get(url) + if err != nil { + return + } + defer resp.Body.Close() + + datafileBytes, err := io.ReadAll(resp.Body) + if err != nil { + return + } + + f.SetDatafile(string(datafileBytes)) +} + +loadDatafile("products") + +// later, when the user reaches checkout +loadDatafile("checkout") +``` + ### Updating datafile You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile-set) event that you can listen and react to accordingly. @@ -567,6 +616,15 @@ unsubscribe := f.On(featurevisor.EventNameStickySet, func(details featurevisor.E }) ``` +### `error` + +```go +unsubscribe := f.On(featurevisor.EventNameError, func(details featurevisor.EventDetails) { + diagnostic := details["diagnostic"] + fmt.Println(diagnostic) +}) +``` + ## Evaluation details Besides logging with debug level enabled, you can also get more details about how the feature variations and variables are evaluated in the runtime against given context: diff --git a/cmd/commands/commands_test.go b/cmd/commands/commands_test.go index 7909c91..a3358c8 100644 --- a/cmd/commands/commands_test.go +++ b/cmd/commands/commands_test.go @@ -26,3 +26,49 @@ func TestTargetDatafileCacheKey(t *testing.T) { t.Fatalf("expected production-target-checkout, got %s", got) } } + +func TestDatafileCacheKeyForTargetAssertion(t *testing.T) { + cache := map[string]interface{}{ + "production": map[string]interface{}{"kind": "base"}, + "production-target-checkout": map[string]interface{}{"kind": "target"}, + } + + got := datafileCacheKeyForAssertion(map[string]interface{}{ + "environment": "production", + "target": "checkout", + }, cache) + + if got != "production-target-checkout" { + t.Fatalf("expected production-target-checkout, got %s", got) + } +} + +func TestDatafileCacheKeyForTargetAssertionFallsBackToBase(t *testing.T) { + cache := map[string]interface{}{ + "production": map[string]interface{}{"kind": "base"}, + } + + got := datafileCacheKeyForAssertion(map[string]interface{}{ + "environment": "production", + "target": "checkout", + }, cache) + + if got != "production" { + t.Fatalf("expected production, got %s", got) + } +} + +func TestDatafileCacheKeyForNoEnvironmentTargetAssertion(t *testing.T) { + cache := map[string]interface{}{ + noEnvironmentKey: map[string]interface{}{"kind": "base"}, + "false-target-checkout": map[string]interface{}{"kind": "target"}, + } + + got := datafileCacheKeyForAssertion(map[string]interface{}{ + "target": "checkout", + }, cache) + + if got != "false-target-checkout" { + t.Fatalf("expected false-target-checkout, got %s", got) + } +} diff --git a/cmd/commands/test.go b/cmd/commands/test.go index 2f6030b..2191f27 100644 --- a/cmd/commands/test.go +++ b/cmd/commands/test.go @@ -710,6 +710,27 @@ func targetDatafileCacheKey(environment *string, target string) string { return fmt.Sprintf("%s-target-%s", base, target) } +func datafileCacheKeyForAssertion(assertion map[string]interface{}, datafileCache map[string]interface{}) string { + var environment *string + if rawEnvironment, exists := assertion["environment"]; exists { + if env, ok := rawEnvironment.(string); ok { + envCopy := env + environment = &envCopy + } + } + + selectedDatafileKey := datafileCacheKey(environment) + + if targetValue, ok := assertion["target"].(string); ok && targetValue != "" { + targetKey := targetDatafileCacheKey(environment, targetValue) + if _, exists := datafileCache[targetKey]; exists { + selectedDatafileKey = targetKey + } + } + + return selectedDatafileKey +} + func buildDatafileCache( featurevisorProjectPath string, config map[string]interface{}, @@ -862,22 +883,7 @@ func runTest(opts CLIOptions) { var testResult AssertionResult if _, hasFeature := test["feature"]; hasFeature { - var environment *string - if rawEnvironment, exists := assertionMap["environment"]; exists { - if env, ok := rawEnvironment.(string); ok { - envCopy := env - environment = &envCopy - } - } - - selectedDatafileKey := datafileCacheKey(environment) - - if targetValue, ok := assertionMap["target"].(string); ok && targetValue != "" { - targetKey := targetDatafileCacheKey(environment, targetValue) - if _, exists := datafileCache[targetKey]; exists { - selectedDatafileKey = targetKey - } - } + selectedDatafileKey := datafileCacheKeyForAssertion(assertionMap, datafileCache) datafile, ok := datafileCache[selectedDatafileKey] if !ok { diff --git a/emitter.go b/emitter.go index 6604521..fb055ed 100644 --- a/emitter.go +++ b/emitter.go @@ -12,6 +12,7 @@ const ( EventNameDatafileSet EventName = "datafile_set" EventNameContextSet EventName = "context_set" EventNameStickySet EventName = "sticky_set" + EventNameError EventName = "error" ) // EventDetails represents additional details for events diff --git a/emitter_test.go b/emitter_test.go index 572045e..8c2a7e8 100644 --- a/emitter_test.go +++ b/emitter_test.go @@ -10,6 +10,7 @@ func TestEventNames(t *testing.T) { EventNameDatafileSet, EventNameContextSet, EventNameStickySet, + EventNameError, } for _, eventName := range eventNames { diff --git a/instance.go b/instance.go index 3aac8d9..eadf7a6 100644 --- a/instance.go +++ b/instance.go @@ -299,7 +299,7 @@ func (i *Featurevisor) reportDiagnostic( } if diagnostic.Level == LogLevelError { - i.emitter.Trigger("error", EventDetails{"diagnostic": diagnostic}) + i.emitter.Trigger(EventNameError, EventDetails{"diagnostic": diagnostic}) } } From d1444668707fea93d5a596aa1b00c03303f94635 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Fri, 26 Jun 2026 19:06:13 +0200 Subject: [PATCH 03/13] updates --- datafile_reader_test.go | 72 +++++++++++++++++++++++ emitter_test.go | 27 +++++++++ instance_test.go | 127 ++++++++++++++++++++++++++++++++++++++++ modules.go | 47 ++++++++++++++- 4 files changed, 270 insertions(+), 3 deletions(-) diff --git a/datafile_reader_test.go b/datafile_reader_test.go index f85e5c2..06d4cff 100644 --- a/datafile_reader_test.go +++ b/datafile_reader_test.go @@ -151,6 +151,30 @@ func TestDatafileReaderAllConditionsAreMatched(t *testing.T) { if !result { t.Error("Or condition should match when at least one sub-condition matches") } + + notCondition := NotCondition{ + Not: []Condition{ + PlainCondition{ + Attribute: "country", + Operator: OperatorEquals, + Value: func() *ConditionValue { v := ConditionValue("US"); return &v }(), + }, + PlainCondition{ + Attribute: "age", + Operator: OperatorGreaterThan, + Value: func() *ConditionValue { v := ConditionValue(30); return &v }(), + }, + }, + } + result = reader.AllConditionsAreMatched(notCondition, context) + if !result { + t.Error("NOT condition should match when not all direct children match") + } + + result = reader.AllConditionsAreMatched(NotCondition{Not: []Condition{}}, context) + if result { + t.Error("Empty NOT condition should defensively return false") + } } // TestDatafileReaderComprehensive tests comprehensive datafile reader functionality @@ -460,6 +484,54 @@ func TestDatafileReaderSegmentMatching(t *testing.T) { t.Error("German mobile users should not match") } }) + + t.Run("not segments negate implicit and", func(t *testing.T) { + segments := NotGroupSegment{ + Not: []GroupSegment{"mobileUsers", "netherlands"}, + } + + result := reader.AllSegmentsAreMatched(segments, Context{ + "country": "nl", + "deviceType": "mobile", + }) + if result { + t.Error("NOT segments should not match when all direct children match") + } + + result = reader.AllSegmentsAreMatched(segments, Context{ + "country": "nl", + "deviceType": "desktop", + }) + if !result { + t.Error("NOT segments should match when only some direct children match") + } + }) + + t.Run("not with nested or means none match", func(t *testing.T) { + segments := NotGroupSegment{ + Not: []GroupSegment{ + OrGroupSegment{ + Or: []GroupSegment{"mobileUsers", "desktopUsers"}, + }, + }, + } + + if reader.AllSegmentsAreMatched(segments, Context{"deviceType": "mobile"}) { + t.Error("Nested OR under NOT should not match mobile users") + } + + if !reader.AllSegmentsAreMatched(segments, Context{"deviceType": "tv"}) { + t.Error("Nested OR under NOT should match when none of the OR children match") + } + }) + + t.Run("empty not segments return false", func(t *testing.T) { + segments := NotGroupSegment{Not: []GroupSegment{}} + + if reader.AllSegmentsAreMatched(segments, Context{}) { + t.Error("Empty NOT segments should defensively return false") + } + }) } func TestDatafileReaderForceMatching(t *testing.T) { diff --git a/emitter_test.go b/emitter_test.go index 8c2a7e8..5b8cdfc 100644 --- a/emitter_test.go +++ b/emitter_test.go @@ -145,6 +145,33 @@ func TestEmitterMultipleListeners(t *testing.T) { } } +func TestEmitterTriggerUsesListenerSnapshot(t *testing.T) { + emitter := NewEmitter() + calls := []string{} + var unsubscribeSecond Unsubscribe + + emitter.On(EventNameStickySet, func(details EventDetails) { + calls = append(calls, "first") + unsubscribeSecond() + }) + unsubscribeSecond = emitter.On(EventNameStickySet, func(details EventDetails) { + calls = append(calls, "second") + }) + + emitter.Trigger(EventNameStickySet, nil) + emitter.Trigger(EventNameStickySet, nil) + + expected := []string{"first", "second", "first"} + if len(calls) != len(expected) { + t.Fatalf("expected calls %#v, got %#v", expected, calls) + } + for i, call := range calls { + if call != expected[i] { + t.Fatalf("expected calls %#v, got %#v", expected, calls) + } + } +} + func TestEmitterUnsubscribe(t *testing.T) { emitter := NewEmitter() diff --git a/instance_test.go b/instance_test.go index 3f5cb14..c45a179 100644 --- a/instance_test.go +++ b/instance_test.go @@ -490,6 +490,133 @@ func TestModulesSetupDiagnosticsAndClose(t *testing.T) { } } +func TestRemovedModulesAreClosed(t *testing.T) { + closed := []string{} + instance := CreateInstance(Options{}) + + unsubscribe := instance.AddModule(&FeaturevisorModule{ + Name: "dynamic", + Close: func() { + closed = append(closed, "dynamic") + }, + }) + + if unsubscribe == nil { + t.Fatal("expected add module to return unsubscribe") + } + + unsubscribe() + unsubscribe() + + instance.AddModule(&FeaturevisorModule{ + Name: "dynamic", + Close: func() { + closed = append(closed, "dynamic-again") + }, + }) + instance.RemoveModule("dynamic") + + if len(closed) != 2 || closed[0] != "dynamic" || closed[1] != "dynamic-again" { + t.Fatalf("expected removed modules to be closed once each, got %#v", closed) + } +} + +func TestModuleCloseErrorsAreReportedAndDoNotStopCleanup(t *testing.T) { + var diagnostics []FeaturevisorDiagnostic + var errorEvents []FeaturevisorDiagnostic + closed := []string{} + + instance := CreateInstance(Options{ + OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { + diagnostics = append(diagnostics, diagnostic) + }, + }) + + instance.On(EventNameError, func(details EventDetails) { + if diagnostic, ok := details["diagnostic"].(FeaturevisorDiagnostic); ok { + errorEvents = append(errorEvents, diagnostic) + } + }) + + instance.AddModule(&FeaturevisorModule{ + Name: "first", + Close: func() { + closed = append(closed, "first") + panic("first close failed") + }, + }) + instance.AddModule(&FeaturevisorModule{ + Name: "second", + Close: func() { + closed = append(closed, "second") + }, + }) + + instance.Close() + + if len(closed) != 2 || closed[0] != "first" || closed[1] != "second" { + t.Fatalf("expected close cleanup to continue after a module panics, got %#v", closed) + } + + foundDiagnostic := false + for _, diagnostic := range diagnostics { + if diagnostic.Code == "module_close_error" && + diagnostic.Level == LogLevelError && + diagnostic.ModuleName == "first" && + diagnostic.OriginalError != nil { + foundDiagnostic = true + break + } + } + if !foundDiagnostic { + t.Fatalf("expected module_close_error diagnostic, got %#v", diagnostics) + } + + foundErrorEvent := false + for _, diagnostic := range errorEvents { + if diagnostic.Code == "module_close_error" && diagnostic.ModuleName == "first" { + foundErrorEvent = true + break + } + } + if !foundErrorEvent { + t.Fatalf("expected module_close_error to trigger error event, got %#v", errorEvents) + } +} + +func TestModuleUnsubscribeReportsCloseErrors(t *testing.T) { + var diagnostics []FeaturevisorDiagnostic + instance := CreateInstance(Options{ + OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { + diagnostics = append(diagnostics, diagnostic) + }, + }) + + unsubscribe := instance.AddModule(&FeaturevisorModule{ + Name: "dynamic", + Close: func() { + panic("dynamic close failed") + }, + }) + if unsubscribe == nil { + t.Fatal("expected add module to return unsubscribe") + } + + unsubscribe() + unsubscribe() + + foundDiagnostic := false + for _, diagnostic := range diagnostics { + if diagnostic.Code == "module_close_error" && diagnostic.ModuleName == "dynamic" { + foundDiagnostic = true + break + } + } + if !foundDiagnostic { + t.Fatalf("expected module_close_error diagnostic from unsubscribe, got %#v", diagnostics) + } +} + func TestSetDatafileMergesByDefaultAndReplacesWhenRequested(t *testing.T) { instance := CreateInstance(Options{ Datafile: DatafileContent{ diff --git a/modules.go b/modules.go index 36244d4..7e7cb39 100644 --- a/modules.go +++ b/modules.go @@ -41,6 +41,14 @@ type FeaturevisorModule struct { Close func() `json:"close,omitempty"` } +func getModuleName(module *FeaturevisorModule) string { + if module == nil { + return "" + } + + return module.Name +} + // ModulesManagerOptions contains options for creating a modules manager. type ModulesManagerOptions struct { Modules []*FeaturevisorModule @@ -102,15 +110,46 @@ func (mm *ModulesManager) Add(module *FeaturevisorModule) FeaturevisorUnsubscrib mm.modules = append(mm.modules, module) return func() { + moduleExists := false + for _, existingModule := range mm.modules { + if existingModule == module { + moduleExists = true + break + } + } + mm.modules = filterModules(mm.modules, func(existingModule *FeaturevisorModule) bool { return existingModule != module }) if mm.clearModuleDiagnosticSubscriptions != nil { mm.clearModuleDiagnosticSubscriptions(module) } + if moduleExists { + mm.closeModule(module) + } } } +func (mm *ModulesManager) closeModule(module *FeaturevisorModule) { + if module == nil || module.Close == nil { + return + } + + defer func() { + if r := recover(); r != nil && mm.reportDiagnostic != nil { + mm.reportDiagnostic(FeaturevisorDiagnostic{ + Level: LogLevelError, + Code: "module_close_error", + Message: "Module close failed", + ModuleName: getModuleName(module), + OriginalError: r, + }, nil) + } + }() + + module.Close() +} + // Remove removes modules by name. func (mm *ModulesManager) Remove(name string) { removedModules := []*FeaturevisorModule{} @@ -131,6 +170,10 @@ func (mm *ModulesManager) Remove(name string) { mm.clearModuleDiagnosticSubscriptions(module) } } + + for _, module := range removedModules { + mm.closeModule(module) + } } // GetAll returns all modules. @@ -147,9 +190,7 @@ func (mm *ModulesManager) CloseAll() { if mm.clearModuleDiagnosticSubscriptions != nil { mm.clearModuleDiagnosticSubscriptions(module) } - if module.Close != nil { - module.Close() - } + mm.closeModule(module) } } From 8b2c15e978bd007dbffac91a7492a56074c53204 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 27 Jun 2026 23:27:22 +0200 Subject: [PATCH 04/13] updates --- child.go | 2 +- cmd/commands/test.go | 16 +--------- conditions_test.go | 4 +-- datafile_reader.go | 65 ++++++++++++++++++++++++++--------------- datafile_reader_test.go | 18 ++++++------ evaluate.go | 28 +++++++++--------- events.go | 4 +-- events_parity_test.go | 4 +-- instance.go | 8 ++--- 9 files changed, 76 insertions(+), 73 deletions(-) diff --git a/child.go b/child.go index dc4f8cf..9e95ff9 100644 --- a/child.go +++ b/child.go @@ -132,7 +132,7 @@ func (c *FeaturevisorChild) getEvaluationDependencies(context Context, options O Context: c.GetContext(context), Logger: c.parent.logger, ModulesManager: c.parent.modulesManager, - DatafileReader: c.parent.datafileReader, + datafileReader: c.parent.datafileReader, Sticky: sticky, DefaultVariationValue: options.DefaultVariationValue, DefaultVariableValue: options.DefaultVariableValue, diff --git a/cmd/commands/test.go b/cmd/commands/test.go index 2191f27..60e7066 100644 --- a/cmd/commands/test.go +++ b/cmd/commands/test.go @@ -355,26 +355,12 @@ func RunTestSegment(assertion map[string]interface{}, segment map[string]interfa conditions := segment["conditions"] - datafile := featurevisor.DatafileContent{ - SchemaVersion: "2", - Revision: "tester", - Features: make(map[featurevisor.FeatureKey]featurevisor.Feature), - Segments: make(map[featurevisor.SegmentKey]featurevisor.Segment), - } - - levelStr := featurevisor.LogLevel(level) - logger := featurevisor.NewLogger(featurevisor.CreateLoggerOptions{Level: &levelStr}) - datafileReader := featurevisor.NewDatafileReader(featurevisor.DatafileReaderOptions{ - Datafile: datafile, - Logger: logger, - }) - hasError := false errors := "" startTime := time.Now() if expectedToMatch, ok := assertion["expectedToMatch"].(bool); ok { - actual := datafileReader.AllConditionsAreMatched(conditions, context) + actual := featurevisor.AllConditionsAreMatched(conditions, context) if actual != expectedToMatch { hasError = true errors += fmt.Sprintf(" ✘ expectedToMatch: expected %v but received %v\n", expectedToMatch, actual) diff --git a/conditions_test.go b/conditions_test.go index ee52f00..082a3ea 100644 --- a/conditions_test.go +++ b/conditions_test.go @@ -328,7 +328,7 @@ func TestConditionIsMatchedComprehensive(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := NewDatafileReader(DatafileReaderOptions{ + reader := newDatafileReader(datafileReaderOptions{ Datafile: datafile, Logger: logger, }) @@ -868,7 +868,7 @@ func TestConditionIsMatchedComplexNested(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := NewDatafileReader(DatafileReaderOptions{ + reader := newDatafileReader(datafileReaderOptions{ Datafile: datafile, Logger: logger, }) diff --git a/datafile_reader.go b/datafile_reader.go index adac297..df26311 100644 --- a/datafile_reader.go +++ b/datafile_reader.go @@ -7,8 +7,8 @@ import ( "strings" ) -// DatafileReaderOptions contains options for creating a datafile reader -type DatafileReaderOptions struct { +// datafileReaderOptions contains options for creating a datafile reader +type datafileReaderOptions struct { Datafile DatafileContent Logger *Logger } @@ -19,8 +19,8 @@ type ForceResult struct { ForceIndex *int `json:"forceIndex,omitempty"` } -// DatafileReader provides functionality to read and query datafile content -type DatafileReader struct { +// datafileReader provides functionality to read and query datafile content +type datafileReader struct { schemaVersion string revision string segments map[SegmentKey]Segment @@ -29,9 +29,9 @@ type DatafileReader struct { regexCache map[string]*regexp.Regexp } -// NewDatafileReader creates a new datafile reader instance -func NewDatafileReader(options DatafileReaderOptions) *DatafileReader { - return &DatafileReader{ +// newDatafileReader creates a new datafile reader instance +func newDatafileReader(options datafileReaderOptions) *datafileReader { + return &datafileReader{ schemaVersion: options.Datafile.SchemaVersion, revision: options.Datafile.Revision, segments: options.Datafile.Segments, @@ -41,18 +41,35 @@ func NewDatafileReader(options DatafileReaderOptions) *DatafileReader { } } +// AllConditionsAreMatched checks whether a condition tree matches the given context. +// It mirrors the JavaScript SDK's narrow root helper export without exposing the +// internal datafile reader implementation. +func AllConditionsAreMatched(conditions Condition, context Context) bool { + reader := newDatafileReader(datafileReaderOptions{ + Datafile: DatafileContent{ + SchemaVersion: "2", + Revision: "matcher", + Segments: make(map[SegmentKey]Segment), + Features: make(map[FeatureKey]Feature), + }, + Logger: NewLogger(CreateLoggerOptions{}), + }) + + return reader.AllConditionsAreMatched(conditions, context) +} + // GetRevision returns the revision of the datafile -func (d *DatafileReader) GetRevision() string { +func (d *datafileReader) GetRevision() string { return d.revision } // GetSchemaVersion returns the schema version of the datafile -func (d *DatafileReader) GetSchemaVersion() string { +func (d *datafileReader) GetSchemaVersion() string { return d.schemaVersion } // GetSegment returns a segment by its key -func (d *DatafileReader) GetSegment(segmentKey SegmentKey) *Segment { +func (d *datafileReader) GetSegment(segmentKey SegmentKey) *Segment { segment, exists := d.segments[segmentKey] if !exists { @@ -65,7 +82,7 @@ func (d *DatafileReader) GetSegment(segmentKey SegmentKey) *Segment { } // GetFeatureKeys returns all feature keys -func (d *DatafileReader) GetFeatureKeys() []string { +func (d *datafileReader) GetFeatureKeys() []string { keys := make([]string, 0, len(d.features)) for key := range d.features { keys = append(keys, string(key)) @@ -74,7 +91,7 @@ func (d *DatafileReader) GetFeatureKeys() []string { } // GetFeature returns a feature by its key -func (d *DatafileReader) GetFeature(featureKey FeatureKey) *Feature { +func (d *datafileReader) GetFeature(featureKey FeatureKey) *Feature { feature, exists := d.features[featureKey] if !exists { return nil @@ -89,7 +106,7 @@ func (d *DatafileReader) GetFeature(featureKey FeatureKey) *Feature { } // GetVariableKeys returns the variable keys for a feature -func (d *DatafileReader) GetVariableKeys(featureKey FeatureKey) []string { +func (d *datafileReader) GetVariableKeys(featureKey FeatureKey) []string { feature := d.GetFeature(featureKey) if feature == nil || feature.VariablesSchema == nil { @@ -104,7 +121,7 @@ func (d *DatafileReader) GetVariableKeys(featureKey FeatureKey) []string { } // HasVariations checks if a feature has variations -func (d *DatafileReader) HasVariations(featureKey FeatureKey) bool { +func (d *datafileReader) HasVariations(featureKey FeatureKey) bool { feature := d.GetFeature(featureKey) if feature == nil { @@ -115,7 +132,7 @@ func (d *DatafileReader) HasVariations(featureKey FeatureKey) bool { } // GetRegex returns a regex pattern with caching -func (d *DatafileReader) GetRegex(regexString string, regexFlags string) *regexp.Regexp { +func (d *datafileReader) GetRegex(regexString string, regexFlags string) *regexp.Regexp { flags := regexFlags if flags == "" { flags = "" @@ -134,7 +151,7 @@ func (d *DatafileReader) GetRegex(regexString string, regexFlags string) *regexp } // AllConditionsAreMatched checks if all conditions are matched given a context -func (d *DatafileReader) AllConditionsAreMatched(conditions Condition, context Context) bool { +func (d *datafileReader) AllConditionsAreMatched(conditions Condition, context Context) bool { // Add error handling wrapper like in TypeScript version defer func() { if r := recover(); r != nil { @@ -276,12 +293,12 @@ func (d *DatafileReader) AllConditionsAreMatched(conditions Condition, context C } // SegmentIsMatched checks if a segment is matched given a context -func (d *DatafileReader) SegmentIsMatched(segment *Segment, context Context) bool { +func (d *datafileReader) SegmentIsMatched(segment *Segment, context Context) bool { return d.AllConditionsAreMatched(segment.Conditions, context) } // AllSegmentsAreMatched checks if all segments are matched given a context -func (d *DatafileReader) AllSegmentsAreMatched(groupSegments interface{}, context Context) bool { +func (d *datafileReader) AllSegmentsAreMatched(groupSegments interface{}, context Context) bool { // Add error handling wrapper like in TypeScript version defer func() { if r := recover(); r != nil { @@ -397,7 +414,7 @@ func (d *DatafileReader) AllSegmentsAreMatched(groupSegments interface{}, contex } // GetMatchedTraffic returns the matched traffic for a given context -func (d *DatafileReader) GetMatchedTraffic(traffic []Traffic, context Context) *Traffic { +func (d *datafileReader) GetMatchedTraffic(traffic []Traffic, context Context) *Traffic { for _, t := range traffic { segments := d.parseSegmentsIfStringified(t.Segments) if d.AllSegmentsAreMatched(segments, context) { @@ -412,7 +429,7 @@ func (d *DatafileReader) GetMatchedTraffic(traffic []Traffic, context Context) * } // GetMatchedAllocation returns the matched allocation for a given bucket value -func (d *DatafileReader) GetMatchedAllocation(traffic *Traffic, bucketValue int) *Allocation { +func (d *datafileReader) GetMatchedAllocation(traffic *Traffic, bucketValue int) *Allocation { if traffic.Allocation == nil { return nil } @@ -430,7 +447,7 @@ func (d *DatafileReader) GetMatchedAllocation(traffic *Traffic, bucketValue int) } // GetMatchedForce returns the matched force for a given feature and context -func (d *DatafileReader) GetMatchedForce(featureKey interface{}, context Context) ForceResult { +func (d *datafileReader) GetMatchedForce(featureKey interface{}, context Context) ForceResult { result := ForceResult{ Force: nil, ForceIndex: nil, @@ -477,7 +494,7 @@ func (d *DatafileReader) GetMatchedForce(featureKey interface{}, context Context } // parseConditionsIfStringified parses conditions if they are stringified -func (d *DatafileReader) parseConditionsIfStringified(conditions Condition) Condition { +func (d *datafileReader) parseConditionsIfStringified(conditions Condition) Condition { if conditionStr, ok := conditions.(string); ok { if conditionStr == "*" { return conditions @@ -500,7 +517,7 @@ func (d *DatafileReader) parseConditionsIfStringified(conditions Condition) Cond } // parseSegmentsIfStringified parses segments if they are stringified -func (d *DatafileReader) parseSegmentsIfStringified(segments interface{}) interface{} { +func (d *datafileReader) parseSegmentsIfStringified(segments interface{}) interface{} { if segmentStr, ok := segments.(string); ok { if strings.HasPrefix(segmentStr, "{") || strings.HasPrefix(segmentStr, "[") { var parsedSegments interface{} @@ -520,7 +537,7 @@ func (d *DatafileReader) parseSegmentsIfStringified(segments interface{}) interf } // parseRequiredIfStringified parses required features if they are stringified -func (d *DatafileReader) parseRequiredIfStringified(required []Required) []Required { +func (d *datafileReader) parseRequiredIfStringified(required []Required) []Required { parsedRequired := make([]Required, len(required)) for i, req := range required { diff --git a/datafile_reader_test.go b/datafile_reader_test.go index 06d4cff..0deaace 100644 --- a/datafile_reader_test.go +++ b/datafile_reader_test.go @@ -18,13 +18,13 @@ func TestNewDatafileReader(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := NewDatafileReader(DatafileReaderOptions{ + reader := newDatafileReader(datafileReaderOptions{ Datafile: datafile, Logger: logger, }) if reader == nil { - t.Error("NewDatafileReader should return a non-nil reader") + t.Error("newDatafileReader should return a non-nil reader") } if reader.GetRevision() != "test-revision" { @@ -50,7 +50,7 @@ func TestDatafileReaderGetRegex(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := NewDatafileReader(DatafileReaderOptions{ + reader := newDatafileReader(datafileReaderOptions{ Datafile: datafile, Logger: logger, }) @@ -84,7 +84,7 @@ func TestDatafileReaderAllConditionsAreMatched(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := NewDatafileReader(DatafileReaderOptions{ + reader := newDatafileReader(datafileReaderOptions{ Datafile: datafile, Logger: logger, }) @@ -236,7 +236,7 @@ func TestDatafileReaderComprehensive(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := NewDatafileReader(DatafileReaderOptions{ + reader := newDatafileReader(datafileReaderOptions{ Datafile: datafile, Logger: logger, }) @@ -391,7 +391,7 @@ func TestDatafileReaderSegmentMatching(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := NewDatafileReader(DatafileReaderOptions{ + reader := newDatafileReader(datafileReaderOptions{ Datafile: datafile, Logger: logger, }) @@ -583,7 +583,7 @@ func TestDatafileReaderForceMatching(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := NewDatafileReader(DatafileReaderOptions{ + reader := newDatafileReader(datafileReaderOptions{ Datafile: datafile, Logger: logger, }) @@ -650,7 +650,7 @@ func TestDatafileReaderStringifiedParsing(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := NewDatafileReader(DatafileReaderOptions{ + reader := newDatafileReader(datafileReaderOptions{ Datafile: datafile, Logger: logger, }) @@ -736,7 +736,7 @@ func TestDatafileReaderErrorHandling(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - reader := NewDatafileReader(DatafileReaderOptions{ + reader := newDatafileReader(datafileReaderOptions{ Datafile: datafile, Logger: logger, }) diff --git a/evaluate.go b/evaluate.go index 218366b..3be8c58 100644 --- a/evaluate.go +++ b/evaluate.go @@ -17,7 +17,7 @@ type EvaluateDependencies struct { Context Context Logger *Logger ModulesManager *ModulesManager - DatafileReader *DatafileReader + datafileReader *datafileReader // OverrideOptions Sticky *StickyFeatures @@ -114,7 +114,7 @@ func Evaluate(options EvaluateOptions) Evaluation { }() // feature not found - feature := options.DatafileReader.GetFeature(options.FeatureKey) + feature := options.datafileReader.GetFeature(options.FeatureKey) if feature == nil { evaluation = Evaluation{ Type: options.Type, @@ -321,7 +321,7 @@ func Evaluate(options EvaluateOptions) Evaluation { /** * Forced */ - forceResult := options.DatafileReader.GetMatchedForce(feature, options.Context) + forceResult := options.datafileReader.GetMatchedForce(feature, options.Context) if forceResult.Force != nil { force := forceResult.Force @@ -503,13 +503,13 @@ func Evaluate(options EvaluateOptions) Evaluation { var matchedAllocation *Allocation if options.Type != EvaluationTypeFlag { - matchedTraffic = options.DatafileReader.GetMatchedTraffic(feature.Traffic, options.Context) + matchedTraffic = options.datafileReader.GetMatchedTraffic(feature.Traffic, options.Context) if matchedTraffic != nil { - matchedAllocation = options.DatafileReader.GetMatchedAllocation(matchedTraffic, bucketValue) + matchedAllocation = options.datafileReader.GetMatchedAllocation(matchedTraffic, bucketValue) } } else { - matchedTraffic = options.DatafileReader.GetMatchedTraffic(feature.Traffic, options.Context) + matchedTraffic = options.datafileReader.GetMatchedTraffic(feature.Traffic, options.Context) } if matchedTraffic != nil { @@ -785,11 +785,11 @@ func Evaluate(options EvaluateOptions) Evaluation { matched := false if override.Conditions != nil { - parsedConditions := options.DatafileReader.parseConditionsIfStringified(override.Conditions) - matched = options.DatafileReader.AllConditionsAreMatched(parsedConditions, options.Context) + parsedConditions := options.datafileReader.parseConditionsIfStringified(override.Conditions) + matched = options.datafileReader.AllConditionsAreMatched(parsedConditions, options.Context) } else if override.Segments != nil { - parsedSegments := options.DatafileReader.parseSegmentsIfStringified(override.Segments) - matched = options.DatafileReader.AllSegmentsAreMatched(parsedSegments, options.Context) + parsedSegments := options.datafileReader.parseSegmentsIfStringified(override.Segments) + matched = options.datafileReader.AllSegmentsAreMatched(parsedSegments, options.Context) } if matched { @@ -862,12 +862,12 @@ func Evaluate(options EvaluateOptions) Evaluation { matched := false if override.Conditions != nil { - parsedConditions := options.DatafileReader.parseConditionsIfStringified(override.Conditions) - matched = options.DatafileReader.AllConditionsAreMatched(parsedConditions, options.Context) + parsedConditions := options.datafileReader.parseConditionsIfStringified(override.Conditions) + matched = options.datafileReader.AllConditionsAreMatched(parsedConditions, options.Context) } else if override.Segments != nil { // Parse segments if they come from JSON unmarshaling - parsedSegments := options.DatafileReader.parseSegmentsIfStringified(override.Segments) - matched = options.DatafileReader.AllSegmentsAreMatched(parsedSegments, options.Context) + parsedSegments := options.datafileReader.parseSegmentsIfStringified(override.Segments) + matched = options.datafileReader.AllSegmentsAreMatched(parsedSegments, options.Context) } if matched { diff --git a/events.go b/events.go index a612328..a5114fb 100644 --- a/events.go +++ b/events.go @@ -2,8 +2,8 @@ package featurevisor // getParamsForDatafileSetEvent gets parameters for datafile set event func getParamsForDatafileSetEvent( - previousDatafileReader *DatafileReader, - newDatafileReader *DatafileReader, + previousDatafileReader *datafileReader, + newDatafileReader *datafileReader, replace bool, ) LogDetails { previousRevision := "" diff --git a/events_parity_test.go b/events_parity_test.go index 3c79471..fcf67c0 100644 --- a/events_parity_test.go +++ b/events_parity_test.go @@ -5,7 +5,7 @@ import "testing" func TestGetParamsForDatafileSetEventShape(t *testing.T) { logger := NewLogger(CreateLoggerOptions{}) - previousReader := NewDatafileReader(DatafileReaderOptions{ + previousReader := newDatafileReader(datafileReaderOptions{ Logger: logger, Datafile: DatafileContent{ SchemaVersion: "2", @@ -16,7 +16,7 @@ func TestGetParamsForDatafileSetEventShape(t *testing.T) { }, }, }) - newReader := NewDatafileReader(DatafileReaderOptions{ + newReader := newDatafileReader(datafileReaderOptions{ Logger: logger, Datafile: DatafileContent{ SchemaVersion: "2", diff --git a/instance.go b/instance.go index eadf7a6..42b71e7 100644 --- a/instance.go +++ b/instance.go @@ -42,7 +42,7 @@ type Featurevisor struct { // internally created datafile DatafileContent - datafileReader *DatafileReader + datafileReader *datafileReader modulesManager *ModulesManager moduleDiagnosticSubscriptions []moduleDiagnosticSubscription nextModuleDiagnosticID int @@ -80,7 +80,7 @@ func NewFeaturevisor(options Options) *Featurevisor { Features: make(map[FeatureKey]Feature), } - datafileReader := NewDatafileReader(DatafileReaderOptions{ + datafileReader := newDatafileReader(datafileReaderOptions{ Datafile: emptyDatafile, Logger: logger, }) @@ -149,7 +149,7 @@ func (i *Featurevisor) SetDatafile(datafile interface{}, replace ...bool) { storedDatafile = mergeStoredDatafile(i.datafile, datafileContent) } - newDatafileReader := NewDatafileReader(DatafileReaderOptions{ + newDatafileReader := newDatafileReader(datafileReaderOptions{ Datafile: storedDatafile, Logger: i.logger, }) @@ -455,7 +455,7 @@ func (i *Featurevisor) getEvaluationDependencies(context Context, options Overri Context: i.GetContext(context), Logger: i.logger, ModulesManager: i.modulesManager, - DatafileReader: i.datafileReader, + datafileReader: i.datafileReader, Sticky: sticky, DefaultVariationValue: options.DefaultVariationValue, DefaultVariableValue: options.DefaultVariableValue, From e9f3fd5b591dbf1f454827804ffd92bcc31015cc Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Tue, 7 Jul 2026 23:17:01 +0200 Subject: [PATCH 05/13] benchmark --- cmd/commands/benchmark.go | 92 +++++++++++++++++++++------------------ 1 file changed, 50 insertions(+), 42 deletions(-) diff --git a/cmd/commands/benchmark.go b/cmd/commands/benchmark.go index d339b53..18390b6 100644 --- a/cmd/commands/benchmark.go +++ b/cmd/commands/benchmark.go @@ -11,32 +11,54 @@ import ( // BenchmarkOutput represents the result of a benchmark operation type BenchmarkOutput struct { - Value interface{} - Duration time.Duration + Value interface{} + Duration time.Duration + MinDuration time.Duration + AverageDuration time.Duration + MaxDuration time.Duration } -// benchmarkFeatureFlag benchmarks the feature flag evaluation -func benchmarkFeatureFlag( - instance *featurevisor.Featurevisor, - featureKey string, - context featurevisor.Context, - n int, -) BenchmarkOutput { - start := time.Now() +func benchmarkEvaluation(n int, evaluate func() interface{}) BenchmarkOutput { var value interface{} + var totalDuration time.Duration + var minDuration time.Duration + var maxDuration time.Duration for i := 0; i < n; i++ { - value = instance.IsEnabled(featureKey, context, featurevisor.OverrideOptions{}) - } + start := time.Now() + value = evaluate() + duration := time.Since(start) - duration := time.Since(start) + totalDuration += duration + if i == 0 || duration < minDuration { + minDuration = duration + } + if duration > maxDuration { + maxDuration = duration + } + } return BenchmarkOutput{ - Value: value, - Duration: duration, + Value: value, + Duration: totalDuration, + MinDuration: minDuration, + AverageDuration: totalDuration / time.Duration(n), + MaxDuration: maxDuration, } } +// benchmarkFeatureFlag benchmarks the feature flag evaluation +func benchmarkFeatureFlag( + instance *featurevisor.Featurevisor, + featureKey string, + context featurevisor.Context, + n int, +) BenchmarkOutput { + return benchmarkEvaluation(n, func() interface{} { + return instance.IsEnabled(featureKey, context, featurevisor.OverrideOptions{}) + }) +} + // benchmarkFeatureVariation benchmarks the feature variation evaluation func benchmarkFeatureVariation( instance *featurevisor.Featurevisor, @@ -44,19 +66,9 @@ func benchmarkFeatureVariation( context featurevisor.Context, n int, ) BenchmarkOutput { - start := time.Now() - var value interface{} - - for i := 0; i < n; i++ { - value = instance.GetVariation(featureKey, context, featurevisor.OverrideOptions{}) - } - - duration := time.Since(start) - - return BenchmarkOutput{ - Value: value, - Duration: duration, - } + return benchmarkEvaluation(n, func() interface{} { + return instance.GetVariation(featureKey, context, featurevisor.OverrideOptions{}) + }) } // benchmarkFeatureVariable benchmarks the feature variable evaluation @@ -67,19 +79,13 @@ func benchmarkFeatureVariable( context featurevisor.Context, n int, ) BenchmarkOutput { - start := time.Now() - var value interface{} - - for i := 0; i < n; i++ { - value = instance.GetVariable(featureKey, variableKey, context, featurevisor.OverrideOptions{}) - } - - duration := time.Since(start) + return benchmarkEvaluation(n, func() interface{} { + return instance.GetVariable(featureKey, variableKey, context, featurevisor.OverrideOptions{}) + }) +} - return BenchmarkOutput{ - Value: value, - Duration: duration, - } +func formatDurationMs(duration time.Duration) string { + return fmt.Sprintf("%.6fms", float64(duration.Nanoseconds())/1_000_000.0) } // prettyDuration formats duration in a human-readable format matching TypeScript implementation @@ -155,7 +161,7 @@ func runBenchmark(opts CLIOptions) { fmt.Printf("Building datafile containing all features for \"%s\"...\n", opts.Environment) datafileBuildStart := time.Now() - datafilesByEnvironment := buildDatafiles(featurevisorProjectPath, []string{opts.Environment}, 0) + datafilesByEnvironment := buildDatafiles(featurevisorProjectPath, []string{opts.Environment}, opts.Inflate) datafileBuildDuration := time.Since(datafileBuildStart) // Convert to milliseconds to match TypeScript behavior datafileBuildDurationMs := datafileBuildDuration.Milliseconds() @@ -218,5 +224,7 @@ func runBenchmark(opts CLIOptions) { fmt.Printf("Evaluated value : %s\n", valueOutput) fmt.Printf("Total duration : %s\n", prettyDuration(output.Duration)) - fmt.Printf("Average duration: %s\n", prettyDuration(output.Duration/time.Duration(opts.N))) + fmt.Printf("Minimum duration: %s\n", formatDurationMs(output.MinDuration)) + fmt.Printf("Average duration: %s\n", formatDurationMs(output.AverageDuration)) + fmt.Printf("Maximum duration: %s\n", formatDurationMs(output.MaxDuration)) } From d9707aede15789b3f1f295236d34d33b127f30c4 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 9 Jul 2026 21:52:06 +0200 Subject: [PATCH 06/13] updates --- instance.go | 15 ++++++++++++--- instance_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/instance.go b/instance.go index 42b71e7..5118b9d 100644 --- a/instance.go +++ b/instance.go @@ -382,11 +382,20 @@ func (i *Featurevisor) SetContext(context Context, replace ...bool) { "replaced": replaceValue, }) + message := "Context updated" if replaceValue { - i.logger.Debug("context replaced", LogDetails{"context": i.context}) - } else { - i.logger.Debug("context updated", LogDetails{"context": i.context}) + message = "Context replaced" } + + i.reportDiagnostic(FeaturevisorDiagnostic{ + Level: LogLevelDebug, + Code: "context_set", + Message: message, + Details: LogDetails{ + "context": i.context, + "replaced": replaceValue, + }, + }, nil) } // GetContext returns the context diff --git a/instance_test.go b/instance_test.go index c45a179..fcd30f5 100644 --- a/instance_test.go +++ b/instance_test.go @@ -896,3 +896,34 @@ func TestGetAllEvaluations(t *testing.T) { t.Error("Expected 'nonExistent' feature to be disabled") } } + +func TestLifecycleMutationsReportDiagnostics(t *testing.T) { + logLevel := LogLevelDebug + diagnostics := []FeaturevisorDiagnostic{} + instance := CreateInstance(Options{ + LogLevel: &logLevel, + OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { + diagnostics = append(diagnostics, diagnostic) + }, + }) + + instance.SetDatafile(DatafileContent{ + SchemaVersion: "2", + Revision: "1", + Segments: map[SegmentKey]Segment{}, + Features: map[FeatureKey]Feature{}, + }) + instance.SetSticky(StickyFeatures{"test": EvaluatedFeature{Enabled: true}}) + instance.SetContext(Context{"country": "nl"}) + + codes := map[string]bool{} + for _, diagnostic := range diagnostics { + codes[diagnostic.Code] = true + } + + for _, code := range []string{"datafile_set", "sticky_set", "context_set"} { + if !codes[code] { + t.Fatalf("expected %s diagnostic, got %#v", code, diagnostics) + } + } +} From 296ae712632cc9c9a07a773961dc35fab8717d9f Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 9 Jul 2026 22:58:46 +0200 Subject: [PATCH 07/13] diagnostics --- README.md | 4 ++++ child.go | 18 +++--------------- evaluate.go | 8 ++++---- instance.go | 33 +++++++++++++++------------------ 4 files changed, 26 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index efbf8b9..54631c0 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,8 @@ This is handy especially when you want to pass all evaluations from a backend ap For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/): +Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; create a child with `SpawnOptions{Sticky: ...}` when a child needs its own sticky state. + ### Initialize with sticky ```go @@ -561,6 +563,8 @@ f := featurevisor.CreateInstance(featurevisor.Options{ Modules can also subscribe to diagnostics or report their own from `Setup` via the provided module API. +Every diagnostic has `Level`, `Code`, `Message`, and an object-shaped `Details` map. Optional `Module`, `ModuleName`, and `OriginalError` fields describe provenance; evaluation metadata belongs in `Details`. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. diff --git a/child.go b/child.go index 9e95ff9..9635a8a 100644 --- a/child.go +++ b/child.go @@ -110,20 +110,8 @@ func (c *FeaturevisorChild) SetSticky(sticky StickyFeatures, replace ...bool) { // getEvaluationDependencies gets evaluation dependencies func (c *FeaturevisorChild) getEvaluationDependencies(context Context, options OverrideOptions) EvaluateDependencies { var sticky *StickyFeatures - if options.Sticky != nil { - if c.sticky != nil { - // Merge sticky features - mergedSticky := StickyFeatures{} - for key, value := range *c.sticky { - mergedSticky[key] = value - } - for key, value := range *options.Sticky { - mergedSticky[key] = value - } - sticky = &mergedSticky - } else { - sticky = options.Sticky - } + if options.sticky != nil { + sticky = options.sticky } else { sticky = c.sticky } @@ -133,7 +121,7 @@ func (c *FeaturevisorChild) getEvaluationDependencies(context Context, options O Logger: c.parent.logger, ModulesManager: c.parent.modulesManager, datafileReader: c.parent.datafileReader, - Sticky: sticky, + sticky: sticky, DefaultVariationValue: options.DefaultVariationValue, DefaultVariableValue: options.DefaultVariableValue, } diff --git a/evaluate.go b/evaluate.go index 3be8c58..28ff34b 100644 --- a/evaluate.go +++ b/evaluate.go @@ -19,8 +19,8 @@ type EvaluateDependencies struct { ModulesManager *ModulesManager datafileReader *datafileReader - // OverrideOptions - Sticky *StickyFeatures + // Instance-internal sticky state. Consumers configure it on an instance. + sticky *StickyFeatures DefaultVariationValue *VariationValue DefaultVariableValue VariableValue @@ -139,8 +139,8 @@ func Evaluate(options EvaluateOptions) Evaluation { /** * Sticky */ - if options.Sticky != nil { - if stickyFeature, exists := (*options.Sticky)[options.FeatureKey]; exists { + if options.sticky != nil { + if stickyFeature, exists := (*options.sticky)[options.FeatureKey]; exists { // flag if options.Type == EvaluationTypeFlag && stickyFeature.Enabled { evaluation = Evaluation{ diff --git a/instance.go b/instance.go index 5118b9d..1c4ab81 100644 --- a/instance.go +++ b/instance.go @@ -7,12 +7,17 @@ import ( // OverrideOptions contains options for overriding evaluation type OverrideOptions struct { - Sticky *StickyFeatures + sticky *StickyFeatures DefaultVariationValue *VariationValue DefaultVariableValue VariableValue } +// SpawnOptions configures a child SDK instance. +type SpawnOptions struct { + Sticky *StickyFeatures +} + // Options contains options for creating an instance type Options struct { Datafile interface{} // DatafileContent | string @@ -262,6 +267,10 @@ func (i *Featurevisor) reportDiagnostic( diagnostic FeaturevisorDiagnostic, sourceModule *FeaturevisorModule, ) { + if diagnostic.Details == nil { + diagnostic.Details = map[string]interface{}{} + } + for _, subscription := range append([]moduleDiagnosticSubscription{}, i.moduleDiagnosticSubscriptions...) { if subscription.module == sourceModule { continue @@ -420,14 +429,14 @@ func (i *Featurevisor) GetContext(context Context) Context { func (i *Featurevisor) Spawn(args ...interface{}) *FeaturevisorChild { // Default values contextValue := Context{} - optionsValue := OverrideOptions{} + optionsValue := SpawnOptions{} // Parse variadic arguments for _, arg := range args { switch v := arg.(type) { case Context: contextValue = v - case OverrideOptions: + case SpawnOptions: optionsValue = v } } @@ -442,20 +451,8 @@ func (i *Featurevisor) Spawn(args ...interface{}) *FeaturevisorChild { // getEvaluationDependencies gets evaluation dependencies func (i *Featurevisor) getEvaluationDependencies(context Context, options OverrideOptions) EvaluateDependencies { var sticky *StickyFeatures - if options.Sticky != nil { - if i.sticky != nil { - // Merge sticky features - mergedSticky := StickyFeatures{} - for key, value := range *i.sticky { - mergedSticky[key] = value - } - for key, value := range *options.Sticky { - mergedSticky[key] = value - } - sticky = &mergedSticky - } else { - sticky = options.Sticky - } + if options.sticky != nil { + sticky = options.sticky } else { sticky = i.sticky } @@ -465,7 +462,7 @@ func (i *Featurevisor) getEvaluationDependencies(context Context, options Overri Logger: i.logger, ModulesManager: i.modulesManager, datafileReader: i.datafileReader, - Sticky: sticky, + sticky: sticky, DefaultVariationValue: options.DefaultVariationValue, DefaultVariableValue: options.DefaultVariableValue, } From 36bb32b96642c170b4659718dffda8c42057ba73 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 11 Jul 2026 18:45:17 +0200 Subject: [PATCH 08/13] improvements --- Makefile | 2 +- README.md | 24 ++++-- child_parity_test.go | 2 +- cmd/commands/assess_distribution.go | 2 +- cmd/commands/benchmark.go | 2 +- cmd/commands/test.go | 2 +- conformance/sdk-v3.json | 49 +++++++++++ conformance_test.go | 63 ++++++++++++++ generic_variable_methods_test.go | 2 +- helpers.go | 23 +++-- helpers_parity_test.go | 16 +++- instance.go | 23 +++-- instance_api_parity_test.go | 4 +- instance_deprecated_features_test.go | 2 +- instance_individually_named_segments_test.go | 2 +- instance_mutually_exclusive_test.go | 2 +- instance_overridden_flags_test.go | 2 +- instance_required_features_test.go | 8 +- instance_rule_variable_overrides_test.go | 4 +- instance_sticky_features_test.go | 4 +- instance_test.go | 91 ++++++++++++++++---- instance_variables_no_variations_test.go | 2 +- instance_variables_test.go | 2 +- instance_variations_test.go | 2 +- modules.go | 24 +++++- 25 files changed, 290 insertions(+), 69 deletions(-) create mode 100644 conformance/sdk-v3.json create mode 100644 conformance_test.go diff --git a/Makefile b/Makefile index f541f71..5c99c77 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ build: go build -o build/featurevisor-go cmd/main.go test: - go test ./... -v + go test ./... test-example-1: go test ./... diff --git a/README.md b/README.md index 54631c0..48f9a11 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ func main() { panic(err) } - f := featurevisor.CreateInstance(featurevisor.Options{ + f := featurevisor.NewFeaturevisor(featurevisor.Options{ Datafile: datafileContent, }) } @@ -142,7 +142,7 @@ import ( "github.com/featurevisor/featurevisor-go" ) -f := featurevisor.CreateInstance(featurevisor.Options{ +f := featurevisor.NewFeaturevisor(featurevisor.Options{ Context: featurevisor.Context{ "deviceId": "123", "country": "nl", @@ -274,6 +274,8 @@ f.GetVariableObject(featureKey, variableKey, context) f.GetVariableJSON(featureKey, variableKey, context) ``` +Type specific methods do not coerce values. `GetVariableInteger()` returns `nil` for the string `"1"`, and `GetVariableBoolean()` returns `nil` for the string `"true"`. + For typed arrays/objects, use `Into` methods with pointer outputs: ```go @@ -325,7 +327,7 @@ import ( "github.com/featurevisor/featurevisor-go" ) -f := featurevisor.CreateInstance(featurevisor.Options{ +f := featurevisor.NewFeaturevisor(featurevisor.Options{ Sticky: &featurevisor.StickyFeatures{ "myFeatureKey": { Enabled: true, @@ -404,7 +406,7 @@ Because merging is the default, a single SDK instance can start with a small dat This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application: ```go -f := featurevisor.CreateInstance(featurevisor.Options{}) +f := featurevisor.NewFeaturevisor(featurevisor.Options{}) func loadDatafile(target string) { url := fmt.Sprintf("https://cdn.yoursite.com/production/featurevisor-%s.json", target) @@ -506,7 +508,7 @@ import ( ) logLevel := featurevisor.LogLevelDebug -f := featurevisor.CreateInstance(featurevisor.Options{ +f := featurevisor.NewFeaturevisor(featurevisor.Options{ LogLevel: &logLevel, }) ``` @@ -515,7 +517,7 @@ Alternatively, you can also set `logLevel` directly: ```go logLevel := featurevisor.LogLevelDebug -f := featurevisor.CreateInstance(featurevisor.Options{ +f := featurevisor.NewFeaturevisor(featurevisor.Options{ LogLevel: &logLevel, }) ``` @@ -542,7 +544,7 @@ logger := featurevisor.NewLogger(featurevisor.CreateLoggerOptions{ }, }) -f := featurevisor.CreateInstance(featurevisor.Options{ +f := featurevisor.NewFeaturevisor(featurevisor.Options{ Logger: logger, }) ``` @@ -554,7 +556,7 @@ Further log levels like `info` and `debug` will help you understand how the feat You can observe SDK and module diagnostics with `OnDiagnostic`: ```go -f := featurevisor.CreateInstance(featurevisor.Options{ +f := featurevisor.NewFeaturevisor(featurevisor.Options{ OnDiagnostic: func(diagnostic featurevisor.FeaturevisorDiagnostic) { fmt.Println(diagnostic.Level, diagnostic.Code, diagnostic.Message) }, @@ -565,6 +567,8 @@ Modules can also subscribe to diagnostics or report their own from `Setup` via t Every diagnostic has `Level`, `Code`, `Message`, and an object-shaped `Details` map. Optional `Module`, `ModuleName`, and `OriginalError` fields describe provenance; evaluation metadata belongs in `Details`. +Diagnostic handlers are isolated from SDK behavior. A panic in a handler does not stop other handlers or evaluations. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. @@ -670,6 +674,8 @@ Modules allow you to intercept the evaluation process and customize it further a A module is a simple struct with a recommended unique `Name` and optional functions: +If `Setup` panics, the module is not registered. Featurevisor removes subscriptions created during setup, reports `module_setup_error`, and calls `Close` when present. + ```go import ( "github.com/featurevisor/featurevisor-go" @@ -734,7 +740,7 @@ import ( "github.com/featurevisor/featurevisor-go" ) -f := featurevisor.CreateInstance(featurevisor.Options{ +f := featurevisor.NewFeaturevisor(featurevisor.Options{ Modules: []*featurevisor.FeaturevisorModule{ myCustomModule, }, diff --git a/child_parity_test.go b/child_parity_test.go index 66b4e9f..69bf96a 100644 --- a/child_parity_test.go +++ b/child_parity_test.go @@ -3,7 +3,7 @@ package featurevisor import "testing" func TestChildEventIsolationAndProxying(t *testing.T) { - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ Datafile: DatafileContent{ SchemaVersion: "2", Revision: "1", diff --git a/cmd/commands/assess_distribution.go b/cmd/commands/assess_distribution.go index fc21311..69d8ecd 100644 --- a/cmd/commands/assess_distribution.go +++ b/cmd/commands/assess_distribution.go @@ -117,7 +117,7 @@ func runAssessDistribution(opts CLIOptions) { json.Unmarshal(datafileBytes, &datafileContent) } - instance := featurevisor.CreateInstance(featurevisor.Options{ + instance := featurevisor.NewFeaturevisor(featurevisor.Options{ Datafile: datafileContent, LogLevel: &level, }) diff --git a/cmd/commands/benchmark.go b/cmd/commands/benchmark.go index 18390b6..f10a0f1 100644 --- a/cmd/commands/benchmark.go +++ b/cmd/commands/benchmark.go @@ -182,7 +182,7 @@ func runBenchmark(opts CLIOptions) { datafileSize := len(datafileBytes) fmt.Printf("Datafile size: %.2f kB\n", float64(datafileSize)/1024.0) - instance := featurevisor.CreateInstance(featurevisor.Options{ + instance := featurevisor.NewFeaturevisor(featurevisor.Options{ Datafile: datafileContent, LogLevel: &level, }) diff --git a/cmd/commands/test.go b/cmd/commands/test.go index 60e7066..ef9cc89 100644 --- a/cmd/commands/test.go +++ b/cmd/commands/test.go @@ -795,7 +795,7 @@ func buildInstanceForAssertion(datafile interface{}, level string, assertion map } levelStr := featurevisor.LogLevel(level) - return featurevisor.CreateInstance(featurevisor.Options{ + return featurevisor.NewFeaturevisor(featurevisor.Options{ Datafile: datafileContent, LogLevel: &levelStr, Modules: []*featurevisor.FeaturevisorModule{ diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json new file mode 100644 index 0000000..ceae692 --- /dev/null +++ b/conformance/sdk-v3.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "description": "Featurevisor v3 cross SDK compatibility contracts", + "bucketing": { + "minimum": 0, + "maximum": 100000, + "percentage": { + "percentage": 50000, + "enabledAt": [0, 50000], + "disabledAt": [50001, 100000] + }, + "allocations": [ + { "variation": "control", "range": [0, 50000] }, + { "variation": "treatment", "range": [50000, 100000] } + ], + "allocationExpectations": { + "0": "control", + "49999": "control", + "50000": "control", + "50001": "treatment", + "99999": "treatment", + "100000": "treatment" + } + }, + "regularExpressions": { + "pattern": "chrome", + "flags": "g", + "values": ["chrome", "chrome", "firefox", "chrome"], + "matches": [true, true, false, true] + }, + "typedVariables": [ + { "type": "integer", "value": 1, "valid": true }, + { "type": "integer", "value": 1.5, "valid": false }, + { "type": "integer", "value": "1", "valid": false }, + { "type": "double", "value": 1.5, "valid": true }, + { "type": "double", "value": "1.5", "valid": false }, + { "type": "boolean", "value": true, "valid": true }, + { "type": "boolean", "value": "true", "valid": false } + ], + "datafile": { + "schemaVersionIsInformational": true, + "schemaVersionType": "string" + }, + "diagnostics": { + "requiredFields": ["level", "code", "message", "details"], + "detailsType": "object", + "emptyDetailsJson": "{}" + } +} diff --git a/conformance_test.go b/conformance_test.go new file mode 100644 index 0000000..5d87150 --- /dev/null +++ b/conformance_test.go @@ -0,0 +1,63 @@ +package featurevisor + +import ( + "encoding/json" + "fmt" + "os" + "testing" +) + +type conformanceFixture struct { + Version int `json:"version"` + Bucketing struct { + Allocations []Allocation `json:"allocations"` + AllocationExpectations map[string]VariationValue `json:"allocationExpectations"` + } `json:"bucketing"` + TypedVariables []struct { + Type string `json:"type"` + Value interface{} `json:"value"` + Valid bool `json:"valid"` + } `json:"typedVariables"` +} + +func loadConformanceFixture(t *testing.T) conformanceFixture { + t.Helper() + content, err := os.ReadFile("conformance/sdk-v3.json") + if err != nil { + t.Fatal(err) + } + var fixture conformanceFixture + if err := json.Unmarshal(content, &fixture); err != nil { + t.Fatal(err) + } + return fixture +} + +func TestSDKV3ConformanceFixture(t *testing.T) { + fixture := loadConformanceFixture(t) + if fixture.Version != 1 { + t.Fatalf("unexpected fixture version %d", fixture.Version) + } + + reader := newDatafileReader(datafileReaderOptions{Datafile: DatafileContent{ + SchemaVersion: "2", Revision: "conformance", Segments: map[SegmentKey]Segment{}, Features: map[FeatureKey]Feature{}, + }, Logger: NewLogger(CreateLoggerOptions{})}) + traffic := &Traffic{Allocation: fixture.Bucketing.Allocations} + for bucket, expected := range fixture.Bucketing.AllocationExpectations { + var bucketValue int + if _, err := fmt.Sscanf(bucket, "%d", &bucketValue); err != nil { + t.Fatal(err) + } + allocation := reader.GetMatchedAllocation(traffic, bucketValue) + if allocation == nil || allocation.Variation != expected { + t.Fatalf("bucket %d: expected %s, got %#v", bucketValue, expected, allocation) + } + } + + for _, item := range fixture.TypedVariables { + actual := GetValueByType(item.Value, item.Type) + if (actual != nil) != item.Valid { + t.Fatalf("type %s value %#v: expected valid=%v, got %#v", item.Type, item.Value, item.Valid, actual) + } + } +} diff --git a/generic_variable_methods_test.go b/generic_variable_methods_test.go index cb744e6..0ac0399 100644 --- a/generic_variable_methods_test.go +++ b/generic_variable_methods_test.go @@ -97,7 +97,7 @@ func TestGenericVariableMethods(t *testing.T) { t.Fatalf("failed to parse datafile: %v", err) } - sdk := CreateInstance(Options{Datafile: datafile}) + sdk := NewFeaturevisor(Options{Datafile: datafile}) context := Context{"userId": "123"} var arr []string diff --git a/helpers.go b/helpers.go index 4f8cad0..24a243e 100644 --- a/helpers.go +++ b/helpers.go @@ -3,8 +3,8 @@ package featurevisor import ( "encoding/json" "fmt" + "math" "reflect" - "strconv" ) // GetValueByType converts a value to the specified type @@ -22,30 +22,29 @@ func GetValueByType(value interface{}, fieldType string) interface{} { return nil case "integer": switch v := value.(type) { - case string: - if n, err := strconv.Atoi(v); err == nil { - return n - } case int: return v case float64: - return int(v) + if !math.IsNaN(v) && !math.IsInf(v, 0) && math.Trunc(v) == v { + return int(v) + } } return nil case "double": switch v := value.(type) { - case string: - if n, err := strconv.ParseFloat(v, 64); err == nil { - return n - } case float64: - return v + if !math.IsNaN(v) && !math.IsInf(v, 0) { + return v + } case int: return float64(v) } return nil case "boolean": - return value == true + if booleanValue, ok := value.(bool); ok { + return booleanValue + } + return nil case "array": if arr, ok := value.([]interface{}); ok { return arr diff --git a/helpers_parity_test.go b/helpers_parity_test.go index a29e8d5..9c35bab 100644 --- a/helpers_parity_test.go +++ b/helpers_parity_test.go @@ -6,10 +6,22 @@ func TestGetValueByTypeBooleanParity(t *testing.T) { if value := GetValueByType(true, "boolean"); value != true { t.Fatalf("expected true bool to remain true") } - if value := GetValueByType("true", "boolean"); value != false { + if value := GetValueByType("true", "boolean"); value != nil { t.Fatalf("expected string \"true\" to not be coerced to true") } - if value := GetValueByType(1, "boolean"); value != false { + if value := GetValueByType(1, "boolean"); value != nil { t.Fatalf("expected numeric value to not be coerced to true") } } + +func TestGetValueByTypeDoesNotCoerceStrings(t *testing.T) { + if value := GetValueByType("1", "integer"); value != nil { + t.Fatalf("expected nil integer, got %#v", value) + } + if value := GetValueByType("1.1", "double"); value != nil { + t.Fatalf("expected nil double, got %#v", value) + } + if value := GetValueByType(1.5, "integer"); value != nil { + t.Fatalf("expected nil fractional integer, got %#v", value) + } +} diff --git a/instance.go b/instance.go index 1c4ab81..8ed420e 100644 --- a/instance.go +++ b/instance.go @@ -278,12 +278,26 @@ func (i *Featurevisor) reportDiagnostic( if !shouldLogDiagnostic(subscription.logLevel, diagnostic.Level) { continue } - subscription.handler(diagnostic) + func() { + defer func() { + if recovered := recover(); recovered != nil { + _ = recovered + } + }() + subscription.handler(diagnostic) + }() } if shouldLogDiagnostic(i.logLevel, diagnostic.Level) { if i.onDiagnostic != nil { - i.onDiagnostic(diagnostic) + func() { + defer func() { + if recovered := recover(); recovered != nil { + _ = recovered + } + }() + i.onDiagnostic(diagnostic) + }() } else { details := LogDetails{} if diagnostic.Details != nil { @@ -821,11 +835,6 @@ func (i *Featurevisor) GetAllEvaluations(context Context, featureKeys []string, return result } -// CreateInstance creates a new Featurevisor instance -func CreateInstance(options Options) *Featurevisor { - return NewFeaturevisor(options) -} - func mergeStoredDatafile(existing DatafileContent, incoming DatafileContent) DatafileContent { mergedSegments := map[SegmentKey]Segment{} for key, value := range existing.Segments { diff --git a/instance_api_parity_test.go b/instance_api_parity_test.go index 08c15ca..9dcd587 100644 --- a/instance_api_parity_test.go +++ b/instance_api_parity_test.go @@ -3,7 +3,7 @@ package featurevisor import "testing" func TestSetDatafileAcceptsJSONString(t *testing.T) { - instance := CreateInstance(Options{}) + instance := NewFeaturevisor(Options{}) instance.SetDatafile(`{ "schemaVersion": "2", "revision": "json-revision", @@ -17,7 +17,7 @@ func TestSetDatafileAcceptsJSONString(t *testing.T) { } func TestInstanceOnReturnsUnsubscribe(t *testing.T) { - instance := CreateInstance(Options{}) + instance := NewFeaturevisor(Options{}) calls := 0 unsubscribe := instance.On(EventNameContextSet, func(details EventDetails) { diff --git a/instance_deprecated_features_test.go b/instance_deprecated_features_test.go index c90fc84..32d2766 100644 --- a/instance_deprecated_features_test.go +++ b/instance_deprecated_features_test.go @@ -75,7 +75,7 @@ func TestDeprecatedFeatures(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := CreateInstance(Options{ + sdk := NewFeaturevisor(Options{ Datafile: datafile, Logger: customLogger, }) diff --git a/instance_individually_named_segments_test.go b/instance_individually_named_segments_test.go index 4625efd..da26c98 100644 --- a/instance_individually_named_segments_test.go +++ b/instance_individually_named_segments_test.go @@ -49,7 +49,7 @@ func TestIndividuallyNamedSegments(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := CreateInstance(Options{ + sdk := NewFeaturevisor(Options{ Datafile: datafile, }) diff --git a/instance_mutually_exclusive_test.go b/instance_mutually_exclusive_test.go index 3a8052a..5028dcd 100644 --- a/instance_mutually_exclusive_test.go +++ b/instance_mutually_exclusive_test.go @@ -33,7 +33,7 @@ func TestMutuallyExclusiveFeatures(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := CreateInstance(Options{ + sdk := NewFeaturevisor(Options{ Modules: []*FeaturevisorModule{ { Name: "unit-test", diff --git a/instance_overridden_flags_test.go b/instance_overridden_flags_test.go index 0754403..936a820 100644 --- a/instance_overridden_flags_test.go +++ b/instance_overridden_flags_test.go @@ -42,7 +42,7 @@ func TestOverriddenFlagsFromRules(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := CreateInstance(Options{ + sdk := NewFeaturevisor(Options{ Datafile: datafile, }) diff --git a/instance_required_features_test.go b/instance_required_features_test.go index 3d86f4a..db88198 100644 --- a/instance_required_features_test.go +++ b/instance_required_features_test.go @@ -44,7 +44,7 @@ func TestRequiredFeaturesSimple(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := CreateInstance(Options{ + sdk := NewFeaturevisor(Options{ Datafile: datafile1, }) @@ -92,7 +92,7 @@ func TestRequiredFeaturesSimple(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk2 := CreateInstance(Options{ + sdk2 := NewFeaturevisor(Options{ Datafile: datafile2, }) @@ -153,7 +153,7 @@ func TestRequiredFeaturesWithVariation(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := CreateInstance(Options{ + sdk := NewFeaturevisor(Options{ Datafile: datafile1, }) @@ -212,7 +212,7 @@ func TestRequiredFeaturesWithVariation(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk2 := CreateInstance(Options{ + sdk2 := NewFeaturevisor(Options{ Datafile: datafile2, }) diff --git a/instance_rule_variable_overrides_test.go b/instance_rule_variable_overrides_test.go index e35a2af..0994dcf 100644 --- a/instance_rule_variable_overrides_test.go +++ b/instance_rule_variable_overrides_test.go @@ -82,7 +82,7 @@ func TestRuleVariableOverridesParity(t *testing.T) { t.Fatalf("failed to parse datafile: %v", err) } - sdk := CreateInstance(Options{Datafile: datafile}) + sdk := NewFeaturevisor(Options{Datafile: datafile}) // first matching rule override by segments should win (index 0) evaluation := sdk.EvaluateVariable("test", "config", Context{ @@ -208,7 +208,7 @@ func TestVariationVariableOverrideReasonSplit(t *testing.T) { t.Fatalf("failed to parse datafile: %v", err) } - sdk := CreateInstance(Options{Datafile: datafile}) + sdk := NewFeaturevisor(Options{Datafile: datafile}) evaluation := sdk.EvaluateVariable("test", "color", Context{ "userId": "user-1", "country": "de", diff --git a/instance_sticky_features_test.go b/instance_sticky_features_test.go index 47ca7da..15fb76e 100644 --- a/instance_sticky_features_test.go +++ b/instance_sticky_features_test.go @@ -39,7 +39,7 @@ func TestStickyFeaturesInitialization(t *testing.T) { } // Create instance with sticky features and datafile - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ Datafile: datafileContent, Sticky: &StickyFeatures{ "test": EvaluatedFeature{ @@ -122,7 +122,7 @@ func TestStickyFeaturesInitialization(t *testing.T) { // TestSetStickyVariadicSignature tests that the new variadic signature works correctly func TestSetStickyVariadicSignature(t *testing.T) { - instance := CreateInstance(Options{}) + instance := NewFeaturevisor(Options{}) // Test calling without replace parameter (should default to false) sticky1 := StickyFeatures{"test1": EvaluatedFeature{Enabled: true}} diff --git a/instance_test.go b/instance_test.go index fcd30f5..a054c0e 100644 --- a/instance_test.go +++ b/instance_test.go @@ -6,8 +6,8 @@ import ( ) func TestCreateInstance(t *testing.T) { - // Test that CreateInstance is a function - instance := CreateInstance(Options{}) + // Test that NewFeaturevisor is a function + instance := NewFeaturevisor(Options{}) if instance == nil { t.Fatal("Expected instance to be created") } @@ -25,7 +25,7 @@ func TestCreateInstance(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance = CreateInstance(Options{ + instance = NewFeaturevisor(Options{ Datafile: datafile, }) @@ -78,7 +78,7 @@ func TestPlainBucketBy(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ Datafile: datafile, Modules: []*FeaturevisorModule{ { @@ -142,7 +142,7 @@ func TestAndBucketBy(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ Datafile: datafile, Modules: []*FeaturevisorModule{ { @@ -207,7 +207,7 @@ func TestOrBucketBy(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ Datafile: datafile, Modules: []*FeaturevisorModule{ { @@ -288,7 +288,7 @@ func TestBeforeModule(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ Datafile: datafile, Modules: []*FeaturevisorModule{ { @@ -362,7 +362,7 @@ func TestAfterModule(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ Datafile: datafile, Modules: []*FeaturevisorModule{ { @@ -414,7 +414,7 @@ func TestModulesSetupDiagnosticsAndClose(t *testing.T) { var moduleSawDatafileSet bool var diagnostics []FeaturevisorDiagnostic - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ Datafile: DatafileContent{ SchemaVersion: "2", Revision: "module-test", @@ -490,9 +490,70 @@ func TestModulesSetupDiagnosticsAndClose(t *testing.T) { } } +func TestModuleSetupFailureIsIsolated(t *testing.T) { + var diagnostics []FeaturevisorDiagnostic + closed := 0 + logLevel := LogLevelError + instance := NewFeaturevisor(Options{ + LogLevel: &logLevel, + OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { + diagnostics = append(diagnostics, diagnostic) + }, + }) + + remove := instance.AddModule(&FeaturevisorModule{ + Name: "broken-setup", + Setup: func(api FeaturevisorModuleApi) { + api.OnDiagnostic(func(FeaturevisorDiagnostic) {}) + panic("broken setup") + }, + Close: func() { closed++ }, + }) + + if remove != nil { + t.Fatal("failed setup module must not be registered") + } + if closed != 1 { + t.Fatalf("expected close once, got %d", closed) + } + found := false + for _, diagnostic := range diagnostics { + if diagnostic.Code == "module_setup_error" && diagnostic.ModuleName == "broken-setup" { + found = true + } + } + if !found { + t.Fatal("expected module_setup_error diagnostic") + } +} + +func TestDiagnosticHandlerPanicsAreIsolated(t *testing.T) { + seen := 0 + logLevel := LogLevelDebug + instance := NewFeaturevisor(Options{ + LogLevel: &logLevel, + OnDiagnostic: func(FeaturevisorDiagnostic) { + panic("broken root handler") + }, + Modules: []*FeaturevisorModule{ + {Setup: func(api FeaturevisorModuleApi) { + api.OnDiagnostic(func(FeaturevisorDiagnostic) { panic("broken module handler") }, FeaturevisorModuleDiagnosticOptions{LogLevel: LogLevelDebug}) + }}, + {Setup: func(api FeaturevisorModuleApi) { + api.OnDiagnostic(func(FeaturevisorDiagnostic) { seen++ }, FeaturevisorModuleDiagnosticOptions{LogLevel: LogLevelDebug}) + }}, + }, + }) + + instance.SetContext(Context{"country": "nl"}, false) + if seen == 0 { + t.Fatal("expected working diagnostic handler to keep running") + } +} + func TestRemovedModulesAreClosed(t *testing.T) { closed := []string{} - instance := CreateInstance(Options{}) + instance := NewFeaturevisor(Options{}) unsubscribe := instance.AddModule(&FeaturevisorModule{ Name: "dynamic", @@ -526,7 +587,7 @@ func TestModuleCloseErrorsAreReportedAndDoNotStopCleanup(t *testing.T) { var errorEvents []FeaturevisorDiagnostic closed := []string{} - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { diagnostics = append(diagnostics, diagnostic) }, @@ -586,7 +647,7 @@ func TestModuleCloseErrorsAreReportedAndDoNotStopCleanup(t *testing.T) { func TestModuleUnsubscribeReportsCloseErrors(t *testing.T) { var diagnostics []FeaturevisorDiagnostic - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { diagnostics = append(diagnostics, diagnostic) }, @@ -618,7 +679,7 @@ func TestModuleUnsubscribeReportsCloseErrors(t *testing.T) { } func TestSetDatafileMergesByDefaultAndReplacesWhenRequested(t *testing.T) { - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ Datafile: DatafileContent{ SchemaVersion: "2", Revision: "1", @@ -756,7 +817,7 @@ func TestGetAllEvaluations(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ Datafile: datafile, }) @@ -900,7 +961,7 @@ func TestGetAllEvaluations(t *testing.T) { func TestLifecycleMutationsReportDiagnostics(t *testing.T) { logLevel := LogLevelDebug diagnostics := []FeaturevisorDiagnostic{} - instance := CreateInstance(Options{ + instance := NewFeaturevisor(Options{ LogLevel: &logLevel, OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { diagnostics = append(diagnostics, diagnostic) diff --git a/instance_variables_no_variations_test.go b/instance_variables_no_variations_test.go index f2d2c2e..8c05df3 100644 --- a/instance_variables_no_variations_test.go +++ b/instance_variables_no_variations_test.go @@ -51,7 +51,7 @@ func TestVariablesWithoutVariations(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := CreateInstance(Options{ + sdk := NewFeaturevisor(Options{ Datafile: datafile, }) diff --git a/instance_variables_test.go b/instance_variables_test.go index 09f1314..5841ec9 100644 --- a/instance_variables_test.go +++ b/instance_variables_test.go @@ -112,7 +112,7 @@ func TestVariables(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := CreateInstance(Options{ + sdk := NewFeaturevisor(Options{ Datafile: datafile, }) diff --git a/instance_variations_test.go b/instance_variations_test.go index 26d73f9..40afbd6 100644 --- a/instance_variations_test.go +++ b/instance_variations_test.go @@ -64,7 +64,7 @@ func TestVariationsWithForceRules(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := CreateInstance(Options{ + sdk := NewFeaturevisor(Options{ Datafile: datafile, }) diff --git a/modules.go b/modules.go index 7e7cb39..b1cc413 100644 --- a/modules.go +++ b/modules.go @@ -104,7 +104,29 @@ func (mm *ModulesManager) Add(module *FeaturevisorModule) FeaturevisorUnsubscrib } if module.Setup != nil && mm.getModuleApi != nil { - module.Setup(mm.getModuleApi(module)) + setupFailed := false + func() { + defer func() { + if recovered := recover(); recovered != nil { + setupFailed = true + if mm.clearModuleDiagnosticSubscriptions != nil { + mm.clearModuleDiagnosticSubscriptions(module) + } + mm.reportDiagnostic(FeaturevisorDiagnostic{ + Level: LogLevelError, + Code: "module_setup_error", + Message: "Module setup failed", + ModuleName: module.Name, + OriginalError: recovered, + }, nil) + mm.closeModule(module) + } + }() + module.Setup(mm.getModuleApi(module)) + }() + if setupFailed { + return nil + } } mm.modules = append(mm.modules, module) From 73a549009ebbbd6609f548ceb417f5e6a88534f0 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 12:33:23 +0200 Subject: [PATCH 09/13] cli alignment --- README.md | 6 ++++-- cmd/commands/assess_distribution.go | 21 +++++++++++++++++---- cmd/commands/benchmark.go | 22 +++++++++++++++++----- cmd/commands/commands.go | 17 ++++++++++++++++- cmd/commands/commands_test.go | 7 +++++++ cmd/commands/test.go | 24 +++++++++++++++++++++++- 6 files changed, 84 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 48f9a11..532a0ae 100644 --- a/README.md +++ b/README.md @@ -809,6 +809,8 @@ f.Close() This package also provides a CLI tool for running your Featurevisor [project](https://featurevisor.com/docs/projects/)'s test specs and benchmarking against this Go SDK: +All three commands accept repeatable `--target=` options. `test` builds only the selected Target datafiles and runs untargeted assertions plus assertions for those targets. `benchmark` and `assess-distribution` run independently against every selected Target datafile. Without `--target`, existing project-wide behavior is preserved. Project definitions, test specs, Target discovery, and datafile generation continue to come from the Node.js CLI. + ### Test Learn more about testing [here](https://featurevisor.com/docs/testing/). @@ -845,7 +847,7 @@ make test-example-1 ### Benchmark -Learn more about benchmarking [here](https://featurevisor.com/docs/cmd/#benchmarking). +Learn more about benchmarking [here](https://featurevisor.com/docs/cli/#benchmarking). ```bash go run cmd/main.go benchmark \ @@ -858,7 +860,7 @@ go run cmd/main.go benchmark \ ### Assess distribution -Learn more about assessing distribution [here](https://featurevisor.com/docs/cmd/#assess-distribution). +Learn more about assessing distribution [here](https://featurevisor.com/docs/cli/#assess-distribution). ```bash go run cmd/main.go assess-distribution \ diff --git a/cmd/commands/assess_distribution.go b/cmd/commands/assess_distribution.go index 69d8ecd..5c2d13e 100644 --- a/cmd/commands/assess_distribution.go +++ b/cmd/commands/assess_distribution.go @@ -96,6 +96,15 @@ func runAssessDistribution(opts CLIOptions) { return } + if len(opts.Targets) > 1 { + for _, target := range opts.Targets { + selected := opts + selected.Targets = []string{target} + runAssessDistribution(selected) + } + return + } + var context featurevisor.Context if opts.Context != "" { json.Unmarshal([]byte(opts.Context), &context) @@ -106,10 +115,11 @@ func runAssessDistribution(opts CLIOptions) { levelStr := getLoggerLevel(opts) level := featurevisor.LogLevel(levelStr) - datafilesByEnvironment := buildDatafiles(featurevisorProjectPath, []string{opts.Environment}, 0) - - // Create SDK instance - datafile := datafilesByEnvironment[opts.Environment] + var target *string + if len(opts.Targets) == 1 { + target = &opts.Targets[0] + } + datafile := buildDatafileJSON(featurevisorProjectPath, &opts.Environment, opts.Inflate, target) // Convert datafile to proper format var datafileContent featurevisor.DatafileContent @@ -135,6 +145,9 @@ func runAssessDistribution(opts CLIOptions) { // Print header matching TypeScript format fmt.Println("\nAssessing distribution for feature:", opts.Feature, "...") + if target != nil { + fmt.Printf("Target: %s\n", *target) + } // Print context information if opts.Context != "" { diff --git a/cmd/commands/benchmark.go b/cmd/commands/benchmark.go index f10a0f1..2d22f72 100644 --- a/cmd/commands/benchmark.go +++ b/cmd/commands/benchmark.go @@ -145,6 +145,15 @@ func runBenchmark(opts CLIOptions) { return } + if len(opts.Targets) > 1 { + for _, target := range opts.Targets { + selected := opts + selected.Targets = []string{target} + runBenchmark(selected) + } + return + } + var context featurevisor.Context if opts.Context != "" { json.Unmarshal([]byte(opts.Context), &context) @@ -159,17 +168,17 @@ func runBenchmark(opts CLIOptions) { fmt.Printf("Running benchmark for feature \"%s\"...\n", opts.Feature) fmt.Println("") - fmt.Printf("Building datafile containing all features for \"%s\"...\n", opts.Environment) datafileBuildStart := time.Now() - datafilesByEnvironment := buildDatafiles(featurevisorProjectPath, []string{opts.Environment}, opts.Inflate) + var target *string + if len(opts.Targets) == 1 { + target = &opts.Targets[0] + } + datafile := buildDatafileJSON(featurevisorProjectPath, &opts.Environment, opts.Inflate, target) datafileBuildDuration := time.Since(datafileBuildStart) // Convert to milliseconds to match TypeScript behavior datafileBuildDurationMs := datafileBuildDuration.Milliseconds() fmt.Printf("Datafile build duration: %dms\n", datafileBuildDurationMs) - // Create SDK instance - datafile := datafilesByEnvironment[opts.Environment] - // Convert datafile to proper format var datafileContent featurevisor.DatafileContent var datafileBytes []byte @@ -180,6 +189,9 @@ func runBenchmark(opts CLIOptions) { // Calculate datafile size datafileSize := len(datafileBytes) + if target != nil { + fmt.Printf("Target: %s\n", *target) + } fmt.Printf("Datafile size: %.2f kB\n", float64(datafileSize)/1024.0) instance := featurevisor.NewFeaturevisor(featurevisor.Options{ diff --git a/cmd/commands/commands.go b/cmd/commands/commands.go index e56acf6..eaf4c8b 100644 --- a/cmd/commands/commands.go +++ b/cmd/commands/commands.go @@ -27,6 +27,7 @@ type CLIOptions struct { SchemaVersion string ProjectDirectoryPath string PopulateUuid []string + Targets []string } // ParseCLIOptions parses command line arguments into CLIOptions @@ -36,12 +37,17 @@ func ParseCLIOptions(args []string) CLIOptions { N: 1000, // default value } - // Handle populateUuid flags (can be multiple) before main flag parsing + // Handle repeatable flags before main flag parsing. var filteredArgs []string for _, arg := range args { if strings.HasPrefix(arg, "--populateUuid=") { value := strings.TrimPrefix(arg, "--populateUuid=") opts.PopulateUuid = append(opts.PopulateUuid, value) + } else if strings.HasPrefix(arg, "--target=") { + value := strings.TrimPrefix(arg, "--target=") + if value != "" && !containsString(opts.Targets, value) { + opts.Targets = append(opts.Targets, value) + } } else { filteredArgs = append(filteredArgs, arg) } @@ -74,6 +80,15 @@ func ParseCLIOptions(args []string) CLIOptions { return opts } +func containsString(values []string, value string) bool { + for _, candidate := range values { + if candidate == value { + return true + } + } + return false +} + // getCurrentDir returns the current working directory func getCurrentDir() string { dir, err := os.Getwd() diff --git a/cmd/commands/commands_test.go b/cmd/commands/commands_test.go index a3358c8..76778ac 100644 --- a/cmd/commands/commands_test.go +++ b/cmd/commands/commands_test.go @@ -16,6 +16,13 @@ func TestParseCLIOptionsAcceptsLegacyIgnoredFlags(t *testing.T) { } } +func TestParseCLIOptionsAcceptsRepeatedTargets(t *testing.T) { + opts := ParseCLIOptions([]string{"--target=web", "--target=mobile", "--target=web"}) + if len(opts.Targets) != 2 || opts.Targets[0] != "web" || opts.Targets[1] != "mobile" { + t.Fatalf("unexpected targets: %#v", opts.Targets) + } +} + func TestTargetDatafileCacheKey(t *testing.T) { if got := targetDatafileCacheKey(nil, "checkout"); got != "false-target-checkout" { t.Fatalf("expected false-target-checkout, got %s", got) diff --git a/cmd/commands/test.go b/cmd/commands/test.go index ef9cc89..43209b6 100644 --- a/cmd/commands/test.go +++ b/cmd/commands/test.go @@ -721,9 +721,13 @@ func buildDatafileCache( featurevisorProjectPath string, config map[string]interface{}, inflate int, + selectedTargets []string, ) map[string]interface{} { cache := make(map[string]interface{}) - targetKeys := getTargets(featurevisorProjectPath) + targetKeys := selectedTargets + if len(targetKeys) == 0 { + targetKeys = getTargets(featurevisorProjectPath) + } environments := []*string{nil} if envList, ok := config["environments"].([]interface{}); ok { @@ -840,6 +844,7 @@ func runTest(opts CLIOptions) { featurevisorProjectPath, config, opts.Inflate, + opts.Targets, ) fmt.Println() @@ -860,6 +865,23 @@ func runTest(opts CLIOptions) { for _, test := range tests { testKey := test["key"].(string) assertions := test["assertions"].([]interface{}) + if _, hasFeature := test["feature"]; hasFeature && len(opts.Targets) > 0 { + filtered := make([]interface{}, 0, len(assertions)) + for _, raw := range assertions { + assertion, ok := raw.(map[string]interface{}) + if !ok { + continue + } + target, _ := assertion["target"].(string) + if target == "" || containsString(opts.Targets, target) { + filtered = append(filtered, raw) + } + } + assertions = filtered + if len(assertions) == 0 { + continue + } + } results := "" testHasError := false testDuration := 0.0 From 72d854f94f2be33d395dfbd569f5425c9b01c6bb Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 18:29:53 +0200 Subject: [PATCH 10/13] alignment --- README.md | 96 +++++----------- api_compatibility_test.go | 34 +++--- bucketer.go | 12 +- bucketer_test.go | 66 +++++------ child.go | 18 +-- child_parity_test.go | 2 +- cmd/commands/assess_distribution.go | 2 +- cmd/commands/benchmark.go | 2 +- cmd/commands/test.go | 2 +- conditions_test.go | 12 +- conformance_test.go | 2 +- datafile_reader.go | 26 ++--- datafile_reader_test.go | 48 ++++---- diagnostics.go | 2 +- emitter.go | 24 ++-- emitter_test.go | 36 +++--- evaluate.go | 92 +++++++-------- events.go | 8 +- events_parity_test.go | 6 +- generic_variable_methods_test.go | 2 +- instance.go | 111 +++++++++++++------ instance_api_parity_test.go | 7 +- instance_deprecated_features_test.go | 36 ++---- instance_individually_named_segments_test.go | 2 +- instance_mutually_exclusive_test.go | 2 +- instance_overridden_flags_test.go | 2 +- instance_required_features_test.go | 8 +- instance_rule_variable_overrides_test.go | 4 +- instance_sticky_features_test.go | 4 +- instance_test.go | 34 +++--- instance_variables_no_variations_test.go | 2 +- instance_variables_test.go | 2 +- instance_variations_test.go | 2 +- logger.go | 81 +++++++------- logger_test.go | 70 ++++++------ modules.go | 24 ++-- 36 files changed, 432 insertions(+), 451 deletions(-) diff --git a/README.md b/README.md index 532a0ae..7586355 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ See example application [here](https://github.com/featurevisor/featurevisor-exam ## Table of contents - [Installation](#installation) +- [Public API](#public-api) - [Initialization](#initialization) - [Evaluation types](#evaluation-types) - [Context](#context) @@ -30,11 +31,9 @@ See example application [here](https://github.com/featurevisor/featurevisor-exam - [Loading datafiles on demand](#loading-datafiles-on-demand) - [Updating datafile](#updating-datafile) - [Interval-based update](#interval-based-update) -- [Logging](#logging) +- [Diagnostics](#diagnostics) - [Levels](#levels) - - [Customizing levels](#customizing-levels) - [Handler](#handler) -- [Diagnostics](#diagnostics) - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) @@ -66,6 +65,18 @@ In your Go application, install the SDK using Go modules: go get github.com/featurevisor/featurevisor-go ``` +## Public API + +The main runtime API is `featurevisor.CreateFeaturevisor()`: + +```go +f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ + Datafile: datafileContent, +}) +``` + +Most applications only need `CreateFeaturevisor`, the `Featurevisor` instance type, and `FeaturevisorOptions`. Public extension and observability types include `FeaturevisorModule`, `FeaturevisorDiagnostic`, and the datafile model types. + ## Initialization The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly: @@ -99,7 +110,7 @@ func main() { panic(err) } - f := featurevisor.NewFeaturevisor(featurevisor.Options{ + f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ Datafile: datafileContent, }) } @@ -142,7 +153,7 @@ import ( "github.com/featurevisor/featurevisor-go" ) -f := featurevisor.NewFeaturevisor(featurevisor.Options{ +f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ Context: featurevisor.Context{ "deviceId": "123", "country": "nl", @@ -327,7 +338,7 @@ import ( "github.com/featurevisor/featurevisor-go" ) -f := featurevisor.NewFeaturevisor(featurevisor.Options{ +f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ Sticky: &featurevisor.StickyFeatures{ "myFeatureKey": { Enabled: true, @@ -406,7 +417,7 @@ Because merging is the default, a single SDK instance can start with a small dat This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application: ```go -f := featurevisor.NewFeaturevisor(featurevisor.Options{}) +f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{}) func loadDatafile(target string) { url := fmt.Sprintf("https://cdn.yoursite.com/production/featurevisor-%s.json", target) @@ -483,80 +494,32 @@ func updateDatafile(f *featurevisor.Featurevisor, datafileURL string) { go updateDatafile(f, datafileURL) ``` -## Logging +## Diagnostics -By default, Featurevisor SDKs will print out logs to the console for `info` level and above. +By default, Featurevisor reports diagnostics to the console for `info` level and above with a `[Featurevisor]` prefix. ### Levels -These are all the available log levels: - -- `error` -- `warn` -- `info` -- `debug` - -### Customizing levels - -If you choose `debug` level to make the logs more verbose, you can set it at the time of SDK initialization. - -Setting `debug` level will print out all logs, including `info`, `warn`, and `error` levels. - -```go -import ( - "github.com/featurevisor/featurevisor-go" -) - -logLevel := featurevisor.LogLevelDebug -f := featurevisor.NewFeaturevisor(featurevisor.Options{ - LogLevel: &logLevel, -}) -``` +Available diagnostic levels are `fatal`, `error`, `warn`, `info`, and `debug`. -Alternatively, you can also set `logLevel` directly: +Set the level during initialization or update it afterwards: ```go logLevel := featurevisor.LogLevelDebug -f := featurevisor.NewFeaturevisor(featurevisor.Options{ +f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ LogLevel: &logLevel, }) -``` -You can also set log level from SDK instance afterwards: - -```go -f.SetLogLevel(featurevisor.LogLevelDebug) +f.SetLogLevel(featurevisor.LogLevelInfo) ``` ### Handler -You can also pass your own log handler, if you do not wish to print the logs to the console: - -```go -import ( - "github.com/featurevisor/featurevisor-go" -) - -logger := featurevisor.NewLogger(featurevisor.CreateLoggerOptions{ - Level: &featurevisor.LogLevelInfo, - Handler: func(level featurevisor.LogLevel, message string, details interface{}) { - // do something with the log - }, -}) - -f := featurevisor.NewFeaturevisor(featurevisor.Options{ - Logger: logger, -}) -``` - -Further log levels like `info` and `debug` will help you understand how the feature variations and variables are evaluated in the runtime against given context. - -## Diagnostics - -You can observe SDK and module diagnostics with `OnDiagnostic`: +Use `OnDiagnostic` to send structured diagnostics to your observability system: ```go -f := featurevisor.NewFeaturevisor(featurevisor.Options{ +f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ + LogLevel: &logLevel, OnDiagnostic: func(diagnostic featurevisor.FeaturevisorDiagnostic) { fmt.Println(diagnostic.Level, diagnostic.Code, diagnostic.Message) }, @@ -565,10 +528,11 @@ f := featurevisor.NewFeaturevisor(featurevisor.Options{ Modules can also subscribe to diagnostics or report their own from `Setup` via the provided module API. -Every diagnostic has `Level`, `Code`, `Message`, and an object-shaped `Details` map. Optional `Module`, `ModuleName`, and `OriginalError` fields describe provenance; evaluation metadata belongs in `Details`. +Every diagnostic has `Level`, `Code`, `Message`, and an object-shaped `Details` map. Optional `Module`, `ModuleName`, and `OriginalError` fields describe provenance. Evaluation metadata belongs in `Details`. Diagnostic handlers are isolated from SDK behavior. A panic in a handler does not stop other handlers or evaluations. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. @@ -740,7 +704,7 @@ import ( "github.com/featurevisor/featurevisor-go" ) -f := featurevisor.NewFeaturevisor(featurevisor.Options{ +f := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ Modules: []*featurevisor.FeaturevisorModule{ myCustomModule, }, diff --git a/api_compatibility_test.go b/api_compatibility_test.go index c8f34ae..897fe5c 100644 --- a/api_compatibility_test.go +++ b/api_compatibility_test.go @@ -51,7 +51,7 @@ func TestAPICompatibilityWithStringFeatureKey(t *testing.T) { } // Create instance - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{"userId": "123"}, }) @@ -108,7 +108,7 @@ func TestAPICompatibilityWithStringFeatureKey(t *testing.T) { func TestAPICompatibilityWithNonExistentFeature(t *testing.T) { // Create instance with empty datafile - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: DatafileContent{ SchemaVersion: "2", Revision: "test", @@ -665,7 +665,7 @@ func TestProductionDatafileFeatures(t *testing.T) { t.Run("allowSignup", func(t *testing.T) { // Test Netherlands (NL) - should always get treatment variation // Using bucket value 60000 (60%) to ensure we get treatment variation - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "country": "nl", @@ -712,7 +712,7 @@ func TestProductionDatafileFeatures(t *testing.T) { } // Test Switzerland (CH) - should get treatment variation based on weight - instanceCH := NewFeaturevisor(Options{ + instanceCH := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "country": "ch", @@ -735,7 +735,7 @@ func TestProductionDatafileFeatures(t *testing.T) { } // Test Germany (DE) - should get control variation in everyone segment - instanceDE := NewFeaturevisor(Options{ + instanceDE := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "country": "de", @@ -762,7 +762,7 @@ func TestProductionDatafileFeatures(t *testing.T) { t.Run("bar", func(t *testing.T) { // Test with US context (should get control variation at low bucket values) // Using bucket value 15000 (15%) to get control variation - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "country": "us", @@ -802,7 +802,7 @@ func TestProductionDatafileFeatures(t *testing.T) { // Test with Germany context (should get variation 'b' with overrides) // Using bucket value 20000 (20%) to get variation 'b' - instanceDE := NewFeaturevisor(Options{ + instanceDE := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "country": "de", @@ -829,7 +829,7 @@ func TestProductionDatafileFeatures(t *testing.T) { t.Run("foo", func(t *testing.T) { // Test with mobile + Germany context (should get treatment variation) // Using bucket value 60000 (60%) to get treatment variation - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "country": "de", @@ -874,7 +874,7 @@ func TestProductionDatafileFeatures(t *testing.T) { } // Test force rule - instanceForce := NewFeaturevisor(Options{ + instanceForce := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "userId": "123", @@ -905,7 +905,7 @@ func TestProductionDatafileFeatures(t *testing.T) { t.Run("sidebar", func(t *testing.T) { // Test with Netherlands context (should get treatment variation) // Using bucket value 90000 (90%) to get treatment variation - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "country": "nl", @@ -958,7 +958,7 @@ func TestProductionDatafileFeatures(t *testing.T) { // Test with Germany context (should get color override) // Using bucket value 90000 (90%) to get treatment variation - instanceDE := NewFeaturevisor(Options{ + instanceDE := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "country": "de", @@ -990,7 +990,7 @@ func TestProductionDatafileFeatures(t *testing.T) { t.Run("qux", func(t *testing.T) { // Test with Netherlands context (should get variation 'b' based on allocation) // Using bucket value 70000 (70%) to get variation 'b' - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "country": "nl", @@ -1025,7 +1025,7 @@ func TestProductionDatafileFeatures(t *testing.T) { // Test with Germany context (should get variation 'b' based on allocation) // Using bucket value 70000 (70%) to get variation 'b' - instanceDE := NewFeaturevisor(Options{ + instanceDE := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "country": "de", @@ -1095,7 +1095,7 @@ func TestProductionDatafileSegments(t *testing.T) { // Test segment evaluation t.Run("segmentEvaluation", func(t *testing.T) { // Test Germany segment - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "country": "de", @@ -1109,7 +1109,7 @@ func TestProductionDatafileSegments(t *testing.T) { } // Test Netherlands segment (should match because of everyone segment rule) - instanceNL := NewFeaturevisor(Options{ + instanceNL := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "country": "nl", @@ -1123,7 +1123,7 @@ func TestProductionDatafileSegments(t *testing.T) { } // Test mobile segment - _ = NewFeaturevisor(Options{ + _ = CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "device": "mobile", @@ -1132,7 +1132,7 @@ func TestProductionDatafileSegments(t *testing.T) { }) // Test everyone segment - instanceEveryone := NewFeaturevisor(Options{ + instanceEveryone := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Context: Context{ "userId": "test-user", diff --git a/bucketer.go b/bucketer.go index c2b1283..c1e7e2b 100644 --- a/bucketer.go +++ b/bucketer.go @@ -19,10 +19,10 @@ type BucketValue = int // GetBucketKeyOptions contains options for getting a bucket key type GetBucketKeyOptions struct { - FeatureKey FeatureKey - BucketBy BucketBy - Context Context - Logger *Logger + FeatureKey FeatureKey + BucketBy BucketBy + Context Context + featurevisorLogger *featurevisorLogger } // DEFAULT_BUCKET_KEY_SEPARATOR is the default separator for bucket keys @@ -41,7 +41,7 @@ func GetBucketKey(options GetBucketKeyOptions) BucketKey { featureKey := options.FeatureKey bucketBy := options.BucketBy context := options.Context - logger := options.Logger + logger := options.featurevisorLogger var bucketType string var attributeKeys []string @@ -90,7 +90,7 @@ func GetBucketKey(options GetBucketKeyOptions) BucketKey { } } default: - logger.Error("invalid bucketBy", LogDetails{ + logger.Error("invalid bucketBy", logDetails{ "featureKey": featureKey, "bucketBy": bucketBy, }) diff --git a/bucketer_test.go b/bucketer_test.go index e66d4cd..f3d5159 100644 --- a/bucketer_test.go +++ b/bucketer_test.go @@ -42,7 +42,7 @@ func TestGetBucketedNumber(t *testing.T) { } func TestGetBucketKey(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) t.Run("plain: should return a bucket key for a plain bucketBy", func(t *testing.T) { featureKey := FeatureKey("test-feature") @@ -53,10 +53,10 @@ func TestGetBucketKey(t *testing.T) { } bucketKey := GetBucketKey(GetBucketKeyOptions{ - FeatureKey: featureKey, - BucketBy: bucketBy, - Context: context, - Logger: logger, + FeatureKey: featureKey, + BucketBy: bucketBy, + Context: context, + featurevisorLogger: logger, }) expected := "123.test-feature" @@ -73,10 +73,10 @@ func TestGetBucketKey(t *testing.T) { } bucketKey := GetBucketKey(GetBucketKeyOptions{ - FeatureKey: featureKey, - BucketBy: bucketBy, - Context: context, - Logger: logger, + FeatureKey: featureKey, + BucketBy: bucketBy, + Context: context, + featurevisorLogger: logger, }) expected := "test-feature" @@ -95,10 +95,10 @@ func TestGetBucketKey(t *testing.T) { } bucketKey := GetBucketKey(GetBucketKeyOptions{ - FeatureKey: featureKey, - BucketBy: bucketBy, - Context: context, - Logger: logger, + FeatureKey: featureKey, + BucketBy: bucketBy, + Context: context, + featurevisorLogger: logger, }) expected := "123.234.test-feature" @@ -116,10 +116,10 @@ func TestGetBucketKey(t *testing.T) { } bucketKey := GetBucketKey(GetBucketKeyOptions{ - FeatureKey: featureKey, - BucketBy: bucketBy, - Context: context, - Logger: logger, + FeatureKey: featureKey, + BucketBy: bucketBy, + Context: context, + featurevisorLogger: logger, }) expected := "123.test-feature" @@ -140,10 +140,10 @@ func TestGetBucketKey(t *testing.T) { } bucketKey := GetBucketKey(GetBucketKeyOptions{ - FeatureKey: featureKey, - BucketBy: bucketBy, - Context: context, - Logger: logger, + FeatureKey: featureKey, + BucketBy: bucketBy, + Context: context, + featurevisorLogger: logger, }) expected := "123.234.test-feature" @@ -164,10 +164,10 @@ func TestGetBucketKey(t *testing.T) { } bucketKey := GetBucketKey(GetBucketKeyOptions{ - FeatureKey: featureKey, - BucketBy: bucketBy, - Context: context, - Logger: logger, + FeatureKey: featureKey, + BucketBy: bucketBy, + Context: context, + featurevisorLogger: logger, }) expected := "234.test-feature" @@ -187,10 +187,10 @@ func TestGetBucketKey(t *testing.T) { } bucketKey := GetBucketKey(GetBucketKeyOptions{ - FeatureKey: featureKey, - BucketBy: bucketBy, - Context: context, - Logger: logger, + FeatureKey: featureKey, + BucketBy: bucketBy, + Context: context, + featurevisorLogger: logger, }) expected := "deviceIdHere.test-feature" @@ -212,10 +212,10 @@ func TestGetBucketKey(t *testing.T) { }() GetBucketKey(GetBucketKeyOptions{ - FeatureKey: featureKey, - BucketBy: bucketBy, - Context: context, - Logger: logger, + FeatureKey: featureKey, + BucketBy: bucketBy, + Context: context, + featurevisorLogger: logger, }) }) } diff --git a/child.go b/child.go index 9635a8a..d0b4ae4 100644 --- a/child.go +++ b/child.go @@ -14,16 +14,16 @@ type FeaturevisorChild struct { parent *Featurevisor context Context sticky *StickyFeatures - emitter *Emitter + emitter *emitter } -// NewFeaturevisorChild creates a new child instance -func NewFeaturevisorChild(options ChildOptions) *FeaturevisorChild { +// newFeaturevisorChild creates a new child instance. +func newFeaturevisorChild(options ChildOptions) *FeaturevisorChild { return &FeaturevisorChild{ parent: options.Parent, context: options.Context, sticky: options.Sticky, - emitter: NewEmitter(), + emitter: newEmitter(), } } @@ -118,8 +118,8 @@ func (c *FeaturevisorChild) getEvaluationDependencies(context Context, options O return EvaluateDependencies{ Context: c.GetContext(context), - Logger: c.parent.logger, - ModulesManager: c.parent.modulesManager, + featurevisorLogger: c.parent.logger, + modulesManager: c.parent.modulesManager, datafileReader: c.parent.datafileReader, sticky: sticky, DefaultVariationValue: options.DefaultVariationValue, @@ -142,7 +142,7 @@ func (c *FeaturevisorChild) EvaluateFlag(featureKey string, context Context, opt func (c *FeaturevisorChild) IsEnabled(featureKey string, args ...interface{}) bool { defer func() { if r := recover(); r != nil { - c.parent.logger.Error("isEnabled", LogDetails{ + c.parent.logger.Error("isEnabled", logDetails{ "featureKey": featureKey, "error": r, }) @@ -187,7 +187,7 @@ func (c *FeaturevisorChild) EvaluateVariation(featureKey string, context Context func (c *FeaturevisorChild) GetVariation(featureKey string, args ...interface{}) *string { defer func() { if r := recover(); r != nil { - c.parent.logger.Error("getVariation", LogDetails{ + c.parent.logger.Error("getVariation", logDetails{ "featureKey": featureKey, "error": r, }) @@ -241,7 +241,7 @@ func (c *FeaturevisorChild) EvaluateVariable(featureKey string, variableKey Vari func (c *FeaturevisorChild) GetVariable(featureKey string, variableKey string, args ...interface{}) VariableValue { defer func() { if r := recover(); r != nil { - c.parent.logger.Error("getVariable", LogDetails{ + c.parent.logger.Error("getVariable", logDetails{ "featureKey": featureKey, "variableKey": variableKey, "error": r, diff --git a/child_parity_test.go b/child_parity_test.go index 69bf96a..3477f64 100644 --- a/child_parity_test.go +++ b/child_parity_test.go @@ -3,7 +3,7 @@ package featurevisor import "testing" func TestChildEventIsolationAndProxying(t *testing.T) { - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: DatafileContent{ SchemaVersion: "2", Revision: "1", diff --git a/cmd/commands/assess_distribution.go b/cmd/commands/assess_distribution.go index 5c2d13e..94615ef 100644 --- a/cmd/commands/assess_distribution.go +++ b/cmd/commands/assess_distribution.go @@ -127,7 +127,7 @@ func runAssessDistribution(opts CLIOptions) { json.Unmarshal(datafileBytes, &datafileContent) } - instance := featurevisor.NewFeaturevisor(featurevisor.Options{ + instance := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ Datafile: datafileContent, LogLevel: &level, }) diff --git a/cmd/commands/benchmark.go b/cmd/commands/benchmark.go index 2d22f72..b54d82e 100644 --- a/cmd/commands/benchmark.go +++ b/cmd/commands/benchmark.go @@ -194,7 +194,7 @@ func runBenchmark(opts CLIOptions) { } fmt.Printf("Datafile size: %.2f kB\n", float64(datafileSize)/1024.0) - instance := featurevisor.NewFeaturevisor(featurevisor.Options{ + instance := featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ Datafile: datafileContent, LogLevel: &level, }) diff --git a/cmd/commands/test.go b/cmd/commands/test.go index 43209b6..03657ed 100644 --- a/cmd/commands/test.go +++ b/cmd/commands/test.go @@ -799,7 +799,7 @@ func buildInstanceForAssertion(datafile interface{}, level string, assertion map } levelStr := featurevisor.LogLevel(level) - return featurevisor.NewFeaturevisor(featurevisor.Options{ + return featurevisor.CreateFeaturevisor(featurevisor.FeaturevisorOptions{ Datafile: datafileContent, LogLevel: &levelStr, Modules: []*featurevisor.FeaturevisorModule{ diff --git a/conditions_test.go b/conditions_test.go index 082a3ea..0b33651 100644 --- a/conditions_test.go +++ b/conditions_test.go @@ -315,7 +315,7 @@ func TestConditionIsMatchedDate(t *testing.T) { // TestConditionIsMatchedComprehensive tests all operators comprehensively func TestConditionIsMatchedComprehensive(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "1", @@ -329,8 +329,8 @@ func TestConditionIsMatchedComprehensive(t *testing.T) { } reader := newDatafileReader(datafileReaderOptions{ - Datafile: datafile, - Logger: logger, + Datafile: datafile, + featurevisorLogger: logger, }) // Test wildcard conditions @@ -855,7 +855,7 @@ func TestConditionIsMatchedEdgeCases(t *testing.T) { } func TestConditionIsMatchedComplexNested(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "1", @@ -869,8 +869,8 @@ func TestConditionIsMatchedComplexNested(t *testing.T) { } reader := newDatafileReader(datafileReaderOptions{ - Datafile: datafile, - Logger: logger, + Datafile: datafile, + featurevisorLogger: logger, }) // Test complex nested conditions similar to TypeScript tests diff --git a/conformance_test.go b/conformance_test.go index 5d87150..00c6a6a 100644 --- a/conformance_test.go +++ b/conformance_test.go @@ -41,7 +41,7 @@ func TestSDKV3ConformanceFixture(t *testing.T) { reader := newDatafileReader(datafileReaderOptions{Datafile: DatafileContent{ SchemaVersion: "2", Revision: "conformance", Segments: map[SegmentKey]Segment{}, Features: map[FeatureKey]Feature{}, - }, Logger: NewLogger(CreateLoggerOptions{})}) + }, featurevisorLogger: newLogger(loggerOptions{})}) traffic := &Traffic{Allocation: fixture.Bucketing.Allocations} for bucket, expected := range fixture.Bucketing.AllocationExpectations { var bucketValue int diff --git a/datafile_reader.go b/datafile_reader.go index df26311..13eacc3 100644 --- a/datafile_reader.go +++ b/datafile_reader.go @@ -9,8 +9,8 @@ import ( // datafileReaderOptions contains options for creating a datafile reader type datafileReaderOptions struct { - Datafile DatafileContent - Logger *Logger + Datafile DatafileContent + featurevisorLogger *featurevisorLogger } // ForceResult represents the result of a force lookup @@ -25,7 +25,7 @@ type datafileReader struct { revision string segments map[SegmentKey]Segment features map[FeatureKey]Feature - logger *Logger + logger *featurevisorLogger regexCache map[string]*regexp.Regexp } @@ -36,7 +36,7 @@ func newDatafileReader(options datafileReaderOptions) *datafileReader { revision: options.Datafile.Revision, segments: options.Datafile.Segments, features: options.Datafile.Features, - logger: options.Logger, + logger: options.featurevisorLogger, regexCache: make(map[string]*regexp.Regexp), } } @@ -52,7 +52,7 @@ func AllConditionsAreMatched(conditions Condition, context Context) bool { Segments: make(map[SegmentKey]Segment), Features: make(map[FeatureKey]Feature), }, - Logger: NewLogger(CreateLoggerOptions{}), + featurevisorLogger: newLogger(loggerOptions{}), }) return reader.AllConditionsAreMatched(conditions, context) @@ -155,7 +155,7 @@ func (d *datafileReader) AllConditionsAreMatched(conditions Condition, context C // Add error handling wrapper like in TypeScript version defer func() { if r := recover(); r != nil { - d.logger.Warn("Error in condition matching", LogDetails{ + d.logger.Warn("Error in condition matching", logDetails{ "error": r, "conditions": conditions, "context": context, @@ -302,7 +302,7 @@ func (d *datafileReader) AllSegmentsAreMatched(groupSegments interface{}, contex // Add error handling wrapper like in TypeScript version defer func() { if r := recover(); r != nil { - d.logger.Warn("Error in segment matching", LogDetails{ + d.logger.Warn("Error in segment matching", logDetails{ "error": r, "groupSegments": groupSegments, "context": context, @@ -311,7 +311,7 @@ func (d *datafileReader) AllSegmentsAreMatched(groupSegments interface{}, contex }() // Handle wildcard if groupSegments == "*" { - d.logger.Debug("matched wildcard segment", LogDetails{ + d.logger.Debug("matched wildcard segment", logDetails{ "segments": groupSegments, }) return true @@ -322,7 +322,7 @@ func (d *datafileReader) AllSegmentsAreMatched(groupSegments interface{}, contex segment := d.GetSegment(SegmentKey(segmentKey)) if segment != nil { matched := d.SegmentIsMatched(segment, context) - d.logger.Debug("checked single segment", LogDetails{ + d.logger.Debug("checked single segment", logDetails{ "segment": segmentKey, "matched": matched, }) @@ -407,7 +407,7 @@ func (d *datafileReader) AllSegmentsAreMatched(groupSegments interface{}, contex } } - d.logger.Debug("no segments matched", LogDetails{ + d.logger.Debug("no segments matched", logDetails{ "segments": groupSegments, }) return false @@ -418,7 +418,7 @@ func (d *datafileReader) GetMatchedTraffic(traffic []Traffic, context Context) * for _, t := range traffic { segments := d.parseSegmentsIfStringified(t.Segments) if d.AllSegmentsAreMatched(segments, context) { - d.logger.Debug("matched traffic rule", LogDetails{ + d.logger.Debug("matched traffic rule", logDetails{ "ruleKey": t.Key, "segments": t.Segments, }) @@ -503,7 +503,7 @@ func (d *datafileReader) parseConditionsIfStringified(conditions Condition) Cond var parsedCondition Condition err := json.Unmarshal([]byte(conditionStr), &parsedCondition) if err != nil { - d.logger.Error("Error parsing conditions", LogDetails{ + d.logger.Error("Error parsing conditions", logDetails{ "error": err, "conditions": conditionStr, }) @@ -523,7 +523,7 @@ func (d *datafileReader) parseSegmentsIfStringified(segments interface{}) interf var parsedSegments interface{} err := json.Unmarshal([]byte(segmentStr), &parsedSegments) if err != nil { - d.logger.Error("Error parsing segments", LogDetails{ + d.logger.Error("Error parsing segments", logDetails{ "error": err, "segments": segmentStr, }) diff --git a/datafile_reader_test.go b/datafile_reader_test.go index 0deaace..51662c2 100644 --- a/datafile_reader_test.go +++ b/datafile_reader_test.go @@ -5,7 +5,7 @@ import ( ) func TestNewDatafileReader(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "test-revision", @@ -19,8 +19,8 @@ func TestNewDatafileReader(t *testing.T) { } reader := newDatafileReader(datafileReaderOptions{ - Datafile: datafile, - Logger: logger, + Datafile: datafile, + featurevisorLogger: logger, }) if reader == nil { @@ -37,7 +37,7 @@ func TestNewDatafileReader(t *testing.T) { } func TestDatafileReaderGetRegex(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "test-revision", @@ -51,8 +51,8 @@ func TestDatafileReaderGetRegex(t *testing.T) { } reader := newDatafileReader(datafileReaderOptions{ - Datafile: datafile, - Logger: logger, + Datafile: datafile, + featurevisorLogger: logger, }) // Test regex caching @@ -71,7 +71,7 @@ func TestDatafileReaderGetRegex(t *testing.T) { } func TestDatafileReaderAllConditionsAreMatched(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "test-revision", @@ -85,8 +85,8 @@ func TestDatafileReaderAllConditionsAreMatched(t *testing.T) { } reader := newDatafileReader(datafileReaderOptions{ - Datafile: datafile, - Logger: logger, + Datafile: datafile, + featurevisorLogger: logger, }) context := Context{ @@ -179,7 +179,7 @@ func TestDatafileReaderAllConditionsAreMatched(t *testing.T) { // TestDatafileReaderComprehensive tests comprehensive datafile reader functionality func TestDatafileReaderComprehensive(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) // Create a comprehensive datafile with segments and features jsonDatafile := `{ @@ -237,8 +237,8 @@ func TestDatafileReaderComprehensive(t *testing.T) { } reader := newDatafileReader(datafileReaderOptions{ - Datafile: datafile, - Logger: logger, + Datafile: datafile, + featurevisorLogger: logger, }) t.Run("basic functionality", func(t *testing.T) { @@ -359,7 +359,7 @@ func TestDatafileReaderComprehensive(t *testing.T) { } func TestDatafileReaderSegmentMatching(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) // Create segments for comprehensive testing jsonDatafile := `{ @@ -392,8 +392,8 @@ func TestDatafileReaderSegmentMatching(t *testing.T) { } reader := newDatafileReader(datafileReaderOptions{ - Datafile: datafile, - Logger: logger, + Datafile: datafile, + featurevisorLogger: logger, }) t.Run("dutch mobile users", func(t *testing.T) { @@ -535,7 +535,7 @@ func TestDatafileReaderSegmentMatching(t *testing.T) { } func TestDatafileReaderForceMatching(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "1", @@ -584,8 +584,8 @@ func TestDatafileReaderForceMatching(t *testing.T) { } reader := newDatafileReader(datafileReaderOptions{ - Datafile: datafile, - Logger: logger, + Datafile: datafile, + featurevisorLogger: logger, }) t.Run("force by conditions", func(t *testing.T) { @@ -632,7 +632,7 @@ func TestDatafileReaderForceMatching(t *testing.T) { } func TestDatafileReaderStringifiedParsing(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "1", @@ -651,8 +651,8 @@ func TestDatafileReaderStringifiedParsing(t *testing.T) { } reader := newDatafileReader(datafileReaderOptions{ - Datafile: datafile, - Logger: logger, + Datafile: datafile, + featurevisorLogger: logger, }) t.Run("parse stringified conditions", func(t *testing.T) { @@ -718,7 +718,7 @@ func TestDatafileReaderStringifiedParsing(t *testing.T) { } func TestDatafileReaderErrorHandling(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) jsonDatafile := `{ "schemaVersion": "2", "revision": "1", @@ -737,8 +737,8 @@ func TestDatafileReaderErrorHandling(t *testing.T) { } reader := newDatafileReader(datafileReaderOptions{ - Datafile: datafile, - Logger: logger, + Datafile: datafile, + featurevisorLogger: logger, }) t.Run("handle invalid JSON in conditions", func(t *testing.T) { diff --git a/diagnostics.go b/diagnostics.go index da1be26..1934298 100644 --- a/diagnostics.go +++ b/diagnostics.go @@ -34,6 +34,6 @@ type FeaturevisorModuleDiagnosticOptions struct { type FeaturevisorUnsubscribe func() func shouldLogDiagnostic(currentLevel LogLevel, targetLevel LogLevel) bool { - logger := NewLogger(CreateLoggerOptions{Level: ¤tLevel}) + logger := newLogger(loggerOptions{Level: ¤tLevel}) return logger.shouldHandle(targetLevel) } diff --git a/emitter.go b/emitter.go index fb055ed..d13d77d 100644 --- a/emitter.go +++ b/emitter.go @@ -33,16 +33,16 @@ type Listeners map[EventName][]ListenerEntry // Unsubscribe is a function type for unsubscribing from events type Unsubscribe func() -// Emitter provides event handling functionality -type Emitter struct { +// emitter provides event handling functionality +type emitter struct { listeners Listeners nextID int mu sync.RWMutex } -// NewEmitter creates a new emitter instance -func NewEmitter() *Emitter { - return &Emitter{ +// newEmitter creates a new emitter instance +func newEmitter() *emitter { + return &emitter{ listeners: make(Listeners), nextID: 1, } @@ -50,7 +50,7 @@ func NewEmitter() *Emitter { // On subscribes to an event with a callback function // Returns an unsubscribe function that can be called to remove the listener -func (e *Emitter) On(eventName EventName, callback EventCallback) Unsubscribe { +func (e *emitter) On(eventName EventName, callback EventCallback) Unsubscribe { e.mu.Lock() defer e.mu.Unlock() @@ -98,7 +98,7 @@ func (e *Emitter) On(eventName EventName, callback EventCallback) Unsubscribe { } // Trigger fires an event with the given details -func (e *Emitter) Trigger(eventName EventName, details EventDetails) { +func (e *emitter) Trigger(eventName EventName, details EventDetails) { if details == nil { details = make(EventDetails) } @@ -128,19 +128,19 @@ func (e *Emitter) Trigger(eventName EventName, details EventDetails) { } // TriggerDefault fires an event with empty details -func (e *Emitter) TriggerDefault(eventName EventName) { +func (e *emitter) TriggerDefault(eventName EventName) { e.Trigger(eventName, make(EventDetails)) } // ClearAll removes all event listeners -func (e *Emitter) ClearAll() { +func (e *emitter) ClearAll() { e.mu.Lock() defer e.mu.Unlock() e.listeners = make(Listeners) } // GetListenerCount returns the number of listeners for a specific event -func (e *Emitter) GetListenerCount(eventName EventName) int { +func (e *emitter) GetListenerCount(eventName EventName) int { e.mu.RLock() defer e.mu.RUnlock() listeners := e.listeners[eventName] @@ -151,12 +151,12 @@ func (e *Emitter) GetListenerCount(eventName EventName) int { } // HasListeners returns true if there are any listeners for the given event -func (e *Emitter) HasListeners(eventName EventName) bool { +func (e *emitter) HasListeners(eventName EventName) bool { return e.GetListenerCount(eventName) > 0 } // GetEventNames returns all event names that have listeners -func (e *Emitter) GetEventNames() []EventName { +func (e *emitter) GetEventNames() []EventName { e.mu.RLock() defer e.mu.RUnlock() var eventNames []EventName diff --git a/emitter_test.go b/emitter_test.go index 5b8cdfc..66cf750 100644 --- a/emitter_test.go +++ b/emitter_test.go @@ -23,17 +23,17 @@ func TestEventNames(t *testing.T) { } func TestNewEmitter(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() if emitter == nil { - t.Error("NewEmitter should return a non-nil emitter") + t.Error("newEmitter should return a non-nil emitter") } if emitter.listeners == nil { - t.Error("Emitter listeners should be initialized") + t.Error("emitter listeners should be initialized") } } func TestEmitterOn(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() // Test subscribing to an event callback := func(details EventDetails) { @@ -57,7 +57,7 @@ func TestEmitterOn(t *testing.T) { } func TestEmitterTrigger(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() // Test triggering an event with no listeners emitter.Trigger(EventNameDatafileSet, EventDetails{"test": "value"}) @@ -87,7 +87,7 @@ func TestEmitterTrigger(t *testing.T) { } func TestEmitterTriggerDefault(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() callbackCalled := false receivedDetails := EventDetails{} @@ -114,7 +114,7 @@ func TestEmitterTriggerDefault(t *testing.T) { } func TestEmitterMultipleListeners(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() callback1Called := false callback2Called := false @@ -146,7 +146,7 @@ func TestEmitterMultipleListeners(t *testing.T) { } func TestEmitterTriggerUsesListenerSnapshot(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() calls := []string{} var unsubscribeSecond Unsubscribe @@ -173,7 +173,7 @@ func TestEmitterTriggerUsesListenerSnapshot(t *testing.T) { } func TestEmitterUnsubscribe(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() callback1Called := false callback2Called := false @@ -208,7 +208,7 @@ func TestEmitterUnsubscribe(t *testing.T) { } func TestEmitterMultipleUnsubscribe(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() callbackCalled := false callback := func(details EventDetails) { @@ -230,7 +230,7 @@ func TestEmitterMultipleUnsubscribe(t *testing.T) { } func TestEmitterClearAll(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() callback1Called := false callback2Called := false @@ -269,7 +269,7 @@ func TestEmitterClearAll(t *testing.T) { } func TestEmitterHasListeners(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() if emitter.HasListeners(EventNameDatafileSet) { t.Error("Should not have listeners initially") @@ -290,7 +290,7 @@ func TestEmitterHasListeners(t *testing.T) { } func TestEmitterGetEventNames(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() // Initially no events eventNames := emitter.GetEventNames() @@ -330,7 +330,7 @@ func TestEmitterGetEventNames(t *testing.T) { } func TestEmitterConcurrentAccess(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() var wg sync.WaitGroup numGoroutines := 10 @@ -362,7 +362,7 @@ func TestEmitterConcurrentAccess(t *testing.T) { } func TestEmitterPanicRecovery(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() // Create a callback that panics panicCallback := func(details EventDetails) { @@ -388,7 +388,7 @@ func TestEmitterPanicRecovery(t *testing.T) { // TestEmitterOriginalSpec matches the original TypeScript test specification func TestEmitterOriginalSpec(t *testing.T) { - emitter := NewEmitter() + emitter := newEmitter() var handledDetails []EventDetails handleDetails := func(details EventDetails) { @@ -443,7 +443,7 @@ func TestEmitterOriginalSpec(t *testing.T) { } func BenchmarkEmitterTrigger(b *testing.B) { - emitter := NewEmitter() + emitter := newEmitter() callback := func(details EventDetails) { // Simulate some work @@ -459,7 +459,7 @@ func BenchmarkEmitterTrigger(b *testing.B) { } func BenchmarkEmitterMultipleListeners(b *testing.B) { - emitter := NewEmitter() + emitter := newEmitter() // Add multiple listeners for i := 0; i < 10; i++ { diff --git a/evaluate.go b/evaluate.go index 28ff34b..5a57304 100644 --- a/evaluate.go +++ b/evaluate.go @@ -14,10 +14,10 @@ type EvaluateParams struct { // EvaluateDependencies contains dependencies for evaluation type EvaluateDependencies struct { - Context Context - Logger *Logger - ModulesManager *ModulesManager - datafileReader *datafileReader + Context Context + featurevisorLogger *featurevisorLogger + modulesManager *modulesManager + datafileReader *datafileReader // Instance-internal sticky state. Consumers configure it on an instance. sticky *StickyFeatures @@ -38,7 +38,7 @@ func EvaluateWithModules(opts EvaluateOptions) Evaluation { defer func() { if r := recover(); r != nil { - opts.Logger.Error("panic during evaluation", LogDetails{ + opts.featurevisorLogger.Error("panic during evaluation", logDetails{ "error": r, }) @@ -53,7 +53,7 @@ func EvaluateWithModules(opts EvaluateOptions) Evaluation { } }() - modulesManager := opts.ModulesManager + modulesManager := opts.modulesManager modules := modulesManager.GetAll() // run before modules @@ -98,7 +98,7 @@ func Evaluate(options EvaluateOptions) Evaluation { defer func() { if r := recover(); r != nil { // Log the panic and return an error evaluation - options.Logger.Error("panic in evaluate", LogDetails{ + options.featurevisorLogger.Error("panic in evaluate", logDetails{ "panic": r, }) @@ -122,7 +122,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Reason: EvaluationReasonFeatureNotFound, } - options.Logger.Warn("feature not found", LogDetails{ + options.featurevisorLogger.Warn("feature not found", logDetails{ "featureKey": options.FeatureKey, }) @@ -131,7 +131,7 @@ func Evaluate(options EvaluateOptions) Evaluation { // feature: deprecated if options.Type == EvaluationTypeFlag && feature.Deprecated != nil && *feature.Deprecated { - options.Logger.Warn("feature is deprecated", LogDetails{ + options.featurevisorLogger.Warn("feature is deprecated", logDetails{ "featureKey": options.FeatureKey, }) } @@ -151,7 +151,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &[]bool{true}[0], } - options.Logger.Debug("using sticky enabled", LogDetails{ + options.featurevisorLogger.Debug("using sticky enabled", logDetails{ "evaluation": evaluation, }) @@ -169,7 +169,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariationValue: &variationValue, } - options.Logger.Debug("using sticky variation", LogDetails{ + options.featurevisorLogger.Debug("using sticky variation", logDetails{ "evaluation": evaluation, }) @@ -188,7 +188,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableValue: variableValue, } - options.Logger.Debug("using sticky variable", LogDetails{ + options.featurevisorLogger.Debug("using sticky variable", logDetails{ "evaluation": evaluation, }) @@ -217,7 +217,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableKey: options.VariableKey, } - options.Logger.Warn("variable schema not found", LogDetails{ + options.featurevisorLogger.Warn("variable schema not found", logDetails{ "evaluation": evaluation, }) @@ -225,7 +225,7 @@ func Evaluate(options EvaluateOptions) Evaluation { } if variableSchema.Deprecated != nil && *variableSchema.Deprecated { - options.Logger.Warn("variable is deprecated", LogDetails{ + options.featurevisorLogger.Warn("variable is deprecated", logDetails{ "featureKey": options.FeatureKey, "variableKey": *options.VariableKey, }) @@ -240,7 +240,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Reason: EvaluationReasonNoVariations, } - options.Logger.Warn("no variations", LogDetails{ + options.featurevisorLogger.Warn("no variations", logDetails{ "evaluation": evaluation, }) @@ -310,7 +310,7 @@ func Evaluate(options EvaluateOptions) Evaluation { } } - options.Logger.Debug("feature is disabled", LogDetails{ + options.featurevisorLogger.Debug("feature is disabled", logDetails{ "evaluation": evaluation, }) @@ -338,7 +338,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: force.Enabled, } - options.Logger.Debug("forced enabled found", LogDetails{ + options.featurevisorLogger.Debug("forced enabled found", logDetails{ "evaluation": evaluation, }) @@ -359,7 +359,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariationValue: &variation.Value, } - options.Logger.Debug("forced variation found", LogDetails{ + options.featurevisorLogger.Debug("forced variation found", logDetails{ "evaluation": evaluation, }) @@ -382,7 +382,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableValue: variableValue, } - options.Logger.Debug("forced variable", LogDetails{ + options.featurevisorLogger.Debug("forced variable", logDetails{ "evaluation": evaluation, }) @@ -455,7 +455,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &[]bool{requiredFeaturesAreEnabled}[0], } - options.Logger.Debug("required features not enabled", LogDetails{ + options.featurevisorLogger.Debug("required features not enabled", logDetails{ "evaluation": evaluation, }) @@ -468,13 +468,13 @@ func Evaluate(options EvaluateOptions) Evaluation { */ // bucketKey bucketKey := GetBucketKey(GetBucketKeyOptions{ - FeatureKey: options.FeatureKey, - BucketBy: feature.BucketBy, - Context: options.Context, - Logger: options.Logger, + FeatureKey: options.FeatureKey, + BucketBy: feature.BucketBy, + Context: options.Context, + featurevisorLogger: options.featurevisorLogger, }) - for _, module := range options.ModulesManager.GetAll() { + for _, module := range options.modulesManager.GetAll() { if module.BucketKey != nil { bucketKey = module.BucketKey(ConfigureBucketKeyOptions{ FeatureKey: options.FeatureKey, @@ -488,7 +488,7 @@ func Evaluate(options EvaluateOptions) Evaluation { // bucketValue bucketValue := GetBucketedNumber(bucketKey) - for _, module := range options.ModulesManager.GetAll() { + for _, module := range options.modulesManager.GetAll() { if module.BucketValue != nil { bucketValue = module.BucketValue(ConfigureBucketValueOptions{ FeatureKey: options.FeatureKey, @@ -526,7 +526,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &[]bool{false}[0], } - options.Logger.Debug("matched rule with 0 percentage", LogDetails{ + options.featurevisorLogger.Debug("matched rule with 0 percentage", logDetails{ "evaluation": evaluation, }) @@ -563,7 +563,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &enabled, } - options.Logger.Debug("matched", LogDetails{ + options.featurevisorLogger.Debug("matched", logDetails{ "evaluation": evaluation, }) @@ -580,7 +580,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &[]bool{false}[0], } - options.Logger.Debug("not matched", LogDetails{ + options.featurevisorLogger.Debug("not matched", logDetails{ "evaluation": evaluation, }) @@ -600,7 +600,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: matchedTraffic.Enabled, } - options.Logger.Debug("override from rule", LogDetails{ + options.featurevisorLogger.Debug("override from rule", logDetails{ "evaluation": evaluation, }) @@ -620,7 +620,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &[]bool{true}[0], } - options.Logger.Debug("matched traffic", LogDetails{ + options.featurevisorLogger.Debug("matched traffic", logDetails{ "evaluation": evaluation, }) @@ -646,7 +646,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariationValue: &variation.Value, } - options.Logger.Debug("override from rule", LogDetails{ + options.featurevisorLogger.Debug("override from rule", logDetails{ "evaluation": evaluation, }) @@ -692,7 +692,7 @@ func Evaluate(options EvaluateOptions) Evaluation { startRange := currentWeight * 100000 / totalWeight endRange := (currentWeight + weightInt) * 100000 / totalWeight - options.Logger.Debug("checking variation weight range", LogDetails{ + options.featurevisorLogger.Debug("checking variation weight range", logDetails{ "variationValue": vw.value, "weight": weightInt, "startRange": startRange, @@ -717,7 +717,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariationValue: &variation.Value, } - options.Logger.Debug("allocated variation with custom weights", LogDetails{ + options.featurevisorLogger.Debug("allocated variation with custom weights", logDetails{ "evaluation": evaluation, }) @@ -746,7 +746,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariationValue: &variation.Value, } - options.Logger.Debug("allocated variation", LogDetails{ + options.featurevisorLogger.Debug("allocated variation", logDetails{ "evaluation": evaluation, }) @@ -770,7 +770,7 @@ func Evaluate(options EvaluateOptions) Evaluation { BucketValue: &bucketValue, } - options.Logger.Debug("variable schema not found", LogDetails{ + options.featurevisorLogger.Debug("variable schema not found", logDetails{ "evaluation": evaluation, }) @@ -808,7 +808,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableOverrideIndex: &overrideIndex, } - options.Logger.Debug("variable override from rule", LogDetails{ + options.featurevisorLogger.Debug("variable override from rule", logDetails{ "evaluation": evaluation, }) @@ -833,7 +833,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableValue: variableValue, } - options.Logger.Debug("override from rule", LogDetails{ + options.featurevisorLogger.Debug("override from rule", logDetails{ "evaluation": evaluation, }) @@ -891,7 +891,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableOverrideIndex: &overrideIndex, } - options.Logger.Debug("variable override from variation", LogDetails{ + options.featurevisorLogger.Debug("variable override from variation", logDetails{ "evaluation": evaluation, }) @@ -921,7 +921,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableValue: variableValue, } - options.Logger.Debug("allocated variable", LogDetails{ + options.featurevisorLogger.Debug("allocated variable", logDetails{ "evaluation": evaluation, }) @@ -945,7 +945,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableValue: variableSchema.DefaultValue, } - options.Logger.Debug("using default value", LogDetails{ + options.featurevisorLogger.Debug("using default value", logDetails{ "evaluation": evaluation, }) @@ -962,7 +962,7 @@ func Evaluate(options EvaluateOptions) Evaluation { BucketValue: &bucketValue, } - options.Logger.Debug("variable not found", LogDetails{ + options.featurevisorLogger.Debug("variable not found", logDetails{ "evaluation": evaluation, }) @@ -981,7 +981,7 @@ func Evaluate(options EvaluateOptions) Evaluation { BucketValue: &bucketValue, } - options.Logger.Debug("no matched variation", LogDetails{ + options.featurevisorLogger.Debug("no matched variation", logDetails{ "evaluation": evaluation, }) @@ -1001,7 +1001,7 @@ func Evaluate(options EvaluateOptions) Evaluation { VariableValue: variableSchema.DefaultValue, } - options.Logger.Debug("using default value", LogDetails{ + options.featurevisorLogger.Debug("using default value", logDetails{ "evaluation": evaluation, }) @@ -1017,7 +1017,7 @@ func Evaluate(options EvaluateOptions) Evaluation { BucketValue: &bucketValue, } - options.Logger.Debug("variable not found", LogDetails{ + options.featurevisorLogger.Debug("variable not found", logDetails{ "evaluation": evaluation, }) @@ -1033,7 +1033,7 @@ func Evaluate(options EvaluateOptions) Evaluation { Enabled: &[]bool{false}[0], } - options.Logger.Debug("nothing matched", LogDetails{ + options.featurevisorLogger.Debug("nothing matched", logDetails{ "evaluation": evaluation, }) diff --git a/events.go b/events.go index a5114fb..f141bbe 100644 --- a/events.go +++ b/events.go @@ -5,7 +5,7 @@ func getParamsForDatafileSetEvent( previousDatafileReader *datafileReader, newDatafileReader *datafileReader, replace bool, -) LogDetails { +) logDetails { previousRevision := "" if previousDatafileReader != nil { previousRevision = previousDatafileReader.GetRevision() @@ -79,7 +79,7 @@ func getParamsForDatafileSetEvent( // Combine all affected feature keys allAffectedFeatures := append(append(removedFeatures, changedFeatures...), addedFeatures...) - return LogDetails{ + return logDetails{ "revision": newRevision, "previousRevision": previousRevision, "revisionChanged": previousRevision != newRevision, @@ -89,7 +89,7 @@ func getParamsForDatafileSetEvent( } // getParamsForStickySetEvent gets parameters for sticky set event -func getParamsForStickySetEvent(previousStickyFeatures StickyFeatures, newStickyFeatures StickyFeatures, replace bool) LogDetails { +func getParamsForStickySetEvent(previousStickyFeatures StickyFeatures, newStickyFeatures StickyFeatures, replace bool) logDetails { keysBefore := make([]string, 0, len(previousStickyFeatures)) for key := range previousStickyFeatures { keysBefore = append(keysBefore, string(key)) @@ -112,7 +112,7 @@ func getParamsForStickySetEvent(previousStickyFeatures StickyFeatures, newSticky } } - return LogDetails{ + return logDetails{ "features": uniqueFeaturesAffected, "replaced": replace, } diff --git a/events_parity_test.go b/events_parity_test.go index fcf67c0..3594a19 100644 --- a/events_parity_test.go +++ b/events_parity_test.go @@ -3,10 +3,10 @@ package featurevisor import "testing" func TestGetParamsForDatafileSetEventShape(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) previousReader := newDatafileReader(datafileReaderOptions{ - Logger: logger, + featurevisorLogger: logger, Datafile: DatafileContent{ SchemaVersion: "2", Revision: "1", @@ -17,7 +17,7 @@ func TestGetParamsForDatafileSetEventShape(t *testing.T) { }, }) newReader := newDatafileReader(datafileReaderOptions{ - Logger: logger, + featurevisorLogger: logger, Datafile: DatafileContent{ SchemaVersion: "2", Revision: "2", diff --git a/generic_variable_methods_test.go b/generic_variable_methods_test.go index 0ac0399..267ac6d 100644 --- a/generic_variable_methods_test.go +++ b/generic_variable_methods_test.go @@ -97,7 +97,7 @@ func TestGenericVariableMethods(t *testing.T) { t.Fatalf("failed to parse datafile: %v", err) } - sdk := NewFeaturevisor(Options{Datafile: datafile}) + sdk := CreateFeaturevisor(FeaturevisorOptions{Datafile: datafile}) context := Context{"userId": "123"} var arr []string diff --git a/instance.go b/instance.go index 8ed420e..634b6c8 100644 --- a/instance.go +++ b/instance.go @@ -18,12 +18,11 @@ type SpawnOptions struct { Sticky *StickyFeatures } -// Options contains options for creating an instance -type Options struct { +// FeaturevisorOptions contains options for creating an instance. +type FeaturevisorOptions struct { Datafile interface{} // DatafileContent | string Context Context LogLevel *LogLevel - Logger *Logger OnDiagnostic FeaturevisorDiagnosticHandler Sticky *StickyFeatures Modules []*FeaturevisorModule @@ -40,7 +39,7 @@ type moduleDiagnosticSubscription struct { type Featurevisor struct { // from options context Context - logger *Logger + logger *featurevisorLogger logLevel LogLevel onDiagnostic FeaturevisorDiagnosticHandler sticky *StickyFeatures @@ -48,35 +47,57 @@ type Featurevisor struct { // internally created datafile DatafileContent datafileReader *datafileReader - modulesManager *ModulesManager + modulesManager *modulesManager moduleDiagnosticSubscriptions []moduleDiagnosticSubscription nextModuleDiagnosticID int - emitter *Emitter + emitter *emitter closed bool } -// NewFeaturevisor creates a new Featurevisor instance -func NewFeaturevisor(options Options) *Featurevisor { +// CreateFeaturevisor creates a new Featurevisor instance. +func CreateFeaturevisor(options FeaturevisorOptions) *Featurevisor { // Set default context context := Context{} if options.Context != nil { context = options.Context } - // Set default logger - var logger *Logger - if options.Logger != nil { - logger = options.Logger - } else { - level := LogLevelInfo - if options.LogLevel != nil { - level = *options.LogLevel - } - logger = NewLogger(CreateLoggerOptions{Level: &level}) + level := LogLevelInfo + if options.LogLevel != nil { + level = *options.LogLevel } + var instance *Featurevisor + handler := logHandler(func(logLevel LogLevel, message logMessage, details logDetails) { + if instance == nil { + return + } + code := string(message) + if reason, ok := details["reason"].(EvaluationReason); ok { + code = string(reason) + } else if reason, ok := details["reason"].(string); ok { + code = reason + } + if message == "feature is deprecated" { + code = "deprecated_feature" + } else if message == "variable is deprecated" { + code = "deprecated_variable" + } else if message == "feature not found" { + code = "feature_not_found" + } else if message == "variable schema not found" { + code = "variable_not_found" + } else if message == "no variations" { + code = "no_variations" + } else if message == "invalid bucketBy" { + code = "invalid_bucket_by" + } + instance.reportDiagnostic(FeaturevisorDiagnostic{ + Level: logLevel, Code: code, Message: string(message), Details: details, + }, nil) + }) + logger := newLogger(loggerOptions{Level: &level, Handler: &handler}) // Create emitter - emitter := NewEmitter() + emitter := newEmitter() emptyDatafile := DatafileContent{ SchemaVersion: "2", @@ -86,11 +107,11 @@ func NewFeaturevisor(options Options) *Featurevisor { } datafileReader := newDatafileReader(datafileReaderOptions{ - Datafile: emptyDatafile, - Logger: logger, + Datafile: emptyDatafile, + featurevisorLogger: logger, }) - instance := &Featurevisor{ + instance = &Featurevisor{ context: context, logger: logger, logLevel: logger.GetLevel(), @@ -101,7 +122,7 @@ func NewFeaturevisor(options Options) *Featurevisor { sticky: options.Sticky, } - instance.modulesManager = NewModulesManager(ModulesManagerOptions{ + instance.modulesManager = newModulesManager(modulesManagerOptions{ Modules: options.Modules, ReportDiagnostic: instance.reportDiagnostic, GetModuleApi: instance.getModuleApi, @@ -155,8 +176,8 @@ func (i *Featurevisor) SetDatafile(datafile interface{}, replace ...bool) { } newDatafileReader := newDatafileReader(datafileReaderOptions{ - Datafile: storedDatafile, - Logger: i.logger, + Datafile: storedDatafile, + featurevisorLogger: i.logger, }) details := getParamsForDatafileSetEvent(i.datafileReader, newDatafileReader, replaceValue) @@ -219,6 +240,26 @@ func (i *Featurevisor) GetRevision() string { return i.datafileReader.GetRevision() } +func (i *Featurevisor) GetSchemaVersion() string { + return i.datafileReader.GetSchemaVersion() +} + +func (i *Featurevisor) GetSegment(segmentKey string) *Segment { + return i.datafileReader.GetSegment(SegmentKey(segmentKey)) +} + +func (i *Featurevisor) GetFeatureKeys() []string { + return i.datafileReader.GetFeatureKeys() +} + +func (i *Featurevisor) GetVariableKeys(featureKey string) []string { + return i.datafileReader.GetVariableKeys(FeatureKey(featureKey)) +} + +func (i *Featurevisor) HasVariations(featureKey string) bool { + return i.datafileReader.HasVariations(FeatureKey(featureKey)) +} + // GetFeature returns a feature by key func (i *Featurevisor) GetFeature(featureKey string) *Feature { return i.datafileReader.GetFeature(FeatureKey(featureKey)) @@ -299,7 +340,7 @@ func (i *Featurevisor) reportDiagnostic( i.onDiagnostic(diagnostic) }() } else { - details := LogDetails{} + details := logDetails{} if diagnostic.Details != nil { for key, value := range diagnostic.Details { details[key] = value @@ -317,7 +358,7 @@ func (i *Featurevisor) reportDiagnostic( if diagnostic.OriginalError != nil { details["originalError"] = diagnostic.OriginalError } - i.logger.Log(diagnostic.Level, LogMessage(diagnostic.Message), details) + defaultLogHandler(diagnostic.Level, logMessage(diagnostic.Message), details) } } @@ -414,7 +455,7 @@ func (i *Featurevisor) SetContext(context Context, replace ...bool) { Level: LogLevelDebug, Code: "context_set", Message: message, - Details: LogDetails{ + Details: logDetails{ "context": i.context, "replaced": replaceValue, }, @@ -455,7 +496,7 @@ func (i *Featurevisor) Spawn(args ...interface{}) *FeaturevisorChild { } } - return NewFeaturevisorChild(ChildOptions{ + return newFeaturevisorChild(ChildOptions{ Parent: i, Context: i.GetContext(contextValue), Sticky: optionsValue.Sticky, @@ -473,8 +514,8 @@ func (i *Featurevisor) getEvaluationDependencies(context Context, options Overri return EvaluateDependencies{ Context: i.GetContext(context), - Logger: i.logger, - ModulesManager: i.modulesManager, + featurevisorLogger: i.logger, + modulesManager: i.modulesManager, datafileReader: i.datafileReader, sticky: sticky, DefaultVariationValue: options.DefaultVariationValue, @@ -497,7 +538,7 @@ func (i *Featurevisor) EvaluateFlag(featureKey string, context Context, options func (i *Featurevisor) IsEnabled(featureKey string, args ...interface{}) bool { defer func() { if r := recover(); r != nil { - i.logger.Error("isEnabled", LogDetails{ + i.logger.Error("isEnabled", logDetails{ "featureKey": featureKey, "error": r, }) @@ -542,7 +583,7 @@ func (i *Featurevisor) EvaluateVariation(featureKey string, context Context, opt func (i *Featurevisor) GetVariation(featureKey string, args ...interface{}) *string { defer func() { if r := recover(); r != nil { - i.logger.Error("getVariation", LogDetails{ + i.logger.Error("getVariation", logDetails{ "featureKey": featureKey, "error": r, }) @@ -596,7 +637,7 @@ func (i *Featurevisor) EvaluateVariable(featureKey string, variableKey VariableK func (i *Featurevisor) GetVariable(featureKey string, variableKey string, args ...interface{}) VariableValue { defer func() { if r := recover(); r != nil { - i.logger.Error("getVariable", LogDetails{ + i.logger.Error("getVariable", logDetails{ "featureKey": featureKey, "variableKey": variableKey, "error": r, @@ -629,7 +670,7 @@ func (i *Featurevisor) GetVariable(featureKey string, variableKey string, args . return parsedJSON } else { // Log error if JSON parsing fails - i.logger.Error("could not parse JSON variable", LogDetails{ + i.logger.Error("could not parse JSON variable", logDetails{ "featureKey": featureKey, "variableKey": variableKey, "error": err, diff --git a/instance_api_parity_test.go b/instance_api_parity_test.go index 9dcd587..4448ecd 100644 --- a/instance_api_parity_test.go +++ b/instance_api_parity_test.go @@ -3,7 +3,10 @@ package featurevisor import "testing" func TestSetDatafileAcceptsJSONString(t *testing.T) { - instance := NewFeaturevisor(Options{}) + instance := CreateFeaturevisor(FeaturevisorOptions{}) + if instance.GetSchemaVersion() != "2" { + t.Fatal("expected schema version 2") + } instance.SetDatafile(`{ "schemaVersion": "2", "revision": "json-revision", @@ -17,7 +20,7 @@ func TestSetDatafileAcceptsJSONString(t *testing.T) { } func TestInstanceOnReturnsUnsubscribe(t *testing.T) { - instance := NewFeaturevisor(Options{}) + instance := CreateFeaturevisor(FeaturevisorOptions{}) calls := 0 unsubscribe := instance.On(EventNameContextSet, func(details EventDetails) { diff --git a/instance_deprecated_features_test.go b/instance_deprecated_features_test.go index 32d2766..78c14ae 100644 --- a/instance_deprecated_features_test.go +++ b/instance_deprecated_features_test.go @@ -1,27 +1,12 @@ package featurevisor import ( - "strings" "testing" ) func TestDeprecatedFeatures(t *testing.T) { var deprecatedCount int - var capturedLogs []string - - // Create a custom logger to capture warnings level := LogLevelWarn - handler := LogHandler(func(level LogLevel, message LogMessage, details LogDetails) { - if level == LogLevelWarn && strings.Contains(string(message), "is deprecated") { - deprecatedCount++ - } - capturedLogs = append(capturedLogs, string(message)) - }) - - customLogger := NewLogger(CreateLoggerOptions{ - Level: &level, - Handler: &handler, - }) jsonDatafile := `{ "schemaVersion": "2", @@ -75,9 +60,14 @@ func TestDeprecatedFeatures(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := NewFeaturevisor(Options{ + sdk := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, - Logger: customLogger, + LogLevel: &level, + OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { + if diagnostic.Code == "deprecated_feature" { + deprecatedCount++ + } + }, }) context := Context{"userId": "123"} @@ -97,18 +87,6 @@ func TestDeprecatedFeatures(t *testing.T) { t.Errorf("Expected 1 deprecated warning, got %d", deprecatedCount) } - // Check that the warning message contains the expected content - foundDeprecatedWarning := false - for _, log := range capturedLogs { - if strings.Contains(log, "deprecated") { - foundDeprecatedWarning = true - break - } - } - - if !foundDeprecatedWarning { - t.Error("Expected to find deprecated warning in logs") - } } // Helper function to create bool pointers diff --git a/instance_individually_named_segments_test.go b/instance_individually_named_segments_test.go index da26c98..42392d2 100644 --- a/instance_individually_named_segments_test.go +++ b/instance_individually_named_segments_test.go @@ -49,7 +49,7 @@ func TestIndividuallyNamedSegments(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := NewFeaturevisor(Options{ + sdk := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, }) diff --git a/instance_mutually_exclusive_test.go b/instance_mutually_exclusive_test.go index 5028dcd..74dcdd6 100644 --- a/instance_mutually_exclusive_test.go +++ b/instance_mutually_exclusive_test.go @@ -33,7 +33,7 @@ func TestMutuallyExclusiveFeatures(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := NewFeaturevisor(Options{ + sdk := CreateFeaturevisor(FeaturevisorOptions{ Modules: []*FeaturevisorModule{ { Name: "unit-test", diff --git a/instance_overridden_flags_test.go b/instance_overridden_flags_test.go index 936a820..1bb50f6 100644 --- a/instance_overridden_flags_test.go +++ b/instance_overridden_flags_test.go @@ -42,7 +42,7 @@ func TestOverriddenFlagsFromRules(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := NewFeaturevisor(Options{ + sdk := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, }) diff --git a/instance_required_features_test.go b/instance_required_features_test.go index db88198..b1ea056 100644 --- a/instance_required_features_test.go +++ b/instance_required_features_test.go @@ -44,7 +44,7 @@ func TestRequiredFeaturesSimple(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := NewFeaturevisor(Options{ + sdk := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile1, }) @@ -92,7 +92,7 @@ func TestRequiredFeaturesSimple(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk2 := NewFeaturevisor(Options{ + sdk2 := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile2, }) @@ -153,7 +153,7 @@ func TestRequiredFeaturesWithVariation(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := NewFeaturevisor(Options{ + sdk := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile1, }) @@ -212,7 +212,7 @@ func TestRequiredFeaturesWithVariation(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk2 := NewFeaturevisor(Options{ + sdk2 := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile2, }) diff --git a/instance_rule_variable_overrides_test.go b/instance_rule_variable_overrides_test.go index 0994dcf..4d4f859 100644 --- a/instance_rule_variable_overrides_test.go +++ b/instance_rule_variable_overrides_test.go @@ -82,7 +82,7 @@ func TestRuleVariableOverridesParity(t *testing.T) { t.Fatalf("failed to parse datafile: %v", err) } - sdk := NewFeaturevisor(Options{Datafile: datafile}) + sdk := CreateFeaturevisor(FeaturevisorOptions{Datafile: datafile}) // first matching rule override by segments should win (index 0) evaluation := sdk.EvaluateVariable("test", "config", Context{ @@ -208,7 +208,7 @@ func TestVariationVariableOverrideReasonSplit(t *testing.T) { t.Fatalf("failed to parse datafile: %v", err) } - sdk := NewFeaturevisor(Options{Datafile: datafile}) + sdk := CreateFeaturevisor(FeaturevisorOptions{Datafile: datafile}) evaluation := sdk.EvaluateVariable("test", "color", Context{ "userId": "user-1", "country": "de", diff --git a/instance_sticky_features_test.go b/instance_sticky_features_test.go index 15fb76e..c6fdacc 100644 --- a/instance_sticky_features_test.go +++ b/instance_sticky_features_test.go @@ -39,7 +39,7 @@ func TestStickyFeaturesInitialization(t *testing.T) { } // Create instance with sticky features and datafile - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafileContent, Sticky: &StickyFeatures{ "test": EvaluatedFeature{ @@ -122,7 +122,7 @@ func TestStickyFeaturesInitialization(t *testing.T) { // TestSetStickyVariadicSignature tests that the new variadic signature works correctly func TestSetStickyVariadicSignature(t *testing.T) { - instance := NewFeaturevisor(Options{}) + instance := CreateFeaturevisor(FeaturevisorOptions{}) // Test calling without replace parameter (should default to false) sticky1 := StickyFeatures{"test1": EvaluatedFeature{Enabled: true}} diff --git a/instance_test.go b/instance_test.go index a054c0e..2bc271a 100644 --- a/instance_test.go +++ b/instance_test.go @@ -6,8 +6,8 @@ import ( ) func TestCreateInstance(t *testing.T) { - // Test that NewFeaturevisor is a function - instance := NewFeaturevisor(Options{}) + // Test that CreateFeaturevisor is a function + instance := CreateFeaturevisor(FeaturevisorOptions{}) if instance == nil { t.Fatal("Expected instance to be created") } @@ -25,7 +25,7 @@ func TestCreateInstance(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance = NewFeaturevisor(Options{ + instance = CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, }) @@ -78,7 +78,7 @@ func TestPlainBucketBy(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Modules: []*FeaturevisorModule{ { @@ -142,7 +142,7 @@ func TestAndBucketBy(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Modules: []*FeaturevisorModule{ { @@ -207,7 +207,7 @@ func TestOrBucketBy(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Modules: []*FeaturevisorModule{ { @@ -288,7 +288,7 @@ func TestBeforeModule(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Modules: []*FeaturevisorModule{ { @@ -362,7 +362,7 @@ func TestAfterModule(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, Modules: []*FeaturevisorModule{ { @@ -414,7 +414,7 @@ func TestModulesSetupDiagnosticsAndClose(t *testing.T) { var moduleSawDatafileSet bool var diagnostics []FeaturevisorDiagnostic - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: DatafileContent{ SchemaVersion: "2", Revision: "module-test", @@ -494,7 +494,7 @@ func TestModuleSetupFailureIsIsolated(t *testing.T) { var diagnostics []FeaturevisorDiagnostic closed := 0 logLevel := LogLevelError - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ LogLevel: &logLevel, OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { diagnostics = append(diagnostics, diagnostic) @@ -530,7 +530,7 @@ func TestModuleSetupFailureIsIsolated(t *testing.T) { func TestDiagnosticHandlerPanicsAreIsolated(t *testing.T) { seen := 0 logLevel := LogLevelDebug - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ LogLevel: &logLevel, OnDiagnostic: func(FeaturevisorDiagnostic) { panic("broken root handler") @@ -553,7 +553,7 @@ func TestDiagnosticHandlerPanicsAreIsolated(t *testing.T) { func TestRemovedModulesAreClosed(t *testing.T) { closed := []string{} - instance := NewFeaturevisor(Options{}) + instance := CreateFeaturevisor(FeaturevisorOptions{}) unsubscribe := instance.AddModule(&FeaturevisorModule{ Name: "dynamic", @@ -587,7 +587,7 @@ func TestModuleCloseErrorsAreReportedAndDoNotStopCleanup(t *testing.T) { var errorEvents []FeaturevisorDiagnostic closed := []string{} - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { diagnostics = append(diagnostics, diagnostic) }, @@ -647,7 +647,7 @@ func TestModuleCloseErrorsAreReportedAndDoNotStopCleanup(t *testing.T) { func TestModuleUnsubscribeReportsCloseErrors(t *testing.T) { var diagnostics []FeaturevisorDiagnostic - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { diagnostics = append(diagnostics, diagnostic) }, @@ -679,7 +679,7 @@ func TestModuleUnsubscribeReportsCloseErrors(t *testing.T) { } func TestSetDatafileMergesByDefaultAndReplacesWhenRequested(t *testing.T) { - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: DatafileContent{ SchemaVersion: "2", Revision: "1", @@ -817,7 +817,7 @@ func TestGetAllEvaluations(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, }) @@ -961,7 +961,7 @@ func TestGetAllEvaluations(t *testing.T) { func TestLifecycleMutationsReportDiagnostics(t *testing.T) { logLevel := LogLevelDebug diagnostics := []FeaturevisorDiagnostic{} - instance := NewFeaturevisor(Options{ + instance := CreateFeaturevisor(FeaturevisorOptions{ LogLevel: &logLevel, OnDiagnostic: func(diagnostic FeaturevisorDiagnostic) { diagnostics = append(diagnostics, diagnostic) diff --git a/instance_variables_no_variations_test.go b/instance_variables_no_variations_test.go index 8c05df3..32409b6 100644 --- a/instance_variables_no_variations_test.go +++ b/instance_variables_no_variations_test.go @@ -51,7 +51,7 @@ func TestVariablesWithoutVariations(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := NewFeaturevisor(Options{ + sdk := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, }) diff --git a/instance_variables_test.go b/instance_variables_test.go index 5841ec9..9a5d4ed 100644 --- a/instance_variables_test.go +++ b/instance_variables_test.go @@ -112,7 +112,7 @@ func TestVariables(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := NewFeaturevisor(Options{ + sdk := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, }) diff --git a/instance_variations_test.go b/instance_variations_test.go index 40afbd6..1a7697a 100644 --- a/instance_variations_test.go +++ b/instance_variations_test.go @@ -64,7 +64,7 @@ func TestVariationsWithForceRules(t *testing.T) { t.Fatalf("Failed to parse datafile JSON: %v", err) } - sdk := NewFeaturevisor(Options{ + sdk := CreateFeaturevisor(FeaturevisorOptions{ Datafile: datafile, }) diff --git a/logger.go b/logger.go index ee44468..b145acf 100644 --- a/logger.go +++ b/logger.go @@ -16,26 +16,26 @@ const ( LogLevelDebug LogLevel = "debug" ) -// LogMessage represents a log message string -type LogMessage string +// logMessage represents a log message string +type logMessage string -// LogDetails represents additional details for logging -type LogDetails map[string]interface{} +// logDetails represents additional details for logging +type logDetails map[string]interface{} -// LogHandler is a function type for handling log messages -type LogHandler func(level LogLevel, message LogMessage, details LogDetails) +// logHandler is a function type for handling log messages +type logHandler func(level LogLevel, message logMessage, details logDetails) -// CreateLoggerOptions contains options for creating a logger -type CreateLoggerOptions struct { +// loggerOptions contains options for creating a logger +type loggerOptions struct { Level *LogLevel - Handler *LogHandler + Handler *logHandler } -// LoggerPrefix is the prefix used for all log messages -const LoggerPrefix = "[Featurevisor]" +// loggerPrefix is the prefix used for all log messages +const loggerPrefix = "[Featurevisor]" -// DefaultLogHandler is the default logging handler -func DefaultLogHandler(level LogLevel, message LogMessage, details LogDetails) { +// defaultLogHandler is the default logging handler +func defaultLogHandler(level LogLevel, message logMessage, details logDetails) { var method string switch level { @@ -52,7 +52,7 @@ func DefaultLogHandler(level LogLevel, message LogMessage, details LogDetails) { } // Format the log message - logMessage := fmt.Sprintf("%s %s: %s", LoggerPrefix, method, message) + logMessage := fmt.Sprintf("%s %s: %s", loggerPrefix, method, message) // Add details if provided if len(details) > 0 { @@ -62,7 +62,7 @@ func DefaultLogHandler(level LogLevel, message LogMessage, details LogDetails) { // Use appropriate log level switch level { case LogLevelFatal: - log.Fatal(logMessage) + log.Printf("[FATAL] %s", logMessage) case LogLevelError: log.Printf("[ERROR] %s", logMessage) case LogLevelWarn: @@ -76,14 +76,14 @@ func DefaultLogHandler(level LogLevel, message LogMessage, details LogDetails) { } } -// Logger provides logging functionality -type Logger struct { +// featurevisorLogger provides logging functionality +type featurevisorLogger struct { level LogLevel - handle LogHandler + handle logHandler } -// AllLevels contains all available log levels in order of severity -var AllLevels = []LogLevel{ +// allLevels contains all available log levels in order of severity +var allLevels = []LogLevel{ LogLevelFatal, LogLevelError, LogLevelWarn, @@ -91,44 +91,44 @@ var AllLevels = []LogLevel{ LogLevelDebug, // not enabled by default } -// DefaultLevel is the default logging level -var DefaultLevel = LogLevelInfo +// defaultLevel is the default logging level +var defaultLevel = LogLevelInfo -// NewLogger creates a new logger instance -func NewLogger(options CreateLoggerOptions) *Logger { - level := DefaultLevel +// newLogger creates a new logger instance +func newLogger(options loggerOptions) *featurevisorLogger { + level := defaultLevel if options.Level != nil { level = *options.Level } - handler := DefaultLogHandler + handler := defaultLogHandler if options.Handler != nil { handler = *options.Handler } - return &Logger{ + return &featurevisorLogger{ level: level, handle: handler, } } // SetLevel sets the logging level -func (l *Logger) SetLevel(level LogLevel) { +func (l *featurevisorLogger) SetLevel(level LogLevel) { l.level = level } // GetLevel returns the current logging level -func (l *Logger) GetLevel() LogLevel { +func (l *featurevisorLogger) GetLevel() LogLevel { return l.level } // shouldHandle checks if a log level should be handled based on current level -func (l *Logger) shouldHandle(level LogLevel) bool { +func (l *featurevisorLogger) shouldHandle(level LogLevel) bool { currentIndex := -1 targetIndex := -1 // Find indices of current and target levels - for i, logLevel := range AllLevels { + for i, logLevel := range allLevels { if logLevel == l.level { currentIndex = i } @@ -147,44 +147,39 @@ func (l *Logger) shouldHandle(level LogLevel) bool { } // Log logs a message at the specified level -func (l *Logger) Log(level LogLevel, message LogMessage, details LogDetails) { +func (l *featurevisorLogger) Log(level LogLevel, message logMessage, details logDetails) { if !l.shouldHandle(level) { return } if details == nil { - details = make(LogDetails) + details = make(logDetails) } l.handle(level, message, details) } // Debug logs a debug message -func (l *Logger) Debug(message LogMessage, details LogDetails) { +func (l *featurevisorLogger) Debug(message logMessage, details logDetails) { l.Log(LogLevelDebug, message, details) } // Info logs an info message -func (l *Logger) Info(message LogMessage, details LogDetails) { +func (l *featurevisorLogger) Info(message logMessage, details logDetails) { l.Log(LogLevelInfo, message, details) } // Warn logs a warning message -func (l *Logger) Warn(message LogMessage, details LogDetails) { +func (l *featurevisorLogger) Warn(message logMessage, details logDetails) { l.Log(LogLevelWarn, message, details) } // Error logs an error message -func (l *Logger) Error(message LogMessage, details LogDetails) { +func (l *featurevisorLogger) Error(message logMessage, details logDetails) { l.Log(LogLevelError, message, details) } // Fatal logs a fatal message and exits -func (l *Logger) Fatal(message LogMessage, details LogDetails) { +func (l *featurevisorLogger) Fatal(message logMessage, details logDetails) { l.Log(LogLevelFatal, message, details) } - -// CreateLogger creates a new logger with the given options -func CreateLogger(options CreateLoggerOptions) *Logger { - return NewLogger(options) -} diff --git a/logger_test.go b/logger_test.go index f48dfd2..e6e5d1b 100644 --- a/logger_test.go +++ b/logger_test.go @@ -35,27 +35,27 @@ func TestAllLevelsOrder(t *testing.T) { LogLevelDebug, } - if len(AllLevels) != len(expectedOrder) { - t.Errorf("AllLevels length = %d, expected %d", len(AllLevels), len(expectedOrder)) + if len(allLevels) != len(expectedOrder) { + t.Errorf("allLevels length = %d, expected %d", len(allLevels), len(expectedOrder)) } - for i, level := range AllLevels { + for i, level := range allLevels { if level != expectedOrder[i] { - t.Errorf("AllLevels[%d] = %s, expected %s", i, level, expectedOrder[i]) + t.Errorf("allLevels[%d] = %s, expected %s", i, level, expectedOrder[i]) } } } func TestNewLogger(t *testing.T) { // Test default logger - logger := NewLogger(CreateLoggerOptions{}) - if logger.GetLevel() != DefaultLevel { - t.Errorf("Default level = %s, expected %s", logger.GetLevel(), DefaultLevel) + logger := newLogger(loggerOptions{}) + if logger.GetLevel() != defaultLevel { + t.Errorf("Default level = %s, expected %s", logger.GetLevel(), defaultLevel) } // Test logger with custom level customLevel := LogLevelDebug - logger = NewLogger(CreateLoggerOptions{ + logger = newLogger(loggerOptions{ Level: &customLevel, }) if logger.GetLevel() != customLevel { @@ -64,7 +64,7 @@ func TestNewLogger(t *testing.T) { } func TestLoggerSetLevel(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) // Test setting different levels testLevels := []LogLevel{LogLevelDebug, LogLevelInfo, LogLevelWarn, LogLevelError, LogLevelFatal} @@ -78,7 +78,7 @@ func TestLoggerSetLevel(t *testing.T) { } func TestLoggerShouldHandle(t *testing.T) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) tests := []struct { name string @@ -163,32 +163,32 @@ func TestDefaultLogHandler(t *testing.T) { // Test different log levels testCases := []struct { level LogLevel - message LogMessage - details LogDetails + message logMessage + details logDetails expect string }{ { level: LogLevelInfo, message: "Test info message", - details: LogDetails{"key": "value"}, + details: logDetails{"key": "value"}, expect: "[INFO]", }, { level: LogLevelWarn, message: "Test warning message", - details: LogDetails{"warning": true}, + details: logDetails{"warning": true}, expect: "[WARN]", }, { level: LogLevelError, message: "Test error message", - details: LogDetails{"error": "test"}, + details: logDetails{"error": "test"}, expect: "[ERROR]", }, { level: LogLevelDebug, message: "Test debug message", - details: LogDetails{"debug": "info"}, + details: logDetails{"debug": "info"}, expect: "[DEBUG]", }, } @@ -197,7 +197,7 @@ func TestDefaultLogHandler(t *testing.T) { t.Run(string(tc.level), func(t *testing.T) { buf.Reset() - DefaultLogHandler(tc.level, tc.message, tc.details) + defaultLogHandler(tc.level, tc.message, tc.details) output := buf.String() if !strings.Contains(output, tc.expect) { @@ -217,7 +217,7 @@ func TestLoggerMethods(t *testing.T) { defer log.SetOutput(os.Stderr) level := LogLevelInfo - logger := NewLogger(CreateLoggerOptions{ + logger := newLogger(loggerOptions{ Level: &level, }) @@ -230,28 +230,28 @@ func TestLoggerMethods(t *testing.T) { { name: "Debug", method: func() { - logger.Debug("debug message", LogDetails{"debug": true}) + logger.Debug("debug message", logDetails{"debug": true}) }, expect: "", // Debug messages should be filtered out at info level }, { name: "Info", method: func() { - logger.Info("info message", LogDetails{"info": true}) + logger.Info("info message", logDetails{"info": true}) }, expect: "info", }, { name: "Warn", method: func() { - logger.Warn("warn message", LogDetails{"warn": true}) + logger.Warn("warn message", logDetails{"warn": true}) }, expect: "warn", }, { name: "Error", method: func() { - logger.Error("error message", LogDetails{"error": true}) + logger.Error("error message", logDetails{"error": true}) }, expect: "error", }, @@ -280,21 +280,21 @@ func TestLoggerMethods(t *testing.T) { func TestCustomLogHandler(t *testing.T) { var capturedLevel LogLevel - var capturedMessage LogMessage - var capturedDetails LogDetails + var capturedMessage logMessage + var capturedDetails logDetails - var customHandler LogHandler = func(level LogLevel, message LogMessage, details LogDetails) { + var customHandler logHandler = func(level LogLevel, message logMessage, details logDetails) { capturedLevel = level capturedMessage = message capturedDetails = details } - logger := NewLogger(CreateLoggerOptions{ + logger := newLogger(loggerOptions{ Handler: &customHandler, }) - expectedMessage := LogMessage("test message") - expectedDetails := LogDetails{"key": "value"} + expectedMessage := logMessage("test message") + expectedDetails := logDetails{"key": "value"} logger.Info(expectedMessage, expectedDetails) @@ -309,16 +309,16 @@ func TestCustomLogHandler(t *testing.T) { } } -func TestCreateLogger(t *testing.T) { +func TestInternalLoggerFactory(t *testing.T) { // Test with no options - logger := CreateLogger(CreateLoggerOptions{}) - if logger.GetLevel() != DefaultLevel { - t.Errorf("CreateLogger default level = %s, expected %s", logger.GetLevel(), DefaultLevel) + logger := newLogger(loggerOptions{}) + if logger.GetLevel() != defaultLevel { + t.Errorf("CreateLogger default level = %s, expected %s", logger.GetLevel(), defaultLevel) } // Test with custom options customLevel := LogLevelDebug - logger = CreateLogger(CreateLoggerOptions{ + logger = newLogger(loggerOptions{ Level: &customLevel, }) if logger.GetLevel() != customLevel { @@ -327,10 +327,10 @@ func TestCreateLogger(t *testing.T) { } func BenchmarkLoggerInfo(b *testing.B) { - logger := NewLogger(CreateLoggerOptions{}) + logger := newLogger(loggerOptions{}) b.ResetTimer() for i := 0; i < b.N; i++ { - logger.Info("benchmark message", LogDetails{"benchmark": i}) + logger.Info("benchmark message", logDetails{"benchmark": i}) } } diff --git a/modules.go b/modules.go index b1cc413..fa440e2 100644 --- a/modules.go +++ b/modules.go @@ -49,25 +49,25 @@ func getModuleName(module *FeaturevisorModule) string { return module.Name } -// ModulesManagerOptions contains options for creating a modules manager. -type ModulesManagerOptions struct { +// modulesManagerOptions contains options for creating a modules manager. +type modulesManagerOptions struct { Modules []*FeaturevisorModule ReportDiagnostic FeaturevisorDiagnosticReporter GetModuleApi func(module *FeaturevisorModule) FeaturevisorModuleApi ClearModuleDiagnosticSubscriptions func(module *FeaturevisorModule) } -// ModulesManager manages Featurevisor modules. -type ModulesManager struct { +// modulesManager manages Featurevisor modules. +type modulesManager struct { modules []*FeaturevisorModule reportDiagnostic FeaturevisorDiagnosticReporter getModuleApi func(module *FeaturevisorModule) FeaturevisorModuleApi clearModuleDiagnosticSubscriptions func(module *FeaturevisorModule) } -// NewModulesManager creates a new modules manager instance. -func NewModulesManager(options ModulesManagerOptions) *ModulesManager { - mm := &ModulesManager{ +// newModulesManager creates a new modules manager instance. +func newModulesManager(options modulesManagerOptions) *modulesManager { + mm := &modulesManager{ modules: make([]*FeaturevisorModule, 0), reportDiagnostic: options.ReportDiagnostic, getModuleApi: options.GetModuleApi, @@ -84,7 +84,7 @@ func NewModulesManager(options ModulesManagerOptions) *ModulesManager { } // Add adds a module to the modules manager. -func (mm *ModulesManager) Add(module *FeaturevisorModule) FeaturevisorUnsubscribe { +func (mm *modulesManager) Add(module *FeaturevisorModule) FeaturevisorUnsubscribe { if module == nil { return nil } @@ -152,7 +152,7 @@ func (mm *ModulesManager) Add(module *FeaturevisorModule) FeaturevisorUnsubscrib } } -func (mm *ModulesManager) closeModule(module *FeaturevisorModule) { +func (mm *modulesManager) closeModule(module *FeaturevisorModule) { if module == nil || module.Close == nil { return } @@ -173,7 +173,7 @@ func (mm *ModulesManager) closeModule(module *FeaturevisorModule) { } // Remove removes modules by name. -func (mm *ModulesManager) Remove(name string) { +func (mm *modulesManager) Remove(name string) { removedModules := []*FeaturevisorModule{} keptModules := []*FeaturevisorModule{} @@ -199,12 +199,12 @@ func (mm *ModulesManager) Remove(name string) { } // GetAll returns all modules. -func (mm *ModulesManager) GetAll() []*FeaturevisorModule { +func (mm *modulesManager) GetAll() []*FeaturevisorModule { return mm.modules } // CloseAll closes and removes all modules. -func (mm *ModulesManager) CloseAll() { +func (mm *modulesManager) CloseAll() { modules := append([]*FeaturevisorModule{}, mm.modules...) mm.modules = []*FeaturevisorModule{} From 00ff1f1a02bad3e4896bb05dcc4efce24a41c930 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 20:39:27 +0200 Subject: [PATCH 11/13] alignment --- README.md | 2 ++ diagnostics.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7586355..642667a 100644 --- a/README.md +++ b/README.md @@ -597,6 +597,8 @@ unsubscribe := f.On(featurevisor.EventNameError, func(details featurevisor.Event }) ``` +The `error` event is emitted for diagnostics whose level is `error`. + ## Evaluation details Besides logging with debug level enabled, you can also get more details about how the feature variations and variables are evaluated in the runtime against given context: diff --git a/diagnostics.go b/diagnostics.go index 1934298..56a4948 100644 --- a/diagnostics.go +++ b/diagnostics.go @@ -10,7 +10,7 @@ type FeaturevisorDiagnostic struct { Module string `json:"module,omitempty"` ModuleName string `json:"moduleName,omitempty"` OriginalError interface{} `json:"originalError,omitempty"` - Details map[string]interface{} `json:"details,omitempty"` + Details map[string]interface{} `json:"details"` } // FeaturevisorModuleReportedDiagnostic is a diagnostic reported by a module. From 9c694e8649cecce64aa561f6f7e9c41b4b15d6b1 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 20:49:03 +0200 Subject: [PATCH 12/13] alignment --- diagnostics.go | 2 +- instance.go | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/diagnostics.go b/diagnostics.go index 56a4948..5600d2c 100644 --- a/diagnostics.go +++ b/diagnostics.go @@ -5,7 +5,7 @@ const FeaturevisorDiagnosticPrefix = "[Featurevisor]" // FeaturevisorDiagnostic is emitted by the SDK and modules for logs/errors. type FeaturevisorDiagnostic struct { Level LogLevel `json:"level"` - Code string `json:"code,omitempty"` + Code string `json:"code"` Message string `json:"message"` Module string `json:"module,omitempty"` ModuleName string `json:"moduleName,omitempty"` diff --git a/instance.go b/instance.go index 634b6c8..cf46aca 100644 --- a/instance.go +++ b/instance.go @@ -910,7 +910,7 @@ func parseDatafileInput(datafile interface{}) (DatafileContent, error) { if err := datafileContent.FromJSON(value); err != nil { return DatafileContent{}, fmt.Errorf("invalid datafile string: %w", err) } - return datafileContent, nil + return validateDatafileContent(datafileContent) case map[string]interface{}: bytes, err := json.Marshal(value) if err != nil { @@ -921,15 +921,23 @@ func parseDatafileInput(datafile interface{}) (DatafileContent, error) { return DatafileContent{}, fmt.Errorf("invalid datafile map: %w", err) } - return datafileContent, nil + return validateDatafileContent(datafileContent) case DatafileContent: - return value, nil + return validateDatafileContent(value) case *DatafileContent: if value == nil { return DatafileContent{}, fmt.Errorf("datafile pointer is nil") } - return *value, nil + return validateDatafileContent(*value) default: return DatafileContent{}, fmt.Errorf("unsupported datafile input type: %T", datafile) } } + +func validateDatafileContent(datafile DatafileContent) (DatafileContent, error) { + if datafile.SchemaVersion == "" || datafile.Revision == "" || datafile.Segments == nil || datafile.Features == nil { + return DatafileContent{}, fmt.Errorf("invalid datafile") + } + + return datafile, nil +} From 2f4fca58dd9d1d1e504a93a174faa14091d1e8ba Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 22:57:25 +0200 Subject: [PATCH 13/13] README updates --- Makefile | 2 +- README.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 5c99c77..79c9e25 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ test: test-example-1: go test ./... - go run cmd/main.go test --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 --onlyFailures + go run cmd/main.go test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures clean: rm -rf build diff --git a/README.md b/README.md index 642667a..20efb24 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This is a port of Featurevisor [Javascript SDK](https://featurevisor.com/docs/sdks/javascript/) v3.x to Go, providing a way to evaluate feature flags, variations, and variables in your Go applications. -This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 projects and v2 datafiles. +This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 projects (and also v2 datafiles). See example application [here](https://github.com/featurevisor/featurevisor-example-go). @@ -532,7 +532,6 @@ Every diagnostic has `Level`, `Code`, `Message`, and an object-shaped `Details` Diagnostic handlers are isolated from SDK behavior. A panic in a handler does not stop other handlers or evaluations. - ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime.