diff --git a/backend/internal/http/router/handlers-web.go b/backend/internal/http/router/handlers-web.go index e66e351d4..235cae509 100644 --- a/backend/internal/http/router/handlers-web.go +++ b/backend/internal/http/router/handlers-web.go @@ -8,6 +8,7 @@ import ( "log" "math/rand" "net/http" + "openreplay/backend/pkg/featureflags" "strconv" "time" @@ -113,6 +114,14 @@ func (e *Router) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request) return } + ua := e.services.UaParser.ParseFromHTTPRequest(r) + if ua == nil { + ResponseWithError(w, http.StatusForbidden, errors.New("browser not recognized"), startTime, r.URL.Path, bodySize) + return + } + + geoInfo := e.ExtractGeoData(r) + userUUID := uuid.GetUUID(req.UserUUID) tokenData, err := e.services.Tokenizer.Parse(req.Token) if err != nil || req.Reset { // Starting the new one @@ -122,11 +131,6 @@ func (e *Router) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request) return } - ua := e.services.UaParser.ParseFromHTTPRequest(r) - if ua == nil { - ResponseWithError(w, http.StatusForbidden, errors.New("browser not recognized"), startTime, r.URL.Path, bodySize) - return - } startTimeMili := startTime.UnixMilli() sessionID, err := e.services.Flaker.Compose(uint64(startTimeMili)) if err != nil { @@ -140,7 +144,6 @@ func (e *Router) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request) Delay: startTimeMili - req.Timestamp, ExpTime: expTime.UnixMilli(), } - geoInfo := e.ExtractGeoData(r) sessionStart := &SessionStart{ Timestamp: getSessionTimestamp(req, startTimeMili), @@ -178,6 +181,12 @@ func (e *Router) startSessionHandlerWeb(w http.ResponseWriter, r *http.Request) ResponseWithJSON(w, &StartSessionResponse{ Token: e.services.Tokenizer.Compose(*tokenData), UserUUID: userUUID, + UserOS: ua.OS, + UserDevice: ua.Device, + UserBrowser: ua.Browser, + UserCountry: geoInfo.Country, + UserState: geoInfo.State, + UserCity: geoInfo.City, SessionID: strconv.FormatUint(tokenData.ID, 10), ProjectID: strconv.FormatUint(uint64(p.ProjectID), 10), BeaconSizeLimit: e.getBeaconSize(tokenData.ID), @@ -281,3 +290,59 @@ func (e *Router) notStartedHandlerWeb(w http.ResponseWriter, r *http.Request) { ResponseOK(w, startTime, r.URL.Path, bodySize) } + +func (e *Router) featureFlagsHandlerWeb(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + bodySize := 0 + + // Check authorization + _, err := e.services.Tokenizer.ParseFromHTTPRequest(r) + if err != nil { + ResponseWithError(w, http.StatusUnauthorized, err, startTime, r.URL.Path, bodySize) + return + } + + // Check request body + if r.Body == nil { + ResponseWithError(w, http.StatusBadRequest, errors.New("request body is empty"), startTime, r.URL.Path, bodySize) + return + } + + bodyBytes, err := e.readBody(w, r, e.cfg.JsonSizeLimit) + if err != nil { + log.Printf("error while reading request body: %s", err) + ResponseWithError(w, http.StatusRequestEntityTooLarge, err, startTime, r.URL.Path, bodySize) + return + } + bodySize = len(bodyBytes) + + // Parse request body + req := &featureflags.FeatureFlagsRequest{} + + if err := json.Unmarshal(bodyBytes, req); err != nil { + ResponseWithError(w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + + // Grab flags and conditions for project + projectID, err := strconv.ParseUint(req.ProjectID, 10, 32) + if err != nil { + ResponseWithError(w, http.StatusBadRequest, err, startTime, r.URL.Path, bodySize) + return + } + flags, err := e.services.Database.GetFeatureFlags(uint32(projectID)) + if err != nil { + ResponseWithError(w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) + return + } + + computedFlags, err := featureflags.ComputeFeatureFlags(flags, req) + if err != nil { + ResponseWithError(w, http.StatusInternalServerError, err, startTime, r.URL.Path, bodySize) + return + } + resp := &featureflags.FeatureFlagsResponse{ + Flags: computedFlags, + } + ResponseWithJSON(w, resp, startTime, r.URL.Path, bodySize) +} diff --git a/backend/internal/http/router/model.go b/backend/internal/http/router/model.go index 0b3a3234e..7d68050d9 100644 --- a/backend/internal/http/router/model.go +++ b/backend/internal/http/router/model.go @@ -20,6 +20,12 @@ type StartSessionResponse struct { Delay int64 `json:"delay"` Token string `json:"token"` UserUUID string `json:"userUUID"` + UserOS string `json:"userOS"` + UserDevice string `json:"userDevice"` + UserBrowser string `json:"userBrowser"` + UserCountry string `json:"userCountry"` + UserState string `json:"userState"` + UserCity string `json:"userCity"` SessionID string `json:"sessionID"` ProjectID string `json:"projectID"` BeaconSizeLimit int64 `json:"beaconSizeLimit"` diff --git a/backend/internal/http/router/router.go b/backend/internal/http/router/router.go index 0c7fdf09c..822d05900 100644 --- a/backend/internal/http/router/router.go +++ b/backend/internal/http/router/router.go @@ -101,9 +101,10 @@ func (e *Router) init() { e.router.HandleFunc("/", e.root) handlers := map[string]func(http.ResponseWriter, *http.Request){ - "/v1/web/not-started": e.notStartedHandlerWeb, - "/v1/web/start": e.startSessionHandlerWeb, - "/v1/web/i": e.pushMessagesHandlerWeb, + "/v1/web/not-started": e.notStartedHandlerWeb, + "/v1/web/start": e.startSessionHandlerWeb, + "/v1/web/i": e.pushMessagesHandlerWeb, + "/v1/web/feature-flags": e.featureFlagsHandlerWeb, } prefix := "/ingest" diff --git a/backend/pkg/db/postgres/feature-flag.go b/backend/pkg/db/postgres/feature-flag.go new file mode 100644 index 000000000..7872057a6 --- /dev/null +++ b/backend/pkg/db/postgres/feature-flag.go @@ -0,0 +1,45 @@ +package postgres + +import ( + "openreplay/backend/pkg/featureflags" +) + +func (conn *Conn) GetFeatureFlags(projectID uint32) ([]*featureflags.FeatureFlag, error) { + rows, err := conn.c.Query(` + SELECT ff.flag_id, ff.flag_key, ff.flag_type, ff.is_persist, ff.payload, ff.rollout_percentages, ff.filters, + ARRAY_AGG(fv.value) as values, + ARRAY_AGG(fv.payload) as payloads, + ARRAY_AGG(fv.rollout_percentage) AS variants_percentages + FROM ( + SELECT ff.feature_flag_id AS flag_id, ff.flag_key AS flag_key, ff.flag_type, ff.is_persist, ff.payload, + ARRAY_AGG(fc.rollout_percentage) AS rollout_percentages, + ARRAY_AGG(fc.filters) AS filters + FROM public.feature_flags ff + LEFT JOIN public.feature_flags_conditions fc ON ff.feature_flag_id = fc.feature_flag_id + WHERE ff.project_id = $1 AND ff.is_active = TRUE + GROUP BY ff.feature_flag_id + ) AS ff + LEFT JOIN public.feature_flags_variants fv ON ff.flag_type = 'multi' AND ff.flag_id = fv.feature_flag_id + GROUP BY ff.flag_id, ff.flag_key, ff.flag_type, ff.is_persist, ff.payload, ff.filters, ff.rollout_percentages; + `, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + + var flags []*featureflags.FeatureFlag + + for rows.Next() { + var flag featureflags.FeatureFlagPG + if err := rows.Scan(&flag.FlagID, &flag.FlagKey, &flag.FlagType, &flag.IsPersist, &flag.Payload, &flag.RolloutPercentages, + &flag.Filters, &flag.Values, &flag.Payloads, &flag.VariantRollout); err != nil { + return nil, err + } + parsedFlag, err := featureflags.ParseFeatureFlag(&flag) + if err != nil { + return nil, err + } + flags = append(flags, parsedFlag) + } + return flags, nil +} diff --git a/backend/pkg/db/postgres/messages-web.go b/backend/pkg/db/postgres/messages-web.go index 3f93ae35d..759a7b721 100644 --- a/backend/pkg/db/postgres/messages-web.go +++ b/backend/pkg/db/postgres/messages-web.go @@ -4,11 +4,11 @@ import ( "log" "openreplay/backend/pkg/db/types" "openreplay/backend/pkg/hashid" - . "openreplay/backend/pkg/messages" + "openreplay/backend/pkg/messages" "openreplay/backend/pkg/url" ) -func (conn *Conn) InsertWebCustomEvent(sessionID uint64, projectID uint32, e *CustomEvent) error { +func (conn *Conn) InsertWebCustomEvent(sessionID uint64, projectID uint32, e *messages.CustomEvent) error { err := conn.InsertCustomEvent( sessionID, uint64(e.Meta().Timestamp), @@ -22,7 +22,7 @@ func (conn *Conn) InsertWebCustomEvent(sessionID uint64, projectID uint32, e *Cu return err } -func (conn *Conn) InsertWebUserID(sessionID uint64, projectID uint32, userID *UserID) error { +func (conn *Conn) InsertWebUserID(sessionID uint64, projectID uint32, userID *messages.UserID) error { err := conn.InsertUserID(sessionID, userID.ID) if err == nil { conn.insertAutocompleteValue(sessionID, projectID, "USERID", userID.ID) @@ -30,7 +30,7 @@ func (conn *Conn) InsertWebUserID(sessionID uint64, projectID uint32, userID *Us return err } -func (conn *Conn) InsertWebUserAnonymousID(sessionID uint64, projectID uint32, userAnonymousID *UserAnonymousID) error { +func (conn *Conn) InsertWebUserAnonymousID(sessionID uint64, projectID uint32, userAnonymousID *messages.UserAnonymousID) error { err := conn.InsertUserAnonymousID(sessionID, userAnonymousID.ID) if err == nil { conn.insertAutocompleteValue(sessionID, projectID, "USERANONYMOUSID", userAnonymousID.ID) @@ -38,7 +38,7 @@ func (conn *Conn) InsertWebUserAnonymousID(sessionID uint64, projectID uint32, u return err } -func (conn *Conn) InsertWebPageEvent(sessionID uint64, projectID uint32, e *PageEvent) error { +func (conn *Conn) InsertWebPageEvent(sessionID uint64, projectID uint32, e *messages.PageEvent) error { host, path, query, err := url.GetURLParts(e.URL) if err != nil { return err @@ -60,7 +60,7 @@ func (conn *Conn) InsertWebPageEvent(sessionID uint64, projectID uint32, e *Page return nil } -func (conn *Conn) InsertWebClickEvent(sessionID uint64, projectID uint32, e *MouseClick) error { +func (conn *Conn) InsertWebClickEvent(sessionID uint64, projectID uint32, e *messages.MouseClick) error { if e.Label == "" { return nil } @@ -76,7 +76,7 @@ func (conn *Conn) InsertWebClickEvent(sessionID uint64, projectID uint32, e *Mou return nil } -func (conn *Conn) InsertWebInputEvent(sessionID uint64, projectID uint32, e *InputEvent) error { +func (conn *Conn) InsertWebInputEvent(sessionID uint64, projectID uint32, e *messages.InputEvent) error { if e.Label == "" { return nil } @@ -88,7 +88,7 @@ func (conn *Conn) InsertWebInputEvent(sessionID uint64, projectID uint32, e *Inp return nil } -func (conn *Conn) InsertWebInputDuration(sessionID uint64, projectID uint32, e *InputChange) error { +func (conn *Conn) InsertWebInputDuration(sessionID uint64, projectID uint32, e *messages.InputChange) error { if e.Label == "" { return nil } @@ -117,7 +117,7 @@ func (conn *Conn) InsertWebErrorEvent(sessionID uint64, projectID uint32, e *typ return nil } -func (conn *Conn) InsertWebNetworkRequest(sessionID uint64, projectID uint32, savePayload bool, e *NetworkRequest) error { +func (conn *Conn) InsertWebNetworkRequest(sessionID uint64, projectID uint32, savePayload bool, e *messages.NetworkRequest) error { var request, response *string if savePayload { request = &e.Request @@ -133,7 +133,7 @@ func (conn *Conn) InsertWebNetworkRequest(sessionID uint64, projectID uint32, sa return nil } -func (conn *Conn) InsertWebGraphQL(sessionID uint64, projectID uint32, savePayload bool, e *GraphQL) error { +func (conn *Conn) InsertWebGraphQL(sessionID uint64, projectID uint32, savePayload bool, e *messages.GraphQL) error { var request, response *string if savePayload { request = &e.Variables @@ -157,7 +157,7 @@ func (conn *Conn) InsertSessionReferrer(sessionID uint64, referrer string) error referrer, url.DiscardURLQuery(referrer), sessionID) } -func (conn *Conn) InsertMouseThrashing(sessionID uint64, projectID uint32, e *MouseThrashing) error { +func (conn *Conn) InsertMouseThrashing(sessionID uint64, projectID uint32, e *messages.MouseThrashing) error { issueID := hashid.MouseThrashingID(projectID, sessionID, e.Timestamp) if err := conn.bulks.Get("webIssues").Append(projectID, issueID, "mouse_thrashing", e.Url); err != nil { log.Printf("insert web issue err: %s", err) diff --git a/backend/pkg/featureflags/feature-flag.go b/backend/pkg/featureflags/feature-flag.go new file mode 100644 index 000000000..5d0757364 --- /dev/null +++ b/backend/pkg/featureflags/feature-flag.go @@ -0,0 +1,357 @@ +package featureflags + +import ( + "encoding/json" + "fmt" + "github.com/jackc/pgtype" + "log" + "math/rand" + "strconv" + "strings" + "time" +) + +type FeatureFlagsRequest struct { + ProjectID string `json:"projectID"` + UserOS string `json:"os"` + UserDevice string `json:"device"` + UserCountry string `json:"country"` + UserState string `json:"state"` + UserCity string `json:"city"` + UserBrowser string `json:"browser"` + Referrer string `json:"referrer"` + UserID string `json:"userID"` + Metadata map[string]string `json:"metadata"` + PersistFlags map[string]interface{} `json:"persistFlags"` // bool or string +} + +type FeatureFlagsResponse struct { + Flags []interface{} `json:"flags"` +} + +type FilterType string + +const ( + UserCountry FilterType = "userCountry" + UserCity FilterType = "userCity" + UserState FilterType = "userState" + UserOS FilterType = "userOs" + UserBrowser FilterType = "userBrowser" + UserDevice FilterType = "userDevice" + UserID FilterType = "userId" + Referrer FilterType = "referrer" + Metadata FilterType = "metadata" +) + +type FilterOperator string + +const ( + Is FilterOperator = "is" + IsNot FilterOperator = "isNot" + IsAny FilterOperator = "isAny" + Contains FilterOperator = "contains" + NotContains FilterOperator = "notContains" + StartsWith FilterOperator = "startsWith" + EndsWith FilterOperator = "endsWith" + IsUndefined FilterOperator = "isUndefined" +) + +type FeatureFlagFilter struct { + Type FilterType `json:"type"` + Operator FilterOperator `json:"operator"` + Source string `json:"source"` + Values []string `json:"value"` +} + +type FeatureFlagCondition struct { + Filters []*FeatureFlagFilter + RolloutPercentage int +} + +type FeatureFlagVariant struct { + Value string + Payload string + RolloutPercentage int +} + +type FlagType string + +const ( + Single FlagType = "single" + Multi FlagType = "multi" +) + +type FeatureFlag struct { + FlagID uint32 + FlagKey string + FlagType FlagType + IsPersist bool + Payload string + Conditions []*FeatureFlagCondition + Variants []*FeatureFlagVariant +} + +type FeatureFlagPG struct { + FlagID uint32 + FlagKey string + FlagType string + IsPersist bool + Payload *string + RolloutPercentages pgtype.EnumArray + Filters pgtype.TextArray + Values pgtype.TextArray + Payloads pgtype.TextArray + VariantRollout pgtype.EnumArray +} + +type flagInfo struct { + Key string `json:"key"` + IsPersist bool `json:"is_persist"` + Value interface{} `json:"value"` + Payload string `json:"payload"` +} + +func numArrayToIntSlice(arr *pgtype.EnumArray) []int { + slice := make([]int, 0, len(arr.Elements)) + for i := range arr.Elements { + num, err := strconv.Atoi(arr.Elements[i].String) + if err != nil { + log.Printf("can't convert string to int: %v, full arr struct: %+v", err, *arr) + slice = append(slice, 0) + } else { + slice = append(slice, num) + } + } + return slice +} + +func parseFlagConditions(conditions *pgtype.TextArray, rolloutPercentages *pgtype.EnumArray) ([]*FeatureFlagCondition, error) { + percents := numArrayToIntSlice(rolloutPercentages) + if len(conditions.Elements) != len(percents) { + return nil, fmt.Errorf("error: len(conditions.Elements) != len(percents)") + } + conds := make([]*FeatureFlagCondition, 0, len(conditions.Elements)) + for i, currCond := range conditions.Elements { + var filters []*FeatureFlagFilter + + err := json.Unmarshal([]byte(currCond.String), &filters) + if err != nil { + return nil, fmt.Errorf("filter unmarshal error: %v", err) + } + conds = append(conds, &FeatureFlagCondition{ + Filters: filters, + RolloutPercentage: percents[i], + }) + } + return conds, nil +} + +func parseFlagVariants(values *pgtype.TextArray, payloads *pgtype.TextArray, variantRollout *pgtype.EnumArray) ([]*FeatureFlagVariant, error) { + percents := numArrayToIntSlice(variantRollout) + variants := make([]*FeatureFlagVariant, 0, len(values.Elements)) + if len(values.Elements) != len(payloads.Elements) || len(values.Elements) != len(percents) { + return nil, fmt.Errorf("wrong number of variant elements") + } + for i := range values.Elements { + variants = append(variants, &FeatureFlagVariant{ + Value: values.Elements[i].String, + Payload: payloads.Elements[i].String, + RolloutPercentage: percents[i], + }) + } + return variants, nil +} + +func ParseFeatureFlag(rawFlag *FeatureFlagPG) (*FeatureFlag, error) { + flag := &FeatureFlag{ + FlagID: rawFlag.FlagID, + FlagKey: rawFlag.FlagKey, + FlagType: FlagType(rawFlag.FlagType), + IsPersist: rawFlag.IsPersist, + Payload: func() string { + if rawFlag.Payload != nil { + return *rawFlag.Payload + } + return "" + }(), + } + // Parse conditions + conditions, err := parseFlagConditions(&rawFlag.Filters, &rawFlag.RolloutPercentages) + if err != nil { + return nil, fmt.Errorf("error: parseFlagConditions: %v", err) + } + flag.Conditions = conditions + + if flag.FlagType == Single { + flag.Variants = []*FeatureFlagVariant{} + return flag, nil + } + + // Parse variants + variants, err := parseFlagVariants(&rawFlag.Values, &rawFlag.Payloads, &rawFlag.VariantRollout) + if err != nil { + return nil, fmt.Errorf("error: parseFlagVariants: %v", err) + } + flag.Variants = variants + return flag, nil +} + +func checkCondition(varValue string, exprValues []string, operator FilterOperator) bool { + switch operator { + case Is: + for _, value := range exprValues { + if varValue == value { + return true + } + } + case IsNot: + for _, value := range exprValues { + if varValue == value { + return false + } + } + return true + case IsAny: + if varValue != "" { + return true + } + case Contains: + for _, value := range exprValues { + if strings.Contains(varValue, value) { + return true + } + } + case NotContains: + for _, value := range exprValues { + if strings.Contains(varValue, value) { + return false + } + } + return true + case StartsWith: + for _, value := range exprValues { + if strings.HasPrefix(varValue, value) { + return true + } + } + case EndsWith: + for _, value := range exprValues { + if strings.HasSuffix(varValue, value) { + return true + } + } + case IsUndefined: + return varValue == "" + } + return false +} + +func ComputeFlagValue(flag *FeatureFlag, sessInfo *FeatureFlagsRequest) interface{} { + for _, cond := range flag.Conditions { + conditionValue := true + for _, filter := range cond.Filters { + filterValue := false + switch filter.Type { + case UserCountry: + filterValue = checkCondition(sessInfo.UserCountry, filter.Values, filter.Operator) + case UserCity: + filterValue = checkCondition(sessInfo.UserCity, filter.Values, filter.Operator) + case UserState: + filterValue = checkCondition(sessInfo.UserState, filter.Values, filter.Operator) + case UserOS: + filterValue = checkCondition(sessInfo.UserOS, filter.Values, filter.Operator) + case UserBrowser: + filterValue = checkCondition(sessInfo.UserBrowser, filter.Values, filter.Operator) + case UserDevice: + filterValue = checkCondition(sessInfo.UserDevice, filter.Values, filter.Operator) + case UserID: + filterValue = checkCondition(sessInfo.UserID, filter.Values, filter.Operator) + case Referrer: + filterValue = checkCondition(sessInfo.Referrer, filter.Values, filter.Operator) + case Metadata: + filterValue = checkCondition(sessInfo.Metadata[filter.Source], filter.Values, filter.Operator) + default: + filterValue = false + } + // If any filter is false, the condition is false, so we can check the next condition + if !filterValue { + conditionValue = false + break + } + } + // If any condition is true, we can return the flag value + if conditionValue { + if cond.RolloutPercentage == 0 { + return nil + } + rand.Seed(time.Now().UnixNano()) + randNum := rand.Intn(100) + if randNum > cond.RolloutPercentage { + return nil + } + if flag.FlagType == Single { + return flagInfo{ + Key: flag.FlagKey, + IsPersist: flag.IsPersist, + Value: true, + Payload: flag.Payload, + } + } + // Multi variant flag + randNum = rand.Intn(100) + prev, curr := 0, 0 + for _, variant := range flag.Variants { + curr += variant.RolloutPercentage + if randNum >= prev && randNum <= curr { + return flagInfo{ + Key: flag.FlagKey, + IsPersist: flag.IsPersist, + Value: variant.Value, + Payload: variant.Payload, + } + } + prev = curr + } + } + } + return nil +} + +func ComputeFeatureFlags(flags []*FeatureFlag, sessInfo *FeatureFlagsRequest) ([]interface{}, error) { + result := make([]interface{}, 0, len(flags)) + + for _, flag := range flags { + if val, ok := sessInfo.PersistFlags[flag.FlagKey]; ok && flag.IsPersist { + if flag.FlagType == Single { + result = append(result, flagInfo{ + Key: flag.FlagKey, + IsPersist: flag.IsPersist, + Value: val, + Payload: flag.Payload, + }) + continue + } else { + found := false + for _, variant := range flag.Variants { + if variant.Value == val { + found = true + result = append(result, flagInfo{ + Key: flag.FlagKey, + IsPersist: flag.IsPersist, + Value: val, + Payload: variant.Payload, + }) + break + } + } + if found { + continue + } + } + } + if computedFlag := ComputeFlagValue(flag, sessInfo); computedFlag != nil { + result = append(result, computedFlag) + } + } + return result, nil +} diff --git a/backend/pkg/featureflags/feature-flag_test.go b/backend/pkg/featureflags/feature-flag_test.go new file mode 100644 index 000000000..f1a2b7801 --- /dev/null +++ b/backend/pkg/featureflags/feature-flag_test.go @@ -0,0 +1,839 @@ +package featureflags + +import ( + "bytes" + "log" + "math/rand" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/jackc/pgtype" +) + +func TestNumArrayToIntSlice(t *testing.T) { + // Test case 1: Valid array + arr1 := &pgtype.EnumArray{ + Elements: []pgtype.GenericText{ + {String: "10"}, + {String: "20"}, + {String: "30"}, + }, + Dimensions: []pgtype.ArrayDimension{{Length: 3}}, + } + expected1 := []int{10, 20, 30} + + result1 := numArrayToIntSlice(arr1) + if !reflect.DeepEqual(result1, expected1) { + t.Errorf("Expected %v, but got %v", expected1, result1) + } + + // Test case 2: Empty array + arr2 := &pgtype.EnumArray{ + Elements: []pgtype.GenericText{}, + Dimensions: []pgtype.ArrayDimension{{Length: 0}}, + } + expected2 := []int{} + + result2 := numArrayToIntSlice(arr2) + if !reflect.DeepEqual(result2, expected2) { + t.Errorf("Expected %v, but got %v", expected2, result2) + } + + // Test case 3: Invalid number + arr3 := &pgtype.EnumArray{ + Elements: []pgtype.GenericText{ + {String: "10"}, + {String: "20"}, + {String: "invalid"}, + {String: "30"}, + }, + Dimensions: []pgtype.ArrayDimension{{Length: 4}}, + } + expected3 := []int{10, 20, 0, 30} + + // Capture the log output for the invalid number + logBuffer := &bytes.Buffer{} + log.SetOutput(logBuffer) + defer log.SetOutput(os.Stderr) + + result3 := numArrayToIntSlice(arr3) + if !reflect.DeepEqual(result3, expected3) { + t.Errorf("Expected %v, but got %v", expected3, result3) + } + + // Check the log output for the invalid number + logOutput := logBuffer.String() + if logOutput == "" { + t.Error("Expected log output for invalid number, but got empty") + } + if !strings.Contains(logOutput, "strconv.Atoi: parsing \"invalid\": invalid syntax") { + t.Errorf("Expected log output containing the error message, but got: %s", logOutput) + } +} + +func TestParseFlagConditions(t *testing.T) { + // Test case 1: Valid conditions + conditions1 := &pgtype.TextArray{ + Elements: []pgtype.Text{ + {String: `[{"type": "userCountry", "operator": "is", "source": "source1", "value": ["value1"]}]`}, + {String: `[{"type": "userCity", "operator": "contains", "source": "source2", "value": ["value2", "value3"]}]`}, + }, + Dimensions: []pgtype.ArrayDimension{{Length: 2}}, + } + rolloutPercentages1 := &pgtype.EnumArray{ + Elements: []pgtype.GenericText{ + {String: "50"}, + {String: "30"}, + }, + Dimensions: []pgtype.ArrayDimension{{Length: 2}}, + } + expected1 := []*FeatureFlagCondition{ + { + Filters: []*FeatureFlagFilter{ + { + Type: UserCountry, + Operator: Is, + Source: "source1", + Values: []string{"value1"}, + }, + }, + RolloutPercentage: 50, + }, + { + Filters: []*FeatureFlagFilter{ + { + Type: UserCity, + Operator: Contains, + Source: "source2", + Values: []string{"value2", "value3"}, + }, + }, + RolloutPercentage: 30, + }, + } + + result1, err1 := parseFlagConditions(conditions1, rolloutPercentages1) + if err1 != nil { + t.Errorf("Error parsing flag conditions: %v", err1) + } + if !reflect.DeepEqual(result1, expected1) { + t.Errorf("Expected %v, but got %v", expected1, result1) + } + + // Test case 2: Empty conditions array + conditions2 := &pgtype.TextArray{ + Elements: []pgtype.Text{}, + Dimensions: []pgtype.ArrayDimension{{Length: 0}}, + } + rolloutPercentages2 := &pgtype.EnumArray{ + Elements: []pgtype.GenericText{}, + Dimensions: []pgtype.ArrayDimension{{Length: 0}}, + } + expected2 := []*FeatureFlagCondition{} + + result2, err2 := parseFlagConditions(conditions2, rolloutPercentages2) + if err2 != nil { + t.Errorf("Error parsing flag conditions: %v", err2) + } + if !reflect.DeepEqual(result2, expected2) { + t.Errorf("Expected %v, but got %v", expected2, result2) + } + + // Test case 3: Mismatched number of elements + conditions3 := &pgtype.TextArray{ + Elements: []pgtype.Text{ + {String: `[{"type": "userCountry", "operator": "is", "source": "source1", "value": ["value1"]}]`}, + {String: `[{"type": "userCity", "operator": "contains", "source": "source2", "value": ["value2", "value3"]}]`}, + }, + Dimensions: []pgtype.ArrayDimension{{Length: 2}}, + } + rolloutPercentages3 := &pgtype.EnumArray{ + Elements: []pgtype.GenericText{ + {String: "50"}, + }, + Dimensions: []pgtype.ArrayDimension{{Length: 1}}, + } + expectedErrorMsg := "error: len(conditions.Elements) != len(percents)" + if _, err3 := parseFlagConditions(conditions3, rolloutPercentages3); err3 == nil || err3.Error() != expectedErrorMsg { + t.Errorf("Expected error: %v, but got: %v", expectedErrorMsg, err3) + } +} + +func TestParseFlagVariants(t *testing.T) { + // Test case 1: Valid variants + values1 := &pgtype.TextArray{ + Elements: []pgtype.Text{ + {String: "variant1"}, + {String: "variant2"}, + {String: "variant3"}, + }, + Dimensions: []pgtype.ArrayDimension{{Length: 3}}, + } + payloads1 := &pgtype.TextArray{ + Elements: []pgtype.Text{ + {String: "payload1"}, + {String: "payload2"}, + {String: "payload3"}, + }, + Dimensions: []pgtype.ArrayDimension{{Length: 3}}, + } + variantRollout1 := &pgtype.EnumArray{ + Elements: []pgtype.GenericText{ + {String: "50"}, + {String: "30"}, + {String: "20"}, + }, + Dimensions: []pgtype.ArrayDimension{{Length: 3}}, + } + expected1 := []*FeatureFlagVariant{ + {Value: "variant1", Payload: "payload1", RolloutPercentage: 50}, + {Value: "variant2", Payload: "payload2", RolloutPercentage: 30}, + {Value: "variant3", Payload: "payload3", RolloutPercentage: 20}, + } + + result1, err1 := parseFlagVariants(values1, payloads1, variantRollout1) + if err1 != nil { + t.Errorf("Error parsing flag variants: %v", err1) + } + if !reflect.DeepEqual(result1, expected1) { + t.Errorf("Expected %v, but got %v", expected1, result1) + } + + // Test case 2: Empty values array + values2 := &pgtype.TextArray{ + Elements: []pgtype.Text{}, + Dimensions: []pgtype.ArrayDimension{{Length: 0}}, + } + payloads2 := &pgtype.TextArray{ + Elements: []pgtype.Text{}, + Dimensions: []pgtype.ArrayDimension{{Length: 0}}, + } + variantRollout2 := &pgtype.EnumArray{ + Elements: []pgtype.GenericText{}, + Dimensions: []pgtype.ArrayDimension{{Length: 0}}, + } + expected2 := []*FeatureFlagVariant{} + + result2, err2 := parseFlagVariants(values2, payloads2, variantRollout2) + if err2 != nil { + t.Errorf("Error parsing flag variants: %v", err2) + } + if !reflect.DeepEqual(result2, expected2) { + t.Errorf("Expected %v, but got %v", expected2, result2) + } + + // Test case 3: Mismatched number of elements + values3 := &pgtype.TextArray{ + Elements: []pgtype.Text{ + {String: "variant1"}, + {String: "variant2"}, + }, + Dimensions: []pgtype.ArrayDimension{{Length: 2}}, + } + payloads3 := &pgtype.TextArray{ + Elements: []pgtype.Text{ + {String: "payload1"}, + {String: "payload2"}, + {String: "payload3"}, + }, + Dimensions: []pgtype.ArrayDimension{{Length: 3}}, + } + variantRollout3 := &pgtype.EnumArray{ + Elements: []pgtype.GenericText{ + {String: "50"}, + {String: "30"}, + }, + Dimensions: []pgtype.ArrayDimension{{Length: 2}}, + } + + result3, err3 := parseFlagVariants(values3, payloads3, variantRollout3) + expectedErrorMsg := "wrong number of variant elements" + if err3 == nil || err3.Error() != expectedErrorMsg { + t.Errorf("Expected error: %v, but got: %v", expectedErrorMsg, err3) + } + if result3 != nil { + t.Errorf("Expected nil result, but got: %v", result3) + } +} + +func TestParseFeatureFlag(t *testing.T) { + // Test case 1: Single flag with no variants + rawFlag1 := &FeatureFlagPG{ + FlagID: 1, + FlagKey: "flag_key", + FlagType: "single", + IsPersist: true, + Payload: nil, + RolloutPercentages: pgtype.EnumArray{}, + Filters: pgtype.TextArray{}, + Values: pgtype.TextArray{}, + Payloads: pgtype.TextArray{}, + VariantRollout: pgtype.EnumArray{}, + } + expectedFlag1 := &FeatureFlag{ + FlagID: 1, + FlagKey: "flag_key", + FlagType: Single, + IsPersist: true, + Payload: "", + Conditions: []*FeatureFlagCondition{}, + Variants: []*FeatureFlagVariant{}, + } + + resultFlag1, err := ParseFeatureFlag(rawFlag1) + if err != nil { + t.Errorf("Error parsing feature flag: %v", err) + } + if !reflect.DeepEqual(resultFlag1, expectedFlag1) { + t.Errorf("Expected %v, but got %v", expectedFlag1, resultFlag1) + } + + // Test case 2: Multi flag with variants + rawFlag2 := &FeatureFlagPG{ + FlagID: 2, + FlagKey: "flag_key", + FlagType: "multi", + IsPersist: false, + Payload: nil, + RolloutPercentages: pgtype.EnumArray{ + Elements: []pgtype.GenericText{ + {String: "70"}, + {String: "90"}, + }, + }, + Filters: pgtype.TextArray{ + Elements: []pgtype.Text{ + {String: `[{"type":"userCountry","operator":"is","source":"","value":["US"]},{"type":"userCity","operator":"startsWith","source":"cookie","value":["New York"]},{"type":"referrer","operator":"contains","source":"header","value":["google.com"]},{"type":"metadata","operator":"is","source":"","value":["some_value"]}]`}, + {String: `[{"type":"userCountry","operator":"is","source":"","value":["CA"]}]`}, + }, + }, + Values: pgtype.TextArray{ + Elements: []pgtype.Text{ + {String: "value1"}, + {String: "value2"}, + }, + }, + Payloads: pgtype.TextArray{ + Elements: []pgtype.Text{ + {String: "payload1"}, + {String: "payload2"}, + }, + }, + VariantRollout: pgtype.EnumArray{ + Elements: []pgtype.GenericText{ + {String: "50"}, + {String: "50"}, + }, + }, + } + expectedFlag2 := &FeatureFlag{ + FlagID: 2, + FlagKey: "flag_key", + FlagType: Multi, + IsPersist: false, + Payload: "", + Conditions: []*FeatureFlagCondition{ + { + Filters: []*FeatureFlagFilter{ + { + Type: UserCountry, + Operator: Is, + Source: "", + Values: []string{"US"}, + }, + { + Type: UserCity, + Operator: StartsWith, + Source: "cookie", + Values: []string{"New York"}, + }, + { + Type: Referrer, + Operator: Contains, + Source: "header", + Values: []string{"google.com"}, + }, + { + Type: Metadata, + Operator: Is, + Source: "", + Values: []string{"some_value"}, + }, + }, + RolloutPercentage: 70, + }, + { + Filters: []*FeatureFlagFilter{ + { + Type: UserCountry, + Operator: Is, + Source: "", + Values: []string{"CA"}, + }, + }, + RolloutPercentage: 90, + }, + }, + Variants: []*FeatureFlagVariant{ + { + Value: "value1", + Payload: "payload1", + RolloutPercentage: 50, + }, + { + Value: "value2", + Payload: "payload2", + RolloutPercentage: 50, + }, + }, + } + + resultFlag2, err := ParseFeatureFlag(rawFlag2) + if err != nil { + t.Errorf("Error parsing feature flag: %v", err) + } + if !reflect.DeepEqual(resultFlag2, expectedFlag2) { + t.Errorf("Expected %v, but got %v", expectedFlag2, resultFlag2) + } +} + +func TestCheckCondition(t *testing.T) { + // Test case 1: Operator - Is, varValue is equal to one of the exprValues + varValue := "Hello" + exprValues := []string{"Hello", "Goodbye"} + operator := Is + expected := true + result := checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 2: Operator - Is, varValue is not equal to any of the exprValues + varValue = "Foo" + expected = false + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 3: Operator - IsNot, varValue is equal to one of the exprValues + varValue = "Hello" + operator = IsNot + expected = false + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 4: Operator - IsNot, varValue is not equal to any of the exprValues + varValue = "Foo" + expected = true + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 5: Operator - IsAny, varValue is not empty + varValue = "Hello" + operator = IsAny + expected = true + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 6: Operator - IsAny, varValue is empty + varValue = "" + expected = false + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 7: Operator - Contains, varValue contains one of the exprValues + varValue = "Hello, World!" + operator = Contains + expected = true + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 8: Operator - Contains, varValue does not contain any of the exprValues + varValue = "Foo Bar" + expected = false + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 9: Operator - NotContains, varValue contains one of the exprValues + varValue = "Hello, World!" + operator = NotContains + expected = false + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 10: Operator - NotContains, varValue does not contain any of the exprValues + varValue = "Foo Bar" + expected = true + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 11: Operator - StartsWith, varValue starts with one of the exprValues + varValue = "Hello, World!" + operator = StartsWith + expected = true + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 12: Operator - StartsWith, varValue does not start with any of the exprValues + varValue = "Foo Bar" + expected = false + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 13: Operator - EndsWith, varValue ends with one of the exprValues + varValue = "Tom! Hello" + operator = EndsWith + expected = true + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 14: Operator - EndsWith, varValue does not end with any of the exprValues + varValue = "Foo Bar" + expected = false + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 15: Operator - IsUndefined, varValue is empty + varValue = "" + operator = IsUndefined + expected = true + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } + + // Test case 16: Operator - IsUndefined, varValue is not empty + varValue = "Hello" + expected = false + result = checkCondition(varValue, exprValues, operator) + if result != expected { + t.Errorf("Expected %v, but got %v", expected, result) + } +} + +func TestComputeFlagValue(t *testing.T) { + rand.Seed(time.Now().UnixNano()) + + // Test case 1: Single flag, condition true, rollout percentage 100 + flag1 := &FeatureFlag{ + FlagID: 1, + FlagKey: "flag_key", + FlagType: Single, + IsPersist: true, + Payload: "payload", + Conditions: []*FeatureFlagCondition{ + { + Filters: []*FeatureFlagFilter{ + { + Type: UserCountry, + Operator: Is, + Source: "", + Values: []string{"US"}, + }, + }, + RolloutPercentage: 100, + }, + }, + Variants: []*FeatureFlagVariant{}, + } + sessInfo1 := &FeatureFlagsRequest{ + UserCountry: "US", + } + + expectedResult1 := flagInfo{ + Key: "flag_key", + IsPersist: true, + Value: true, + Payload: "payload", + } + + result1 := ComputeFlagValue(flag1, sessInfo1) + if result1 == nil { + t.Errorf("Expected %v, but got nil", expectedResult1) + } else if !reflect.DeepEqual(result1, expectedResult1) { + t.Errorf("Expected %v, but got %v", expectedResult1, result1) + } + + // Test case 2: Single flag, condition false, rollout percentage 100 + flag2 := &FeatureFlag{ + FlagID: 2, + FlagKey: "flag_key", + FlagType: Single, + IsPersist: false, + Payload: "payload", + Conditions: []*FeatureFlagCondition{ + { + Filters: []*FeatureFlagFilter{ + { + Type: UserCountry, + Operator: Is, + Source: "", + Values: []string{"US"}, + }, + }, + RolloutPercentage: 100, + }, + }, + Variants: []*FeatureFlagVariant{}, + } + sessInfo2 := &FeatureFlagsRequest{ + UserCountry: "CA", + } + + result2 := ComputeFlagValue(flag2, sessInfo2) + if result2 != nil { + t.Errorf("Expected nil, but got %v", result2) + } + + // Test case 3: Multi variant flag, condition true, rollout percentage 100 + flag3 := &FeatureFlag{ + FlagID: 3, + FlagKey: "flag_key", + FlagType: Multi, + IsPersist: true, + Payload: "payload", + Conditions: []*FeatureFlagCondition{ + { + Filters: []*FeatureFlagFilter{ + { + Type: UserCountry, + Operator: Is, + Source: "", + Values: []string{"US"}, + }, + }, + RolloutPercentage: 100, + }, + }, + Variants: []*FeatureFlagVariant{ + { + Value: "value1", + Payload: "payload1", + RolloutPercentage: 50, + }, + { + Value: "value2", + Payload: "payload2", + RolloutPercentage: 50, + }, + }, + } + sessInfo3 := &FeatureFlagsRequest{ + UserCountry: "US", + } + + expectedResult3 := flagInfo{ + Key: "flag_key", + IsPersist: true, + Value: "value1", + Payload: "payload1", + } + + result3 := ComputeFlagValue(flag3, sessInfo3) + if result3 == nil { + t.Errorf("Expected %v, but got nil", expectedResult3) + } else if !reflect.DeepEqual(result3, expectedResult3) { + t.Errorf("Expected %v, but got %v", expectedResult3, result3) + } + + // Test case 4: Multi variant flag, condition true, rollout percentage 0 + flag4 := &FeatureFlag{ + FlagID: 4, + FlagKey: "flag_key", + FlagType: Multi, + IsPersist: false, + Payload: "payload", + Conditions: []*FeatureFlagCondition{ + { + Filters: []*FeatureFlagFilter{ + { + Type: UserCountry, + Operator: Is, + Source: "", + Values: []string{"US"}, + }, + }, + RolloutPercentage: 0, + }, + }, + Variants: []*FeatureFlagVariant{ + { + Value: "value1", + Payload: "payload1", + RolloutPercentage: 50, + }, + { + Value: "value2", + Payload: "payload2", + RolloutPercentage: 50, + }, + }, + } + sessInfo4 := &FeatureFlagsRequest{ + UserCountry: "US", + } + + result4 := ComputeFlagValue(flag4, sessInfo4) + if result4 != nil { + t.Errorf("Expected nil, but got %v", result4) + } +} + +func TestComputeFeatureFlags(t *testing.T) { + // Initialize test cases + var testCases = []struct { + name string + flags []*FeatureFlag + sessInfo *FeatureFlagsRequest + expectedOutput []interface{} + expectedError error + }{ + { + "Persist flag with FlagType Single", + []*FeatureFlag{ + { + FlagKey: "testFlag", + FlagType: Single, + IsPersist: true, + Payload: "testPayload", + }, + }, + &FeatureFlagsRequest{ + PersistFlags: map[string]interface{}{ + "testFlag": "testValue", + }, + }, + []interface{}{ + flagInfo{ + Key: "testFlag", + IsPersist: true, + Value: "testValue", + Payload: "testPayload", + }, + }, + nil, + }, + { + "Persist flag with FlagType Multi and variant match", + []*FeatureFlag{ + { + FlagKey: "testFlag", + FlagType: Multi, + IsPersist: true, + Variants: []*FeatureFlagVariant{ + { + Value: "testValue", + Payload: "testPayload", + }, + }, + }, + }, + &FeatureFlagsRequest{ + PersistFlags: map[string]interface{}{ + "testFlag": "testValue", + }, + }, + []interface{}{ + flagInfo{ + Key: "testFlag", + IsPersist: true, + Value: "testValue", + Payload: "testPayload", + }, + }, + nil, + }, + } + + // Execute test cases + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + output, err := ComputeFeatureFlags(tc.flags, tc.sessInfo) + reflect.DeepEqual(tc.expectedError, err) + reflect.DeepEqual(tc.expectedOutput, output) + }) + } +} + +func TestFeatureFlags(t *testing.T) { + flags := []*FeatureFlag{ + { + FlagID: 1, + FlagKey: "checkCity", + FlagType: Single, + IsPersist: true, + Payload: "test", + Conditions: []*FeatureFlagCondition{ + { + Filters: []*FeatureFlagFilter{ + { + Type: UserCity, + Operator: Contains, + Values: []string{"Paris"}, + }, + }, + RolloutPercentage: 80, + }, + }, + Variants: []*FeatureFlagVariant{ + { + Value: "blue", + Payload: "{\"color\": \"blue\"}", + RolloutPercentage: 50, + }, + { + Value: "red", + Payload: "{\"color\": \"red\"}", + RolloutPercentage: 50, + }, + }, + }, + } + sessInfo := FeatureFlagsRequest{ + ProjectID: "123", + UserOS: "macos", + UserDevice: "macbook", + UserCountry: "France", + UserState: "Ile-de-France", + UserCity: "Paris", + UserBrowser: "Safari", + Referrer: "https://google.com", + UserID: "456", + Metadata: map[string]string{"test": "test"}, + PersistFlags: map[string]interface{}{"test": "test"}, + } + + result, err := ComputeFeatureFlags(flags, &sessInfo) + if err != nil { + t.Error(err) + } + t.Log(result) +}