[Backend] feature flags (#1354)

* feat(backend): added ff mock

* feat(backend): added feature flag pg method

* fix(backend): fixed ff request field

* feat(backend): added multivariant support

* feat(backend): added logic handler for feature flag filters

* fix(backend): correct sql request for multivariants flags

* feat(backend): added new fields to sessionStart response

* feat(backend): removed unused fields from getFeatureFlags request

* fix(backend): removed comments

* feat(backend): added debug logs

* feat(backend): added single type case for arrayToNum parser

* feat(backend): added unit tests for feature flags
This commit is contained in:
Alexander 2023-06-21 13:41:06 +02:00 committed by GitHub
parent d4b8d3a7f9
commit db7d624b3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 1333 additions and 20 deletions

View file

@ -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)
}

View file

@ -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"`

View file

@ -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"

View file

@ -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
}

View file

@ -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)

View file

@ -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
}

View file

@ -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)
}