mirror of
https://github.com/hanzoai/insights-go.git
synced 2026-09-30 22:46:12 +00:00
The module declares go 1.27.1 and the adapter image follows. go fix applied the modernizers and left ptrString and ptrBool with no callers, so they are gone; omitzero is not applied, since it changes what the JSON says. Co-authored-by: Hanzo Dev <[email protected]>
2235 lines
70 KiB
Go
2235 lines
70 KiB
Go
package insights
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"slices"
|
|
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
json "github.com/goccy/go-json"
|
|
)
|
|
|
|
const (
|
|
// LONG_SCALE is the denominator used to normalize feature flag hash values.
|
|
LONG_SCALE = 0xfffffffffffffff
|
|
bucketingIdentifierDevice = "device_id"
|
|
)
|
|
|
|
var relativeDateRegex = regexp.MustCompile(`^-?([0-9]+)([hdwmy])$`)
|
|
|
|
// Common sentinel errors for matchProperty — reused to avoid allocating new
|
|
// InconclusiveMatchError structs on every evaluation of flags with missing properties.
|
|
var (
|
|
errMissingPropertyValue = &InconclusiveMatchError{"Can't match properties without a given property value"}
|
|
errOperatorIsNotSet = &InconclusiveMatchError{"Can't match properties with operator is_not_set"}
|
|
errInconclusiveMatch = &InconclusiveMatchError{"Can't determine if feature flag is enabled or not with given properties"}
|
|
errCohortPropertyValue = &InconclusiveMatchError{msg: "Can't match cohort without a given cohort property value"}
|
|
errCohortRequiresServerEval = &RequiresServerEvaluationError{msg: "cohort not found in local cohorts - likely a static cohort that requires server evaluation"}
|
|
)
|
|
|
|
// regexCache caches compiled regexps for flag property matching.
|
|
// The set of patterns is bounded by the number of flag conditions (loaded once),
|
|
// so this cache grows proportionally and avoids re-compiling on every evaluation.
|
|
var regexCache sync.Map // map[string]*regexp.Regexp
|
|
|
|
// getOrCompileRegex returns a cached compiled regexp for the given pattern,
|
|
// or compiles and caches it on first use.
|
|
func getOrCompileRegex(pattern string) (*regexp.Regexp, error) {
|
|
if cached, ok := regexCache.Load(pattern); ok {
|
|
return cached.(*regexp.Regexp), nil
|
|
}
|
|
r, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Store and return — concurrent stores of the same pattern are harmless
|
|
regexCache.Store(pattern, r)
|
|
return r, nil
|
|
}
|
|
|
|
// flagsState holds the feature flag data that is atomically swapped during updates.
|
|
// This provides lock-free reads for the common path (flag evaluation).
|
|
type flagsState struct {
|
|
featureFlags []FeatureFlag
|
|
flagsByKey map[string]FeatureFlag // pre-built index for O(1) lookup, avoids rebuilding per evaluation
|
|
cohorts map[string]PropertyGroup
|
|
groups map[string]string
|
|
flagsEtag string
|
|
}
|
|
|
|
// FeatureFlagsPoller periodically loads feature flag definitions for local evaluation.
|
|
// Applications normally interact with it through Client methods rather than constructing it directly.
|
|
type FeatureFlagsPoller struct {
|
|
// firstFeatureFlagRequestFinished is used to log feature flag usage before the first feature flag request is done.
|
|
// After the request the channel get closed.
|
|
firstFeatureFlagRequestFinished chan bool
|
|
shutdown chan bool
|
|
forceReload chan bool
|
|
|
|
// state holds all flag-related data using atomic pointer for lock-free reads
|
|
state atomic.Pointer[flagsState]
|
|
|
|
personalApiKey string
|
|
projectApiKey string
|
|
localEvalUrl *url.URL
|
|
// Logger receives poller warnings and errors.
|
|
Logger Logger
|
|
// Endpoint is the PostHog API host used by the poller.
|
|
Endpoint string
|
|
http http.Client
|
|
nextPollTick func() time.Duration
|
|
flagTimeout time.Duration
|
|
decider decider
|
|
disableGeoIP bool
|
|
}
|
|
|
|
// FeatureFlag is a feature flag definition returned by the local evaluation endpoint.
|
|
type FeatureFlag struct {
|
|
// Key is the feature flag key.
|
|
Key string `json:"key"`
|
|
// RolloutPercentage is the top-level rollout percentage, when configured.
|
|
RolloutPercentage *float64 `json:"rollout_percentage"`
|
|
// Active reports whether the flag is active.
|
|
Active bool `json:"active"`
|
|
// Filters contains matching conditions, variants, and payloads.
|
|
Filters Filter `json:"filters"`
|
|
// EnsureExperienceContinuity indicates that the flag requires server-side continuity checks.
|
|
EnsureExperienceContinuity *bool `json:"ensure_experience_continuity"`
|
|
// BucketingIdentifier optionally selects the property used for hash bucketing.
|
|
BucketingIdentifier *string `json:"bucketing_identifier"`
|
|
}
|
|
|
|
// Filter contains the targeting rules, variants, and payloads for a FeatureFlag.
|
|
type Filter struct {
|
|
// AggregationGroupTypeIndex identifies the group type for group-targeted flags.
|
|
AggregationGroupTypeIndex *uint8 `json:"aggregation_group_type_index"`
|
|
// Groups contains the ordered condition groups to evaluate.
|
|
Groups []FeatureFlagCondition `json:"groups"`
|
|
// Multivariate contains variant definitions for multivariate flags.
|
|
Multivariate *Variants `json:"multivariate"`
|
|
// Payloads maps flag values or variant keys to raw JSON payloads.
|
|
Payloads map[string]json.RawMessage `json:"payloads"`
|
|
// DecodedPayloads holds pre-decoded string versions of Payloads.
|
|
// Built once at flag load time to avoid per-evaluation json.Unmarshal / unquoting.
|
|
DecodedPayloads map[string]string `json:"-"`
|
|
// VariantLookupTable is pre-computed at flag load time to avoid per-evaluation
|
|
// slice allocation for multivariate flags.
|
|
VariantLookupTable []FlagVariantMeta `json:"-"`
|
|
}
|
|
|
|
// Variants contains all variants configured for a multivariate feature flag.
|
|
type Variants struct {
|
|
// Variants is the ordered list of possible flag variants.
|
|
Variants []FlagVariant `json:"variants"`
|
|
}
|
|
|
|
// FlagVariant describes one multivariate feature flag variant.
|
|
type FlagVariant struct {
|
|
// Key is the stable variant key returned by flag evaluation.
|
|
Key string `json:"key"`
|
|
// Name is the display name for the variant.
|
|
Name string `json:"name"`
|
|
// RolloutPercentage is the percentage allocated to this variant.
|
|
RolloutPercentage *float64 `json:"rollout_percentage"`
|
|
}
|
|
|
|
// FeatureFlagCondition describes one condition group for a feature flag.
|
|
type FeatureFlagCondition struct {
|
|
// Properties are the targeting rules in this condition group.
|
|
Properties []FlagProperty `json:"properties"`
|
|
// RolloutPercentage is the rollout percentage for this condition group.
|
|
RolloutPercentage *float64 `json:"rollout_percentage"`
|
|
// Variant is the variant key forced by this condition group, when any.
|
|
Variant *string `json:"variant"`
|
|
// AggregationGroupTypeIndex identifies the group type for this condition group.
|
|
AggregationGroupTypeIndex *uint8 `json:"aggregation_group_type_index"`
|
|
}
|
|
|
|
// FlagProperty describes one property matcher in a feature flag or cohort condition.
|
|
type FlagProperty struct {
|
|
// Key is the property key to read.
|
|
Key string `json:"key"`
|
|
// Operator is the comparison operator, such as exact, is_not, or regex.
|
|
Operator string `json:"operator"`
|
|
// Value is the comparison value for Operator.
|
|
Value any `json:"value"`
|
|
// Type is the property source. Supported values include "person", "group", "cohort", and "flag".
|
|
Type string `json:"type"`
|
|
// Negation inverts the property match when true.
|
|
Negation bool `json:"negation"`
|
|
// DependencyChain tracks feature flag dependencies while evaluating nested flag properties.
|
|
DependencyChain []string `json:"dependency_chain"`
|
|
}
|
|
|
|
// PropertyGroup describes a nested property group used by cohorts and flag conditions.
|
|
type PropertyGroup struct {
|
|
// Type is the boolean operator joining Values, such as AND or OR.
|
|
Type string `json:"type"`
|
|
// Values contains nested PropertyGroup values or FlagProperty values.
|
|
Values []any `json:"values"`
|
|
// ParsedValues holds pre-parsed typed values from Values.
|
|
// Built once at flag load time to avoid reconstructing FlagProperty/PropertyGroup
|
|
// from map[string]any on every cohort evaluation.
|
|
ParsedValues []parsedPropertyValue `json:"-"`
|
|
}
|
|
|
|
// parsedPropertyValue is a union holding either a nested PropertyGroup or a FlagProperty.
|
|
// Exactly one field is set. Avoids per-evaluation type assertion and reconstruction.
|
|
type parsedPropertyValue struct {
|
|
IsGroup bool
|
|
Group PropertyGroup
|
|
Property FlagProperty
|
|
}
|
|
|
|
// FlagVariantMeta is a precomputed hash range for a feature flag variant.
|
|
type FlagVariantMeta struct {
|
|
// ValueMin is the inclusive lower bound of the normalized hash range.
|
|
ValueMin float64
|
|
// ValueMax is the exclusive upper bound of the normalized hash range.
|
|
ValueMax float64
|
|
// Key is the variant key for this hash range.
|
|
Key string
|
|
}
|
|
|
|
// FeatureFlagsResponse is the wire-format response from the local evaluation endpoint.
|
|
type FeatureFlagsResponse struct {
|
|
// Flags contains feature flag definitions for local evaluation.
|
|
Flags []FeatureFlag `json:"flags"`
|
|
// GroupTypeMapping maps group type indexes to group type names.
|
|
GroupTypeMapping *map[string]string `json:"group_type_mapping"`
|
|
// Cohorts contains cohort definitions referenced by local feature flags.
|
|
Cohorts map[string]PropertyGroup `json:"cohorts"`
|
|
}
|
|
|
|
// DecideRequestData is the legacy wire-format request body for flag decide calls.
|
|
type DecideRequestData struct {
|
|
// ApiKey is the PostHog project API key.
|
|
ApiKey string `json:"api_key"`
|
|
// DistinctId is the user distinct ID to evaluate flags for.
|
|
DistinctId string `json:"distinct_id"`
|
|
// Groups contains group identifiers for group-targeted flags.
|
|
Groups Groups `json:"groups"`
|
|
// PersonProperties overrides person properties for this evaluation.
|
|
PersonProperties Properties `json:"person_properties"`
|
|
// GroupProperties overrides group properties for this evaluation, keyed by group type.
|
|
GroupProperties map[string]Properties `json:"group_properties"`
|
|
}
|
|
|
|
// DecideResponse is the legacy wire-format response body for flag decide calls.
|
|
type DecideResponse struct {
|
|
// FeatureFlags contains evaluated flag values keyed by flag key.
|
|
FeatureFlags map[string]any `json:"featureFlags"`
|
|
// FeatureFlagPayloads contains raw payloads keyed by flag key.
|
|
FeatureFlagPayloads map[string]json.RawMessage `json:"featureFlagPayloads"`
|
|
}
|
|
|
|
// InconclusiveMatchError indicates that local evaluation could not conclusively match a condition.
|
|
type InconclusiveMatchError struct {
|
|
msg string
|
|
}
|
|
|
|
// Error returns the inconclusive match message.
|
|
func (e *InconclusiveMatchError) Error() string {
|
|
return e.msg
|
|
}
|
|
|
|
// RequiresServerEvaluationError is returned when feature flag evaluation
|
|
// requires server-side data that is not available locally (e.g., static cohorts,
|
|
// experience continuity). This error should propagate immediately to trigger
|
|
// API fallback, unlike InconclusiveMatchError which allows trying other conditions.
|
|
type RequiresServerEvaluationError struct {
|
|
msg string
|
|
}
|
|
|
|
// Error returns the reason server-side evaluation is required.
|
|
func (e *RequiresServerEvaluationError) Error() string {
|
|
return e.msg
|
|
}
|
|
|
|
// isServerEvalError returns true if err is a RequiresServerEvaluationError.
|
|
// Uses type assertion instead of errors.As to avoid pointer-variable heap escapes.
|
|
func isServerEvalError(err error) bool {
|
|
_, ok := err.(*RequiresServerEvaluationError)
|
|
return ok
|
|
}
|
|
|
|
// isInconclusiveError returns true if err is an InconclusiveMatchError.
|
|
func isInconclusiveError(err error) bool {
|
|
_, ok := err.(*InconclusiveMatchError)
|
|
return ok
|
|
}
|
|
|
|
// FeatureFlagResult represents the result of a feature flag evaluation,
|
|
// containing both the flag value and its payload.
|
|
type FeatureFlagResult struct {
|
|
// Key is the feature flag key that was evaluated
|
|
Key string
|
|
|
|
// Enabled indicates whether the feature flag evaluation determined
|
|
// the flag to be in an enabled state.
|
|
Enabled bool
|
|
|
|
// RawPayload is the serialized JSON payload associated with the flag variant.
|
|
// Nil if no payload is configured.
|
|
// Use GetPayloadAs to unmarshal the payload into a specific type.
|
|
RawPayload *string
|
|
|
|
// Variant is the variant key if this is a multivariate flag.
|
|
// Nil for boolean flags.
|
|
Variant *string
|
|
|
|
// payloadStore and variantStore hold the actual string values so that
|
|
// RawPayload/Variant can point into the same allocation as the struct itself,
|
|
// avoiding separate heap escapes for the *string pointers.
|
|
payloadStore string
|
|
variantStore string
|
|
}
|
|
|
|
// GetPayloadAs unmarshals the JSON payload into the provided type.
|
|
// Returns an error if the payload is empty or cannot be unmarshaled.
|
|
func (r *FeatureFlagResult) GetPayloadAs(v any) error {
|
|
if r.RawPayload == nil || *r.RawPayload == "" {
|
|
return errors.New("no payload available")
|
|
}
|
|
return json.Unmarshal([]byte(*r.RawPayload), v)
|
|
}
|
|
|
|
// evaluateFlagDependency evaluates a flag dependency property according to the dependency chain algorithm
|
|
func (poller *FeatureFlagsPoller) evaluateFlagDependency(
|
|
property FlagProperty,
|
|
flagsByKey map[string]FeatureFlag,
|
|
evaluationCache map[string]any,
|
|
distinctId string,
|
|
deviceId *string,
|
|
properties Properties,
|
|
cohorts map[string]PropertyGroup,
|
|
) (bool, error) {
|
|
// Some of these conditions should never happen, but we'll check them to be defensive.
|
|
if property.Value == nil {
|
|
return false, &InconclusiveMatchError{
|
|
msg: fmt.Sprintf("Cannot evaluate flag dependency on '%s' without a value", property.Key),
|
|
}
|
|
}
|
|
|
|
if property.Operator != "flag_evaluates_to" {
|
|
return false, &InconclusiveMatchError{
|
|
msg: fmt.Sprintf("Unsupported operator '%s' for flag dependency '%s'", property.Operator, property.Key),
|
|
}
|
|
}
|
|
|
|
if flagsByKey == nil || evaluationCache == nil {
|
|
// Cannot evaluate flag dependencies without required context
|
|
return false, &InconclusiveMatchError{
|
|
msg: fmt.Sprintf("Cannot evaluate flag dependency on '%s' without flagsByKey and evaluationCache", property.Key),
|
|
}
|
|
}
|
|
|
|
// Check if dependency_chain is present - it should always be provided for flag dependencies
|
|
if property.DependencyChain == nil {
|
|
// Missing dependency_chain indicates malformed server data
|
|
return false, &InconclusiveMatchError{
|
|
msg: fmt.Sprintf("Flag dependency property for '%s' is missing required 'dependency_chain' field", property.Key),
|
|
}
|
|
}
|
|
|
|
dependencyChain := property.DependencyChain
|
|
|
|
// Handle circular dependency (empty chain means circular)
|
|
if len(dependencyChain) == 0 {
|
|
if poller.Logger != nil {
|
|
poller.Logger.Debugf("Circular dependency detected for flag: %s", property.Key)
|
|
}
|
|
return false, &InconclusiveMatchError{
|
|
msg: fmt.Sprintf("Circular dependency detected for flag '%s'", property.Key),
|
|
}
|
|
}
|
|
|
|
// Evaluate all dependencies in the chain order
|
|
for _, depFlagKey := range dependencyChain {
|
|
if _, exists := evaluationCache[depFlagKey]; exists {
|
|
continue
|
|
}
|
|
|
|
// Need to evaluate this dependency first
|
|
depFlag, flagExists := flagsByKey[depFlagKey]
|
|
if !flagExists {
|
|
// Missing flag dependency - cannot evaluate locally
|
|
evaluationCache[depFlagKey] = nil
|
|
return false, &InconclusiveMatchError{
|
|
msg: fmt.Sprintf("Cannot evaluate flag dependency '%s' - flag not found in local flags", depFlagKey),
|
|
}
|
|
}
|
|
|
|
// Check if the flag is active (same check as in computeFlagLocally)
|
|
if !depFlag.Active {
|
|
evaluationCache[depFlagKey] = false
|
|
} else {
|
|
// Recursively evaluate the dependency
|
|
result, err := poller.matchFeatureFlagProperties(depFlag, distinctId, deviceId, properties, cohorts, flagsByKey, evaluationCache, nil, nil)
|
|
if err != nil {
|
|
// If we can't evaluate a dependency, store nil and propagate the error
|
|
evaluationCache[depFlagKey] = nil
|
|
return false, &InconclusiveMatchError{
|
|
msg: fmt.Sprintf("Cannot evaluate flag dependency '%s': %s", depFlagKey, err.Error()),
|
|
}
|
|
}
|
|
evaluationCache[depFlagKey] = result
|
|
}
|
|
}
|
|
|
|
// Check if the dependency result matches the expected value and operator
|
|
if cachedResult, exists := evaluationCache[property.Key]; exists && cachedResult != nil {
|
|
match, err := checkFlagDependencyValue(property.Value, cachedResult)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return match, nil
|
|
}
|
|
|
|
// The main dependency couldn't be evaluated
|
|
return false, &InconclusiveMatchError{
|
|
msg: fmt.Sprintf("Flag dependency '%s' could not be evaluated for value comparison", property.Key),
|
|
}
|
|
}
|
|
|
|
// checkFlagDependencyValue checks if a flag dependency result matches the expected value and operator
|
|
func checkFlagDependencyValue(expectedValue any, actualResult any) (bool, error) {
|
|
// String variant case - check for exact match or boolean true
|
|
if actualStr, ok := actualResult.(string); ok && len(actualStr) > 0 {
|
|
if expectedBool, ok := expectedValue.(bool); ok {
|
|
// Any variant matches boolean true
|
|
return expectedBool, nil
|
|
} else if expectedStr, ok := expectedValue.(string); ok {
|
|
// variants are case-sensitive, hence our comparison is too
|
|
return actualStr == expectedStr, nil
|
|
} else {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
// Boolean case - must match expected boolean value
|
|
if actualBool, ok := actualResult.(bool); ok {
|
|
if expectedBool, ok := expectedValue.(bool); ok {
|
|
return actualBool == expectedBool, nil
|
|
}
|
|
}
|
|
|
|
// Default case
|
|
return false, nil
|
|
}
|
|
|
|
func newFeatureFlagsPoller(
|
|
projectApiKey string,
|
|
personalApiKey string,
|
|
logger Logger,
|
|
endpoint string,
|
|
httpClient http.Client,
|
|
pollingInterval time.Duration,
|
|
nextPollTick func() time.Duration,
|
|
flagTimeout time.Duration,
|
|
decider decider,
|
|
disableGeoIP bool,
|
|
) (*FeatureFlagsPoller, error) {
|
|
localEvaluationEndpoint := "/flags/definitions"
|
|
localEvalURL, err := url.Parse(endpoint + localEvaluationEndpoint)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating local evaluation URL - %w", err)
|
|
}
|
|
|
|
if nextPollTick == nil {
|
|
nextPollTick = func() time.Duration { return pollingInterval }
|
|
}
|
|
|
|
poller := FeatureFlagsPoller{
|
|
firstFeatureFlagRequestFinished: make(chan bool),
|
|
shutdown: make(chan bool),
|
|
forceReload: make(chan bool),
|
|
personalApiKey: personalApiKey,
|
|
projectApiKey: projectApiKey,
|
|
localEvalUrl: localEvalURL,
|
|
Logger: logger,
|
|
Endpoint: endpoint,
|
|
http: httpClient,
|
|
nextPollTick: nextPollTick,
|
|
flagTimeout: flagTimeout,
|
|
decider: decider,
|
|
disableGeoIP: disableGeoIP,
|
|
}
|
|
|
|
go poller.run()
|
|
return &poller, nil
|
|
}
|
|
|
|
func (poller *FeatureFlagsPoller) run() {
|
|
poller.fetchNewFeatureFlags()
|
|
close(poller.firstFeatureFlagRequestFinished)
|
|
|
|
for {
|
|
timer := time.NewTimer(poller.nextPollTick())
|
|
select {
|
|
case <-poller.shutdown:
|
|
close(poller.forceReload)
|
|
timer.Stop()
|
|
return
|
|
case <-poller.forceReload:
|
|
timer.Stop()
|
|
poller.fetchNewFeatureFlags()
|
|
case <-timer.C:
|
|
poller.fetchNewFeatureFlags()
|
|
}
|
|
}
|
|
}
|
|
|
|
// fetchNewFeatureFlags fetches the latest feature flag definitions from the Insights API
|
|
// These are used for local evaluation of feature flags and should not be confused with
|
|
// the feature flags fetched from the flags API.
|
|
func (poller *FeatureFlagsPoller) fetchNewFeatureFlags() {
|
|
personalApiKey := poller.personalApiKey
|
|
headers := http.Header{"Authorization": []string{"Bearer " + personalApiKey}}
|
|
|
|
// Read current ETag from state (lock-free)
|
|
currentState := poller.state.Load()
|
|
currentEtag := ""
|
|
if currentState != nil {
|
|
currentEtag = currentState.flagsEtag
|
|
}
|
|
|
|
res, cancel, err := poller.localEvaluationFlags(headers, currentEtag)
|
|
if err != nil {
|
|
poller.Logger.Errorf("Unable to fetch feature flags: %s", err)
|
|
return
|
|
}
|
|
defer cancel()
|
|
defer res.Body.Close()
|
|
|
|
// Handle 304 Not Modified - flags haven't changed, skip processing
|
|
if res.StatusCode == http.StatusNotModified {
|
|
poller.Logger.Debugf("[FEATURE FLAGS] Flags not modified (304), using cached data")
|
|
// Update ETag if server returned one (preserve existing if not)
|
|
if newEtag := res.Header.Get("ETag"); newEtag != "" && currentState != nil {
|
|
// Atomically swap with updated ETag
|
|
newState := &flagsState{
|
|
featureFlags: currentState.featureFlags,
|
|
flagsByKey: currentState.flagsByKey,
|
|
cohorts: currentState.cohorts,
|
|
groups: currentState.groups,
|
|
flagsEtag: newEtag,
|
|
}
|
|
poller.state.Store(newState)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle quota limit response (HTTP 402)
|
|
if res.StatusCode == http.StatusPaymentRequired {
|
|
// Clear existing flags when quota limited - atomic swap
|
|
poller.state.Store(&flagsState{
|
|
featureFlags: []FeatureFlag{},
|
|
cohorts: map[string]PropertyGroup{},
|
|
groups: map[string]string{},
|
|
flagsEtag: "",
|
|
})
|
|
poller.Logger.Warnf("[FEATURE FLAGS] feature flags quota limited, resetting feature flag data")
|
|
return
|
|
}
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
poller.Logger.Errorf("Unable to fetch feature flags, status: %s", res.Status)
|
|
return
|
|
}
|
|
|
|
resBody, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
poller.Logger.Errorf("Unable to fetch feature flags: %s", err)
|
|
return
|
|
}
|
|
var featureFlagsResponse FeatureFlagsResponse
|
|
if err = json.Unmarshal(resBody, &featureFlagsResponse); err != nil {
|
|
poller.Logger.Errorf("Unable to unmarshal response from api/feature_flag/local_evaluation: %s", err)
|
|
return
|
|
}
|
|
newFlags := append(make([]FeatureFlag, 0, len(featureFlagsResponse.Flags)), featureFlagsResponse.Flags...)
|
|
|
|
// Pre-decode payloads once at load time (avoids per-evaluation json unquoting)
|
|
preDecodePayloads(newFlags)
|
|
|
|
// Pre-build flagsByKey index for O(1) lookup during evaluation
|
|
flagsByKey := buildFlagsByKey(newFlags)
|
|
|
|
// Store new ETag from response (clear if server stops sending)
|
|
newEtag := res.Header.Get("ETag")
|
|
|
|
// Build new groups map
|
|
groups := map[string]string{}
|
|
if featureFlagsResponse.GroupTypeMapping != nil {
|
|
groups = *featureFlagsResponse.GroupTypeMapping
|
|
}
|
|
|
|
// Pre-parse cohort values into typed structs (avoids per-evaluation reconstruction)
|
|
parsedCohorts := preParseCohortValues(featureFlagsResponse.Cohorts)
|
|
|
|
// Atomic swap of entire state
|
|
poller.state.Store(&flagsState{
|
|
featureFlags: newFlags,
|
|
flagsByKey: flagsByKey,
|
|
cohorts: parsedCohorts,
|
|
groups: groups,
|
|
flagsEtag: newEtag,
|
|
})
|
|
}
|
|
|
|
// GetFeatureFlag evaluates one flag using locally loaded definitions when possible.
|
|
// It returns the flag value, whether that value was locally evaluated, and an error.
|
|
// If local evaluation is inconclusive and OnlyEvaluateLocally is false, it falls back to /flags.
|
|
func (poller *FeatureFlagsPoller) GetFeatureFlag(flagConfig FeatureFlagPayload) (any, bool, error) {
|
|
flag, err := poller.getFeatureFlag(flagConfig)
|
|
|
|
// Make sure person_properties contains distinct_id so /flags request payloads
|
|
// have it under person_properties (matches the batch path and what server expects).
|
|
personProps := mergeDistinctIDIntoProperties(flagConfig.PersonProperties, flagConfig.DistinctId)
|
|
|
|
var result any
|
|
locallyEvaluated := false
|
|
|
|
if flag.Key != "" {
|
|
result, err = poller.computeFlagLocally(
|
|
flag,
|
|
flagConfig.DistinctId,
|
|
flagConfig.DeviceId,
|
|
flagConfig.Groups,
|
|
personProps,
|
|
flagConfig.GroupProperties,
|
|
poller.getCohorts(),
|
|
)
|
|
locallyEvaluated = err == nil && result != nil
|
|
}
|
|
|
|
if err != nil {
|
|
poller.Logger.Warnf("Unable to compute flag locally (%s) - %s", flagConfig.Key, err)
|
|
}
|
|
|
|
if (err != nil || result == nil) && !flagConfig.OnlyEvaluateLocally {
|
|
result, err = poller.getFeatureFlagVariant(flagConfig.Key, flagConfig.DistinctId, flagConfig.DeviceId, flagConfig.Groups, personProps, flagConfig.GroupProperties)
|
|
if err != nil {
|
|
return nil, locallyEvaluated, err
|
|
}
|
|
}
|
|
|
|
return result, locallyEvaluated, err
|
|
}
|
|
|
|
// mergeDistinctIDIntoProperties returns a copy of properties with distinct_id added
|
|
// (if not already present). Used so /flags request payloads always carry distinct_id
|
|
// inside person_properties as well as at the top level.
|
|
func mergeDistinctIDIntoProperties(properties Properties, distinctID string) Properties {
|
|
if _, ok := properties["distinct_id"]; ok {
|
|
return properties
|
|
}
|
|
merged := make(Properties, len(properties)+1)
|
|
merged["distinct_id"] = distinctID
|
|
maps.Copy(merged, properties)
|
|
return merged
|
|
}
|
|
|
|
// GetFeatureFlagPayload returns the payload for the evaluated flag value.
|
|
// It tries local payloads first and falls back to the remote API unless OnlyEvaluateLocally is true.
|
|
func (poller *FeatureFlagsPoller) GetFeatureFlagPayload(flagConfig FeatureFlagPayload) (string, error) {
|
|
flag, err := poller.getFeatureFlag(flagConfig)
|
|
|
|
var variant any
|
|
|
|
if flag.Key != "" {
|
|
variant, err = poller.computeFlagLocally(
|
|
flag,
|
|
flagConfig.DistinctId,
|
|
flagConfig.DeviceId,
|
|
flagConfig.Groups,
|
|
flagConfig.PersonProperties,
|
|
flagConfig.GroupProperties,
|
|
poller.getCohorts(),
|
|
)
|
|
}
|
|
if err != nil {
|
|
poller.Logger.Warnf("Unable to compute flag locally (%s) - %s", flagConfig.Key, err)
|
|
} else if variant != nil {
|
|
if decoded, ok := flag.Filters.DecodedPayloads[variantToString(variant)]; ok {
|
|
return decoded, nil
|
|
}
|
|
}
|
|
|
|
if (variant == nil || err != nil) && !flagConfig.OnlyEvaluateLocally {
|
|
result, err := poller.getFeatureFlagPayload(flagConfig.Key, flagConfig.DistinctId, flagConfig.DeviceId, flagConfig.Groups, flagConfig.PersonProperties, flagConfig.GroupProperties)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
return "", errors.New("unable to compute flag locally")
|
|
}
|
|
|
|
// flagValueAndPayload holds the result of a single flag evaluation that returns
|
|
// both the flag value and its payload, avoiding the need for double evaluation.
|
|
type flagValueAndPayload struct {
|
|
value any
|
|
payload string
|
|
err error
|
|
locallyEvaluated bool
|
|
}
|
|
|
|
// GetFeatureFlagWithPayload evaluates a feature flag once and returns both its value
|
|
// and payload. This avoids the double evaluation that would happen when calling
|
|
// GetFeatureFlag and GetFeatureFlagPayload separately.
|
|
func (poller *FeatureFlagsPoller) GetFeatureFlagWithPayload(flagConfig FeatureFlagPayload) flagValueAndPayload {
|
|
flag, err := poller.getFeatureFlag(flagConfig)
|
|
|
|
var result any
|
|
|
|
if flag.Key != "" {
|
|
result, err = poller.computeFlagLocally(
|
|
flag,
|
|
flagConfig.DistinctId,
|
|
flagConfig.DeviceId,
|
|
flagConfig.Groups,
|
|
flagConfig.PersonProperties,
|
|
flagConfig.GroupProperties,
|
|
poller.getCohorts(),
|
|
)
|
|
}
|
|
|
|
if err != nil {
|
|
poller.Logger.Warnf("Unable to compute flag locally (%s) - %s", flagConfig.Key, err)
|
|
}
|
|
|
|
// Try to resolve payload from local evaluation result using pre-decoded payloads
|
|
var payload string
|
|
if err == nil && result != nil {
|
|
variantKey := variantToString(result)
|
|
if decoded, ok := flag.Filters.DecodedPayloads[variantKey]; ok {
|
|
payload = decoded
|
|
}
|
|
}
|
|
|
|
locallyEvaluated := err == nil && result != nil
|
|
|
|
// Fall back to remote evaluation if local didn't produce a result
|
|
if (err != nil || result == nil) && !flagConfig.OnlyEvaluateLocally {
|
|
flagsResponse, remoteErr := poller.getFeatureFlagVariants(flagConfig.DistinctId, flagConfig.DeviceId, flagConfig.Groups, flagConfig.PersonProperties, flagConfig.GroupProperties)
|
|
if remoteErr != nil {
|
|
return flagValueAndPayload{value: nil, err: remoteErr}
|
|
}
|
|
locallyEvaluated = false
|
|
// Clear local eval error — we successfully made a remote request
|
|
err = nil
|
|
if flagsResponse != nil {
|
|
if flagValue, ok := flagsResponse.FeatureFlags[flagConfig.Key]; ok {
|
|
result = flagValue
|
|
} else {
|
|
// Flag not in remote response — treat as false (matches getFeatureFlagVariant behavior)
|
|
result = false
|
|
}
|
|
if rawPayload, ok := flagsResponse.FeatureFlagPayloads[flagConfig.Key]; ok {
|
|
payload = rawMessageToString(rawPayload)
|
|
}
|
|
} else {
|
|
result = false
|
|
}
|
|
}
|
|
|
|
return flagValueAndPayload{value: result, payload: payload, err: err, locallyEvaluated: locallyEvaluated}
|
|
}
|
|
|
|
func (poller *FeatureFlagsPoller) getFeatureFlag(flagConfig FeatureFlagPayload) (FeatureFlag, error) {
|
|
// Wait for initial flag fetch to complete
|
|
<-poller.firstFeatureFlagRequestFinished
|
|
|
|
// Use pre-built index for O(1) lookup instead of linear scan
|
|
flagsByKey := poller.getFlagsByKey()
|
|
if flagsByKey == nil {
|
|
return FeatureFlag{}, errors.New("flags were not successfully fetched yet")
|
|
}
|
|
|
|
if f, ok := flagsByKey[flagConfig.Key]; ok {
|
|
return f, nil
|
|
}
|
|
return FeatureFlag{}, nil
|
|
}
|
|
|
|
// GetAllFlags evaluates every available flag for the configured user.
|
|
// Values are bools for boolean flags or strings for multivariate variants.
|
|
func (poller *FeatureFlagsPoller) GetAllFlags(flagConfig FeatureFlagPayloadNoKey) (map[string]any, error) {
|
|
featureFlags, err := poller.GetFeatureFlags()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fallbackToDecide := false
|
|
cohorts := poller.getCohorts()
|
|
|
|
// Pre-size response map to avoid rehashing as flags are added
|
|
response := make(map[string]any, len(featureFlags))
|
|
|
|
// Pre-merge distinct_id into person properties once for the entire batch,
|
|
// instead of copying the map per-flag inside computeFlagLocally.
|
|
personProps := mergeDistinctIDIntoProperties(flagConfig.PersonProperties, flagConfig.DistinctId)
|
|
|
|
if len(featureFlags) == 0 {
|
|
fallbackToDecide = true
|
|
} else {
|
|
for _, storedFlag := range featureFlags {
|
|
result, err := poller.computeFlagLocally(
|
|
storedFlag,
|
|
flagConfig.DistinctId,
|
|
flagConfig.DeviceId,
|
|
flagConfig.Groups,
|
|
personProps,
|
|
flagConfig.GroupProperties,
|
|
cohorts,
|
|
)
|
|
if err != nil {
|
|
poller.Logger.Warnf("Unable to compute flag locally (%s) - %s", storedFlag.Key, err)
|
|
fallbackToDecide = true
|
|
} else {
|
|
response[storedFlag.Key] = result
|
|
}
|
|
}
|
|
}
|
|
|
|
if fallbackToDecide && !flagConfig.OnlyEvaluateLocally {
|
|
flagsResponse, err := poller.getFeatureFlagVariants(
|
|
flagConfig.DistinctId,
|
|
flagConfig.DeviceId,
|
|
flagConfig.Groups,
|
|
flagConfig.PersonProperties,
|
|
flagConfig.GroupProperties,
|
|
)
|
|
|
|
if err != nil {
|
|
return response, err
|
|
}
|
|
if flagsResponse != nil {
|
|
maps.Copy(response, flagsResponse.FeatureFlags)
|
|
}
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
func (poller *FeatureFlagsPoller) computeFlagLocally(
|
|
flag FeatureFlag,
|
|
distinctId string,
|
|
deviceId *string,
|
|
groups Groups,
|
|
personProperties Properties,
|
|
groupProperties map[string]Properties,
|
|
cohorts map[string]PropertyGroup,
|
|
) (any, error) {
|
|
if flag.EnsureExperienceContinuity != nil && *flag.EnsureExperienceContinuity {
|
|
return nil, &InconclusiveMatchError{"Flag has experience continuity enabled"}
|
|
}
|
|
|
|
if !flag.Active {
|
|
return false, nil
|
|
}
|
|
|
|
// Use pre-built flagsByKey index (built once when flags are fetched, not per evaluation)
|
|
flagsByKey := poller.getFlagsByKey()
|
|
|
|
// evaluationCache is created lazily — only allocated when flag has dependencies.
|
|
// For simple flags (no dependencies), this avoids a map allocation per evaluation.
|
|
var evaluationCache map[string]any
|
|
if flagHasDependencies(flag) {
|
|
evaluationCache = make(map[string]any)
|
|
}
|
|
|
|
if flag.Filters.AggregationGroupTypeIndex != nil {
|
|
groupType, exists := poller.getGroups()[fmt.Sprintf("%d", *flag.Filters.AggregationGroupTypeIndex)]
|
|
|
|
if !exists {
|
|
errMessage := "flag has unknown group type index"
|
|
return nil, errors.New(errMessage)
|
|
}
|
|
|
|
groupKey, exists := groups[groupType]
|
|
|
|
if !exists {
|
|
errMessage := fmt.Sprintf("[FEATURE FLAGS] Can't compute group feature flag: %s without group names passed in", flag.Key)
|
|
return nil, errors.New(errMessage)
|
|
}
|
|
|
|
focusedGroupProperties := groupProperties[groupType]
|
|
if _, ok := focusedGroupProperties["$group_key"]; !ok {
|
|
focusedGroupProperties = Properties{"$group_key": groupKey}.Merge(focusedGroupProperties)
|
|
}
|
|
return poller.matchFeatureFlagProperties(flag, groups[groupType].(string), nil, focusedGroupProperties, cohorts, flagsByKey, evaluationCache, groups, groupProperties)
|
|
} else {
|
|
localPersonProperties := personProperties
|
|
// Only add distinct_id if the flag has conditions that check person properties.
|
|
// For simple flags (no property conditions), this avoids creating a map that's never read.
|
|
if flagHasPersonProperties(flag) {
|
|
if _, ok := localPersonProperties["distinct_id"]; !ok {
|
|
if personProperties == nil {
|
|
localPersonProperties = Properties{"distinct_id": distinctId}
|
|
} else {
|
|
localPersonProperties = make(Properties, len(personProperties)+1)
|
|
localPersonProperties["distinct_id"] = distinctId
|
|
maps.Copy(localPersonProperties, personProperties)
|
|
}
|
|
}
|
|
}
|
|
return poller.matchFeatureFlagProperties(flag, distinctId, deviceId, localPersonProperties, cohorts, flagsByKey, evaluationCache, groups, groupProperties)
|
|
}
|
|
}
|
|
|
|
func getMatchingVariant(flag FeatureFlag, bucketingId string) any {
|
|
// Use pre-computed lookup table if available, otherwise compute on the fly
|
|
lookupTable := flag.Filters.VariantLookupTable
|
|
if lookupTable == nil {
|
|
// Fast path: no multivariate variants means boolean flag — skip hash computation
|
|
if flag.Filters.Multivariate == nil || len(flag.Filters.Multivariate.Variants) == 0 {
|
|
return true
|
|
}
|
|
lookupTable = getVariantLookupTable(flag)
|
|
}
|
|
if len(lookupTable) == 0 {
|
|
return true
|
|
}
|
|
|
|
hashValue := calculateHash(flag.Key, bucketingId, "variant")
|
|
for _, variant := range lookupTable {
|
|
if hashValue >= float64(variant.ValueMin) && hashValue < float64(variant.ValueMax) {
|
|
return variant.Key
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func getBucketingID(flag FeatureFlag, distinctId string, deviceId *string) string {
|
|
if flag.BucketingIdentifier != nil && *flag.BucketingIdentifier == bucketingIdentifierDevice && deviceId != nil {
|
|
return *deviceId
|
|
}
|
|
return distinctId
|
|
}
|
|
|
|
func getVariantLookupTable(flag FeatureFlag) []FlagVariantMeta {
|
|
multivariates := flag.Filters.Multivariate
|
|
if multivariates == nil || multivariates.Variants == nil {
|
|
return nil
|
|
}
|
|
|
|
lookupTable := make([]FlagVariantMeta, 0, len(multivariates.Variants))
|
|
valueMin := 0.00
|
|
for _, variant := range multivariates.Variants {
|
|
valueMax := valueMin + *variant.RolloutPercentage/100.
|
|
lookupTable = append(lookupTable, FlagVariantMeta{ValueMin: valueMin, ValueMax: valueMax, Key: variant.Key})
|
|
valueMin = valueMax
|
|
}
|
|
return lookupTable
|
|
}
|
|
|
|
func (poller *FeatureFlagsPoller) matchFeatureFlagProperties(
|
|
flag FeatureFlag,
|
|
distinctId string,
|
|
deviceId *string,
|
|
properties Properties,
|
|
cohorts map[string]PropertyGroup,
|
|
flagsByKey map[string]FeatureFlag,
|
|
evaluationCache map[string]any,
|
|
groups Groups,
|
|
groupProperties map[string]Properties,
|
|
) (any, error) {
|
|
conditions := flag.Filters.Groups
|
|
bucketingId := getBucketingID(flag, distinctId, deviceId)
|
|
flagAggregation := flag.Filters.AggregationGroupTypeIndex
|
|
groupTypeMapping := poller.getGroups()
|
|
isInconclusive := false
|
|
|
|
for _, condition := range conditions {
|
|
// Per-condition aggregation overrides only when the condition explicitly
|
|
// sets its own AggregationGroupTypeIndex that differs from the flag level
|
|
// (mixed targeting). When absent, fall back to the flag-level aggregation
|
|
// so existing pure person and pure group flags keep their original behavior.
|
|
conditionAggregation := condition.AggregationGroupTypeIndex
|
|
if conditionAggregation == nil {
|
|
conditionAggregation = flagAggregation
|
|
}
|
|
|
|
effectiveProperties := properties
|
|
effectiveBucketingId := bucketingId
|
|
|
|
// Mixed-override path: condition-level aggregation differs from flag-level.
|
|
// This assumes flag-level aggregation is nil for mixed flags.
|
|
if !uint8PtrEqual(conditionAggregation, flagAggregation) {
|
|
if conditionAggregation != nil {
|
|
groupType, exists := groupTypeMapping[fmt.Sprintf("%d", *conditionAggregation)]
|
|
if !exists {
|
|
continue
|
|
}
|
|
groupKey, hasGroup := groups[groupType]
|
|
if !hasGroup {
|
|
continue
|
|
}
|
|
focusedGroupProperties, hasProps := groupProperties[groupType]
|
|
if !hasProps {
|
|
isInconclusive = true
|
|
continue
|
|
}
|
|
groupKeyStr, ok := groupKey.(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, exists := focusedGroupProperties["$group_key"]; !exists {
|
|
focusedGroupProperties = Properties{"$group_key": groupKey}.Merge(focusedGroupProperties)
|
|
}
|
|
effectiveProperties = focusedGroupProperties
|
|
effectiveBucketingId = groupKeyStr
|
|
}
|
|
}
|
|
|
|
isMatch, err := poller.isConditionMatch(flag, distinctId, effectiveBucketingId, deviceId, condition, effectiveProperties, cohorts, flagsByKey, evaluationCache)
|
|
if err != nil {
|
|
// Use direct type switch instead of errors.As to avoid pointer escape allocations.
|
|
// Our error types are returned directly (not wrapped), so type assertion suffices.
|
|
switch err.(type) {
|
|
case *RequiresServerEvaluationError:
|
|
// Static cohort or other missing server-side data - must fallback to API
|
|
return nil, err
|
|
case *InconclusiveMatchError:
|
|
// Evaluation error (bad regex, invalid date, missing property, etc.)
|
|
// Track that we had an inconclusive match, but try other conditions
|
|
isInconclusive = true
|
|
default:
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if isMatch {
|
|
variantOverride := condition.Variant
|
|
multivariates := flag.Filters.Multivariate
|
|
|
|
if variantOverride != nil && multivariates != nil && multivariates.Variants != nil && containsVariant(multivariates.Variants, *variantOverride) {
|
|
return *variantOverride, nil
|
|
} else {
|
|
return getMatchingVariant(flag, effectiveBucketingId), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if isInconclusive {
|
|
return false, errInconclusiveMatch
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
func uint8PtrEqual(a, b *uint8) bool {
|
|
if a == nil || b == nil {
|
|
return a == b
|
|
}
|
|
return *a == *b
|
|
}
|
|
|
|
func (poller *FeatureFlagsPoller) isConditionMatch(
|
|
flag FeatureFlag,
|
|
distinctId string,
|
|
bucketingId string,
|
|
deviceId *string,
|
|
condition FeatureFlagCondition,
|
|
properties Properties,
|
|
cohorts map[string]PropertyGroup,
|
|
flagsByKey map[string]FeatureFlag,
|
|
evaluationCache map[string]any,
|
|
) (bool, error) {
|
|
if len(condition.Properties) > 0 {
|
|
var (
|
|
isMatch bool
|
|
err error
|
|
)
|
|
for _, prop := range condition.Properties {
|
|
if prop.Type == "cohort" {
|
|
isMatch, err = poller.matchCohort(prop, properties, cohorts, flagsByKey, evaluationCache, distinctId, deviceId)
|
|
} else if prop.Type == "flag" {
|
|
isMatch, err = poller.evaluateFlagDependency(prop, flagsByKey, evaluationCache, distinctId, deviceId, properties, cohorts)
|
|
} else {
|
|
isMatch, err = matchProperty(prop, properties)
|
|
}
|
|
|
|
if err != nil || !isMatch {
|
|
return false, err
|
|
}
|
|
}
|
|
}
|
|
|
|
if condition.RolloutPercentage != nil {
|
|
return checkIfSimpleFlagEnabled(flag.Key, bucketingId, *condition.RolloutPercentage), nil
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (poller *FeatureFlagsPoller) matchCohort(property FlagProperty, properties Properties, cohorts map[string]PropertyGroup, flagsByKey map[string]FeatureFlag, evaluationCache map[string]any, distinctId string, deviceId *string) (bool, error) {
|
|
cohortId := valueToString(property.Value)
|
|
propertyGroup, ok := cohorts[cohortId]
|
|
if !ok {
|
|
return false, errCohortRequiresServerEval
|
|
}
|
|
|
|
return poller.matchPropertyGroup(propertyGroup, properties, cohorts, flagsByKey, evaluationCache, distinctId, deviceId)
|
|
}
|
|
|
|
func (poller *FeatureFlagsPoller) matchPropertyGroup(propertyGroup PropertyGroup, properties Properties, cohorts map[string]PropertyGroup, flagsByKey map[string]FeatureFlag, evaluationCache map[string]any, distinctId string, deviceId *string) (bool, error) {
|
|
groupType := propertyGroup.Type
|
|
|
|
// Use pre-parsed values if available (built at load time), otherwise fall back to raw values
|
|
if len(propertyGroup.ParsedValues) > 0 {
|
|
return poller.matchParsedPropertyGroup(groupType, propertyGroup.ParsedValues, properties, cohorts, flagsByKey, evaluationCache, distinctId, deviceId)
|
|
}
|
|
|
|
values := propertyGroup.Values
|
|
if len(values) == 0 {
|
|
// empty groups are no-ops, always match
|
|
return true, nil
|
|
}
|
|
|
|
errorMatchingLocally := false
|
|
|
|
for _, value := range values {
|
|
switch prop := value.(type) {
|
|
case map[string]any:
|
|
if _, ok := prop["values"]; ok {
|
|
// PropertyGroup
|
|
matches, err := poller.matchPropertyGroup(PropertyGroup{
|
|
Type: getSafeProp[string](prop, "type"),
|
|
Values: getSafeProp[[]any](prop, "values"),
|
|
}, properties, cohorts, flagsByKey, evaluationCache, distinctId, deviceId)
|
|
if err != nil {
|
|
if isServerEvalError(err) {
|
|
return false, err
|
|
} else if isInconclusiveError(err) {
|
|
errorMatchingLocally = true
|
|
} else {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
if groupType == "AND" {
|
|
if !matches {
|
|
return false, nil
|
|
}
|
|
} else {
|
|
if matches {
|
|
return true, nil
|
|
}
|
|
}
|
|
} else {
|
|
// FlagProperty
|
|
var matches bool
|
|
var err error
|
|
flagProperty := FlagProperty{
|
|
Key: getSafeProp[string](prop, "key"),
|
|
Operator: getSafeProp[string](prop, "operator"),
|
|
Value: getSafeProp[any](prop, "value"),
|
|
Type: getSafeProp[string](prop, "type"),
|
|
Negation: getSafeProp[bool](prop, "negation"),
|
|
DependencyChain: getSafeProp[[]string](prop, "dependency_chain"),
|
|
}
|
|
if prop["type"] == "cohort" {
|
|
matches, err = poller.matchCohort(flagProperty, properties, cohorts, flagsByKey, evaluationCache, distinctId, deviceId)
|
|
} else if prop["type"] == "flag" {
|
|
matches, err = poller.evaluateFlagDependency(flagProperty, flagsByKey, evaluationCache, distinctId, deviceId, properties, cohorts)
|
|
} else {
|
|
matches, err = matchProperty(flagProperty, properties)
|
|
}
|
|
|
|
if err != nil {
|
|
if isServerEvalError(err) {
|
|
return false, err
|
|
} else if isInconclusiveError(err) {
|
|
errorMatchingLocally = true
|
|
} else {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
negation := flagProperty.Negation
|
|
if groupType == "AND" {
|
|
if !matches && !negation {
|
|
return false, nil
|
|
}
|
|
if matches && negation {
|
|
return false, nil
|
|
}
|
|
} else {
|
|
if matches && !negation {
|
|
return true, nil
|
|
}
|
|
if !matches && negation {
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if errorMatchingLocally {
|
|
return false, errCohortPropertyValue
|
|
}
|
|
|
|
return groupType == "AND", nil
|
|
}
|
|
|
|
// matchParsedPropertyGroup evaluates pre-parsed property values without per-evaluation
|
|
// reconstruction from map[string]any. This is the fast path for cohort matching.
|
|
func (poller *FeatureFlagsPoller) matchParsedPropertyGroup(groupType string, parsedValues []parsedPropertyValue, properties Properties, cohorts map[string]PropertyGroup, flagsByKey map[string]FeatureFlag, evaluationCache map[string]any, distinctId string, deviceId *string) (bool, error) {
|
|
errorMatchingLocally := false
|
|
|
|
for i := range parsedValues {
|
|
pv := &parsedValues[i]
|
|
if pv.IsGroup {
|
|
matches, err := poller.matchPropertyGroup(pv.Group, properties, cohorts, flagsByKey, evaluationCache, distinctId, deviceId)
|
|
if err != nil {
|
|
if isServerEvalError(err) {
|
|
return false, err
|
|
} else if isInconclusiveError(err) {
|
|
errorMatchingLocally = true
|
|
} else {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
if groupType == "AND" {
|
|
if !matches {
|
|
return false, nil
|
|
}
|
|
} else {
|
|
if matches {
|
|
return true, nil
|
|
}
|
|
}
|
|
} else {
|
|
var matches bool
|
|
var err error
|
|
fp := &pv.Property
|
|
if fp.Type == "cohort" {
|
|
matches, err = poller.matchCohort(*fp, properties, cohorts, flagsByKey, evaluationCache, distinctId, deviceId)
|
|
} else if fp.Type == "flag" {
|
|
matches, err = poller.evaluateFlagDependency(*fp, flagsByKey, evaluationCache, distinctId, deviceId, properties, cohorts)
|
|
} else {
|
|
matches, err = matchProperty(*fp, properties)
|
|
}
|
|
|
|
if err != nil {
|
|
if isServerEvalError(err) {
|
|
return false, err
|
|
} else if isInconclusiveError(err) {
|
|
errorMatchingLocally = true
|
|
} else {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
negation := fp.Negation
|
|
if groupType == "AND" {
|
|
if !matches && !negation {
|
|
return false, nil
|
|
}
|
|
if matches && negation {
|
|
return false, nil
|
|
}
|
|
} else {
|
|
if matches && !negation {
|
|
return true, nil
|
|
}
|
|
if !matches && negation {
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if errorMatchingLocally {
|
|
return false, errCohortPropertyValue
|
|
}
|
|
|
|
return groupType == "AND", nil
|
|
}
|
|
|
|
func matchProperty(property FlagProperty, properties Properties) (bool, error) {
|
|
key := property.Key
|
|
operator := property.Operator
|
|
value := property.Value
|
|
if _, ok := properties[key]; !ok {
|
|
return false, errMissingPropertyValue
|
|
}
|
|
|
|
if operator == "is_not_set" {
|
|
return false, errOperatorIsNotSet
|
|
}
|
|
|
|
override_value := properties[key]
|
|
|
|
if operator == "exact" {
|
|
switch t := value.(type) {
|
|
case []any:
|
|
return contains(t, override_value), nil
|
|
default:
|
|
return value == override_value, nil
|
|
}
|
|
}
|
|
|
|
if operator == "is_not" {
|
|
switch t := value.(type) {
|
|
case []any:
|
|
return !contains(t, override_value), nil
|
|
default:
|
|
return value != override_value, nil
|
|
}
|
|
}
|
|
|
|
if operator == "is_set" {
|
|
return true, nil
|
|
}
|
|
|
|
if operator == "icontains" {
|
|
return strings.Contains(strings.ToLower(valueToString(override_value)), strings.ToLower(valueToString(value))), nil
|
|
}
|
|
|
|
if operator == "not_icontains" {
|
|
return !strings.Contains(strings.ToLower(valueToString(override_value)), strings.ToLower(valueToString(value))), nil
|
|
}
|
|
|
|
if operator == "regex" {
|
|
r, err := getOrCompileRegex(valueToString(value))
|
|
// invalid regex
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
|
|
return r.MatchString(valueToString(override_value)), nil
|
|
}
|
|
|
|
if operator == "not_regex" {
|
|
var pattern string
|
|
|
|
if valueString, ok := value.(string); ok {
|
|
pattern = valueString
|
|
} else if valueInt, ok := value.(int); ok {
|
|
pattern = strconv.Itoa(valueInt)
|
|
} else {
|
|
return false, errors.New("regex expression not allowed")
|
|
}
|
|
|
|
r, err := getOrCompileRegex(pattern)
|
|
// invalid regex
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
|
|
var match bool
|
|
if valueString, ok := override_value.(string); ok {
|
|
match = r.MatchString(valueString)
|
|
} else if valueInt, ok := override_value.(int); ok {
|
|
match = r.MatchString(strconv.Itoa(valueInt))
|
|
} else {
|
|
return false, errors.New("value type not supported")
|
|
}
|
|
|
|
return !match, nil
|
|
}
|
|
|
|
if operator == "gt" {
|
|
valueOrderable, overrideValueOrderable, err := validateOrderable(value, override_value)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return overrideValueOrderable > valueOrderable, nil
|
|
}
|
|
|
|
if operator == "lt" {
|
|
valueOrderable, overrideValueOrderable, err := validateOrderable(value, override_value)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return overrideValueOrderable < valueOrderable, nil
|
|
}
|
|
|
|
if operator == "gte" {
|
|
valueOrderable, overrideValueOrderable, err := validateOrderable(value, override_value)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return overrideValueOrderable >= valueOrderable, nil
|
|
}
|
|
|
|
if operator == "lte" {
|
|
valueOrderable, overrideValueOrderable, err := validateOrderable(value, override_value)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return overrideValueOrderable <= valueOrderable, nil
|
|
}
|
|
|
|
if operator == "is_date_before" || operator == "is_date_after" {
|
|
overrideTime, err := validateDates(override_value)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
valueTime, err := validateDates(value)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if operator == "is_date_before" {
|
|
return overrideTime.Before(valueTime), nil
|
|
}
|
|
|
|
return overrideTime.After(valueTime), nil
|
|
}
|
|
|
|
// Semver comparison operators
|
|
if operator == "semver_eq" || operator == "semver_neq" ||
|
|
operator == "semver_gt" || operator == "semver_gte" ||
|
|
operator == "semver_lt" || operator == "semver_lte" {
|
|
|
|
overrideStr, ok := override_value.(string)
|
|
if !ok {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("semver comparison requires string value, got %T", override_value)}
|
|
}
|
|
|
|
overrideParsed, err := parseSemver(overrideStr)
|
|
if err != nil {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("property value '%s' is not a valid semver", overrideStr)}
|
|
}
|
|
|
|
valueStr, ok := value.(string)
|
|
if !ok {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value must be a string, got %T", value)}
|
|
}
|
|
|
|
valueParsed, err := parseSemver(valueStr)
|
|
if err != nil {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value '%s' is not a valid semver", valueStr)}
|
|
}
|
|
|
|
cmp := overrideParsed.compareTo(valueParsed)
|
|
|
|
switch operator {
|
|
case "semver_eq":
|
|
return cmp == 0, nil
|
|
case "semver_neq":
|
|
return cmp != 0, nil
|
|
case "semver_gt":
|
|
return cmp > 0, nil
|
|
case "semver_gte":
|
|
return cmp >= 0, nil
|
|
case "semver_lt":
|
|
return cmp < 0, nil
|
|
case "semver_lte":
|
|
return cmp <= 0, nil
|
|
}
|
|
}
|
|
|
|
if operator == "semver_tilde" {
|
|
overrideStr, ok := override_value.(string)
|
|
if !ok {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("semver comparison requires string value, got %T", override_value)}
|
|
}
|
|
|
|
overrideParsed, err := parseSemver(overrideStr)
|
|
if err != nil {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("property value '%s' is not a valid semver", overrideStr)}
|
|
}
|
|
|
|
valueStr, ok := value.(string)
|
|
if !ok {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value must be a string, got %T", value)}
|
|
}
|
|
|
|
lower, upper, err := computeTildeBounds(valueStr)
|
|
if err != nil {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value '%s' is not valid for tilde operator", valueStr)}
|
|
}
|
|
|
|
// Check: lower <= override < upper
|
|
return overrideParsed.compareTo(lower) >= 0 && overrideParsed.compareTo(upper) < 0, nil
|
|
}
|
|
|
|
if operator == "semver_caret" {
|
|
overrideStr, ok := override_value.(string)
|
|
if !ok {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("semver comparison requires string value, got %T", override_value)}
|
|
}
|
|
|
|
overrideParsed, err := parseSemver(overrideStr)
|
|
if err != nil {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("property value '%s' is not a valid semver", overrideStr)}
|
|
}
|
|
|
|
valueStr, ok := value.(string)
|
|
if !ok {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value must be a string, got %T", value)}
|
|
}
|
|
|
|
lower, upper, err := computeCaretBounds(valueStr)
|
|
if err != nil {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value '%s' is not valid for caret operator", valueStr)}
|
|
}
|
|
|
|
// Check: lower <= override < upper
|
|
return overrideParsed.compareTo(lower) >= 0 && overrideParsed.compareTo(upper) < 0, nil
|
|
}
|
|
|
|
if operator == "semver_wildcard" {
|
|
overrideStr, ok := override_value.(string)
|
|
if !ok {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("semver comparison requires string value, got %T", override_value)}
|
|
}
|
|
|
|
overrideParsed, err := parseSemver(overrideStr)
|
|
if err != nil {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("property value '%s' is not a valid semver", overrideStr)}
|
|
}
|
|
|
|
valueStr, ok := value.(string)
|
|
if !ok {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value must be a string, got %T", value)}
|
|
}
|
|
|
|
lower, upper, err := computeWildcardBounds(valueStr)
|
|
if err != nil {
|
|
return false, &InconclusiveMatchError{fmt.Sprintf("flag semver value '%s' is not valid for wildcard operator", valueStr)}
|
|
}
|
|
|
|
// Check: lower <= override < upper
|
|
return overrideParsed.compareTo(lower) >= 0 && overrideParsed.compareTo(upper) < 0, nil
|
|
}
|
|
|
|
return false, &InconclusiveMatchError{"Unknown operator: " + operator}
|
|
|
|
}
|
|
|
|
func validateOrderable(firstValue any, secondValue any) (float64, float64, error) {
|
|
convertedFirstValue, err := interfaceToFloat(firstValue)
|
|
if err != nil {
|
|
errMessage := "value 1 is not orderable"
|
|
return 0, 0, errors.New(errMessage)
|
|
}
|
|
convertedSecondValue, err := interfaceToFloat(secondValue)
|
|
if err != nil {
|
|
errMessage := "value 2 is not orderable"
|
|
return 0, 0, errors.New(errMessage)
|
|
}
|
|
|
|
return convertedFirstValue, convertedSecondValue, nil
|
|
}
|
|
|
|
func validateDates(value any) (time.Time, error) {
|
|
dateStr, ok := value.(string)
|
|
if !ok {
|
|
return time.Time{}, errors.New("date comparison requires string values")
|
|
}
|
|
|
|
parsedTime, err := parseDate(dateStr)
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
return parsedTime, nil
|
|
}
|
|
|
|
// parseDate parses a date string which can be in various ISO 8601 formats or relative format (e.g., "-7d")
|
|
// Supported formats:
|
|
// - RFC3339 with fractional seconds and timezone offsets: "2020-01-01T10:00:00.123Z", "2020-01-01T10:00:00.123+05:30"
|
|
// - RFC3339: "2020-01-01T10:00:00Z" or "2020-01-01T10:00:00+00:00"
|
|
// - Date only: "2020-01-01"
|
|
// - DateTime without timezone: "2020-01-01T10:00:00"
|
|
// - Relative: "-7d", "1h", etc.
|
|
func parseDate(dateStr string) (time.Time, error) {
|
|
formats := []string{
|
|
time.RFC3339Nano, // Handles: fractional seconds, timezone offsets, and Z (e.g., 2024-01-15T10:30:00.123Z, 2024-01-15T10:30:00+05:30)
|
|
time.RFC3339, // Handles: basic RFC3339 without fractional seconds (e.g., 2024-01-15T10:30:00Z)
|
|
time.DateOnly, // Handles: date-only format (e.g., 2024-01-15)
|
|
time.DateTime, // Handles: datetime with space separator (e.g., 2024-01-15 10:30:00)
|
|
"2006-01-02T15:04:05", // Handles: datetime without timezone (e.g., 2024-01-15T10:30:05)
|
|
}
|
|
|
|
for _, format := range formats {
|
|
t, err := time.Parse(format, dateStr)
|
|
if err == nil {
|
|
return t, nil
|
|
}
|
|
}
|
|
|
|
relativeTime, relErr := parseRelativeDate(dateStr)
|
|
if relErr == nil {
|
|
return relativeTime, nil
|
|
}
|
|
|
|
return time.Time{}, errors.New("invalid date format, must be ISO 8601 or relative format (e.g., '-7d')")
|
|
}
|
|
|
|
// parseRelativeDate parses relative date format (e.g., "-1d" or "1d" for 1 day ago).
|
|
// Always produces a date in the past.
|
|
func parseRelativeDate(dateStr string) (time.Time, error) {
|
|
matches := relativeDateRegex.FindStringSubmatch(dateStr)
|
|
if matches == nil {
|
|
return time.Time{}, errors.New("invalid relative date format")
|
|
}
|
|
|
|
number, err := strconv.Atoi(matches[1])
|
|
if err != nil {
|
|
return time.Time{}, errors.New("invalid number in relative date")
|
|
}
|
|
|
|
// Avoid overflow or overly large date ranges
|
|
if number >= 10000 {
|
|
return time.Time{}, errors.New("relative date number too large (must be < 10000)")
|
|
}
|
|
|
|
interval := matches[2]
|
|
now := time.Now()
|
|
|
|
switch interval {
|
|
case "h":
|
|
return now.Add(-time.Duration(number) * time.Hour), nil
|
|
case "d":
|
|
return now.AddDate(0, 0, -number), nil
|
|
case "w":
|
|
return now.AddDate(0, 0, -number*7), nil
|
|
case "m":
|
|
return now.AddDate(0, -number, 0), nil
|
|
case "y":
|
|
return now.AddDate(-number, 0, 0), nil
|
|
default:
|
|
return time.Time{}, errors.New("invalid interval in relative date")
|
|
}
|
|
}
|
|
|
|
// semverTuple represents a parsed semantic version as (major, minor, patch).
|
|
type semverTuple struct {
|
|
major, minor, patch int
|
|
}
|
|
|
|
// compareTo returns -1 if s < other, 0 if s == other, 1 if s > other.
|
|
func (s semverTuple) compareTo(other semverTuple) int {
|
|
if s.major != other.major {
|
|
if s.major < other.major {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
if s.minor != other.minor {
|
|
if s.minor < other.minor {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
if s.patch != other.patch {
|
|
if s.patch < other.patch {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// parseSemverNumeric parses a single numeric identifier, rejecting leading
|
|
// zeros per semver 2.0.0 §2 (numeric identifiers MUST NOT include them).
|
|
func parseSemverNumeric(part string) (int, error) {
|
|
if part == "" {
|
|
return 0, errors.New("empty numeric identifier")
|
|
}
|
|
if len(part) > 1 && part[0] == '0' {
|
|
return 0, fmt.Errorf("numeric identifier has leading zero: '%s'", part)
|
|
}
|
|
return strconv.Atoi(part)
|
|
}
|
|
|
|
// parseSemver parses a version string into a semverTuple.
|
|
// Parsing rules:
|
|
// 1. Strip leading/trailing whitespace
|
|
// 2. Strip v or V prefix
|
|
// 3. Strip pre-release and build metadata suffixes (split on - or +)
|
|
// 4. Split on . and parse first 3 components as integers
|
|
// 5. Default missing components to 0
|
|
// 6. Ignore extra components beyond the third
|
|
// 7. Return error for invalid input (including numeric identifiers with leading zeros)
|
|
func parseSemver(value string) (semverTuple, error) {
|
|
text := strings.TrimSpace(value)
|
|
|
|
// Strip v/V prefix
|
|
if len(text) > 0 && (text[0] == 'v' || text[0] == 'V') {
|
|
text = text[1:]
|
|
}
|
|
|
|
if text == "" {
|
|
return semverTuple{}, errors.New("invalid semver: empty string")
|
|
}
|
|
|
|
// Find the core version (before any - or +)
|
|
// We need to validate the core before stripping suffixes
|
|
coreEnd := len(text)
|
|
if idx := strings.Index(text, "-"); idx >= 0 {
|
|
coreEnd = idx
|
|
}
|
|
if idx := strings.Index(text, "+"); idx >= 0 && idx < coreEnd {
|
|
coreEnd = idx
|
|
}
|
|
core := text[:coreEnd]
|
|
|
|
if core == "" {
|
|
return semverTuple{}, errors.New("invalid semver: empty version before suffix")
|
|
}
|
|
|
|
parts := strings.Split(core, ".")
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
return semverTuple{}, errors.New("invalid semver: no version components")
|
|
}
|
|
|
|
major, err := parseSemverNumeric(parts[0])
|
|
if err != nil {
|
|
return semverTuple{}, fmt.Errorf("invalid semver: major version '%s': %w", parts[0], err)
|
|
}
|
|
|
|
minor := 0
|
|
if len(parts) > 1 {
|
|
if parts[1] == "" {
|
|
return semverTuple{}, errors.New("invalid semver: empty minor version component")
|
|
}
|
|
minor, err = parseSemverNumeric(parts[1])
|
|
if err != nil {
|
|
return semverTuple{}, fmt.Errorf("invalid semver: minor version '%s': %w", parts[1], err)
|
|
}
|
|
}
|
|
|
|
patch := 0
|
|
if len(parts) > 2 {
|
|
if parts[2] == "" {
|
|
return semverTuple{}, errors.New("invalid semver: empty patch version component")
|
|
}
|
|
patch, err = parseSemverNumeric(parts[2])
|
|
if err != nil {
|
|
return semverTuple{}, fmt.Errorf("invalid semver: patch version '%s': %w", parts[2], err)
|
|
}
|
|
}
|
|
|
|
return semverTuple{major: major, minor: minor, patch: patch}, nil
|
|
}
|
|
|
|
// computeTildeBounds computes the bounds for the tilde (~) operator.
|
|
// ~X.Y.Z means >=X.Y.Z and <X.(Y+1).0
|
|
func computeTildeBounds(value string) (lower, upper semverTuple, err error) {
|
|
parsed, err := parseSemver(value)
|
|
if err != nil {
|
|
return semverTuple{}, semverTuple{}, err
|
|
}
|
|
lower = parsed
|
|
upper = semverTuple{major: parsed.major, minor: parsed.minor + 1, patch: 0}
|
|
return lower, upper, nil
|
|
}
|
|
|
|
// computeCaretBounds computes the bounds for the caret (^) operator.
|
|
// ^X.Y.Z where:
|
|
// - X > 0: >=X.Y.Z <(X+1).0.0
|
|
// - X == 0, Y > 0: >=0.Y.Z <0.(Y+1).0
|
|
// - X == 0, Y == 0: >=0.0.Z <0.0.(Z+1)
|
|
func computeCaretBounds(value string) (lower, upper semverTuple, err error) {
|
|
parsed, err := parseSemver(value)
|
|
if err != nil {
|
|
return semverTuple{}, semverTuple{}, err
|
|
}
|
|
lower = parsed
|
|
|
|
if parsed.major > 0 {
|
|
upper = semverTuple{major: parsed.major + 1, minor: 0, patch: 0}
|
|
} else if parsed.minor > 0 {
|
|
upper = semverTuple{major: 0, minor: parsed.minor + 1, patch: 0}
|
|
} else {
|
|
upper = semverTuple{major: 0, minor: 0, patch: parsed.patch + 1}
|
|
}
|
|
return lower, upper, nil
|
|
}
|
|
|
|
// computeWildcardBounds computes the bounds for the wildcard (*) operator.
|
|
// X.* means >=X.0.0 <(X+1).0.0
|
|
// X.Y.* means >=X.Y.0 <X.(Y+1).0
|
|
func computeWildcardBounds(value string) (lower, upper semverTuple, err error) {
|
|
text := strings.TrimSpace(value)
|
|
|
|
// Strip v/V prefix
|
|
if len(text) > 0 && (text[0] == 'v' || text[0] == 'V') {
|
|
text = text[1:]
|
|
}
|
|
|
|
// Remove wildcards and trailing dots
|
|
text = strings.ReplaceAll(text, "*", "")
|
|
text = strings.TrimRight(text, ".")
|
|
|
|
if text == "" {
|
|
return semverTuple{}, semverTuple{}, errors.New("invalid wildcard pattern: empty after removing wildcards")
|
|
}
|
|
|
|
parts := strings.Split(text, ".")
|
|
// Filter out empty parts
|
|
var nonEmptyParts []string
|
|
for _, p := range parts {
|
|
if p != "" {
|
|
nonEmptyParts = append(nonEmptyParts, p)
|
|
}
|
|
}
|
|
|
|
if len(nonEmptyParts) == 0 {
|
|
return semverTuple{}, semverTuple{}, errors.New("invalid wildcard pattern: no version components")
|
|
}
|
|
|
|
major, err := parseSemverNumeric(nonEmptyParts[0])
|
|
if err != nil {
|
|
return semverTuple{}, semverTuple{}, fmt.Errorf("invalid wildcard pattern: major version '%s': %w", nonEmptyParts[0], err)
|
|
}
|
|
|
|
if len(nonEmptyParts) == 1 {
|
|
// X.* pattern
|
|
lower = semverTuple{major: major, minor: 0, patch: 0}
|
|
upper = semverTuple{major: major + 1, minor: 0, patch: 0}
|
|
return lower, upper, nil
|
|
}
|
|
|
|
minor, err := parseSemverNumeric(nonEmptyParts[1])
|
|
if err != nil {
|
|
return semverTuple{}, semverTuple{}, fmt.Errorf("invalid wildcard pattern: minor version '%s': %w", nonEmptyParts[1], err)
|
|
}
|
|
|
|
if len(nonEmptyParts) == 2 {
|
|
// X.Y.* pattern
|
|
lower = semverTuple{major: major, minor: minor, patch: 0}
|
|
upper = semverTuple{major: major, minor: minor + 1, patch: 0}
|
|
return lower, upper, nil
|
|
}
|
|
|
|
// X.Y.Z.* pattern - treat as X.Y.Z to X.Y.(Z+1)
|
|
patch, err := parseSemverNumeric(nonEmptyParts[2])
|
|
if err != nil {
|
|
return semverTuple{}, semverTuple{}, fmt.Errorf("invalid wildcard pattern: patch version '%s': %w", nonEmptyParts[2], err)
|
|
}
|
|
lower = semverTuple{major: major, minor: minor, patch: patch}
|
|
upper = semverTuple{major: major, minor: minor, patch: patch + 1}
|
|
return lower, upper, nil
|
|
}
|
|
|
|
func interfaceToFloat(val any) (float64, error) {
|
|
var i float64
|
|
switch t := val.(type) {
|
|
case int:
|
|
i = float64(t)
|
|
case int8:
|
|
i = float64(t)
|
|
case int16:
|
|
i = float64(t)
|
|
case int32:
|
|
i = float64(t)
|
|
case int64:
|
|
i = float64(t)
|
|
case float32:
|
|
i = float64(t)
|
|
case float64:
|
|
i = float64(t)
|
|
case uint8:
|
|
i = float64(t)
|
|
case uint16:
|
|
i = float64(t)
|
|
case uint32:
|
|
i = float64(t)
|
|
case uint64:
|
|
i = float64(t)
|
|
default:
|
|
errMessage := "argument not orderable"
|
|
return 0.0, errors.New(errMessage)
|
|
}
|
|
|
|
return i, nil
|
|
}
|
|
|
|
func contains(s []any, e any) bool {
|
|
return slices.Contains(s, e)
|
|
}
|
|
|
|
func containsVariant(variantList []FlagVariant, key string) bool {
|
|
for _, variant := range variantList {
|
|
if variant.Key == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// flagHasDependencies returns true if any condition in the flag references another flag
|
|
// or cohort, requiring an evaluationCache for dependency resolution.
|
|
func flagHasDependencies(flag FeatureFlag) bool {
|
|
for _, group := range flag.Filters.Groups {
|
|
for _, prop := range group.Properties {
|
|
if prop.Type == "flag" || prop.Type == "cohort" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// flagHasPersonProperties returns true if any condition in the flag checks person properties.
|
|
// When false, the distinct_id merge into personProperties can be skipped entirely.
|
|
func flagHasPersonProperties(flag FeatureFlag) bool {
|
|
for _, group := range flag.Filters.Groups {
|
|
if len(group.Properties) > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// variantToString converts a flag variant value to its string key for payload lookup.
|
|
// Flag variants are always bool or string, so we avoid fmt.Sprintf overhead.
|
|
// valueToString converts an interface{} value to string without fmt.Sprint allocation
|
|
// for common types (string, int, float64). Used for cohort ID lookup.
|
|
func valueToString(v any) string {
|
|
switch val := v.(type) {
|
|
case string:
|
|
return val
|
|
case float64:
|
|
if val == float64(int64(val)) {
|
|
return strconv.FormatInt(int64(val), 10)
|
|
}
|
|
return strconv.FormatFloat(val, 'f', -1, 64)
|
|
case int:
|
|
return strconv.Itoa(val)
|
|
case int64:
|
|
return strconv.FormatInt(val, 10)
|
|
case bool:
|
|
return strconv.FormatBool(val)
|
|
default:
|
|
return fmt.Sprint(v)
|
|
}
|
|
}
|
|
|
|
func variantToString(v any) string {
|
|
switch val := v.(type) {
|
|
case string:
|
|
return val
|
|
case bool:
|
|
if val {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
default:
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
// extracted as a regular func for testing purposes
|
|
func checkIfSimpleFlagEnabled(key, bucketingId string, rolloutPercentage float64) bool {
|
|
hash := calculateHash(key, bucketingId, "")
|
|
return hash <= rolloutPercentage/100.
|
|
}
|
|
|
|
// calculateHash computes a deterministic hash value in [0, 1) for flag bucketing.
|
|
// The input is: key + "." + distinctId + salt.
|
|
// The "." separator is appended internally so callers don't need to concatenate.
|
|
func calculateHash(key, distinctId, salt string) float64 {
|
|
// Build the input in a stack-allocated buffer to avoid heap allocations.
|
|
// sha1.Sum takes a complete []byte and returns a [20]byte — no heap escapes.
|
|
totalLen := len(key) + 1 + len(distinctId) + len(salt) // +1 for "."
|
|
var buf [256]byte
|
|
var input []byte
|
|
if totalLen <= len(buf) {
|
|
input = buf[:0]
|
|
} else {
|
|
input = make([]byte, 0, totalLen)
|
|
}
|
|
input = append(input, key...)
|
|
input = append(input, '.')
|
|
input = append(input, distinctId...)
|
|
input = append(input, salt...)
|
|
|
|
digest := sha1.Sum(input)
|
|
return float64(binary.BigEndian.Uint64(digest[:8])>>4) / LONG_SCALE
|
|
}
|
|
|
|
// GetFeatureFlags returns the locally loaded feature flag definitions.
|
|
// It waits for the initial poll to complete and returns an error if flags were not loaded.
|
|
func (poller *FeatureFlagsPoller) GetFeatureFlags() ([]FeatureFlag, error) {
|
|
// When channel is open this will block. When channel is closed it will immediately exit.
|
|
<-poller.firstFeatureFlagRequestFinished
|
|
|
|
// Lock-free read of state
|
|
state := poller.state.Load()
|
|
if state == nil || state.featureFlags == nil {
|
|
// There was an error with initial flag fetching
|
|
return nil, errors.New("flags were not successfully fetched yet")
|
|
}
|
|
|
|
return state.featureFlags, nil
|
|
}
|
|
|
|
// getState returns the current flags state or nil if not initialized.
|
|
// This is a lock-free read.
|
|
func (poller *FeatureFlagsPoller) getState() *flagsState {
|
|
return poller.state.Load()
|
|
}
|
|
|
|
// getCohorts returns the current cohorts map or an empty map if not initialized.
|
|
func (poller *FeatureFlagsPoller) getCohorts() map[string]PropertyGroup {
|
|
state := poller.state.Load()
|
|
if state == nil || state.cohorts == nil {
|
|
return map[string]PropertyGroup{}
|
|
}
|
|
return state.cohorts
|
|
}
|
|
|
|
// preParseCohortValues converts raw []any values in cohort PropertyGroups into
|
|
// typed parsedPropertyValue slices, avoiding per-evaluation reconstruction from map[string]any.
|
|
func preParseCohortValues(cohorts map[string]PropertyGroup) map[string]PropertyGroup {
|
|
if cohorts == nil {
|
|
return cohorts
|
|
}
|
|
result := make(map[string]PropertyGroup, len(cohorts))
|
|
for k, pg := range cohorts {
|
|
result[k] = preParsePG(pg)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func preParsePG(pg PropertyGroup) PropertyGroup {
|
|
if len(pg.Values) == 0 {
|
|
return pg
|
|
}
|
|
parsed := make([]parsedPropertyValue, 0, len(pg.Values))
|
|
for _, value := range pg.Values {
|
|
prop, ok := value.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, hasValues := prop["values"]; hasValues {
|
|
// Nested PropertyGroup — recurse
|
|
childPG := PropertyGroup{
|
|
Type: getSafeProp[string](prop, "type"),
|
|
Values: getSafeProp[[]any](prop, "values"),
|
|
}
|
|
childPG = preParsePG(childPG)
|
|
parsed = append(parsed, parsedPropertyValue{
|
|
IsGroup: true,
|
|
Group: childPG,
|
|
})
|
|
} else {
|
|
// FlagProperty
|
|
parsed = append(parsed, parsedPropertyValue{
|
|
Property: FlagProperty{
|
|
Key: getSafeProp[string](prop, "key"),
|
|
Operator: getSafeProp[string](prop, "operator"),
|
|
Value: getSafeProp[any](prop, "value"),
|
|
Type: getSafeProp[string](prop, "type"),
|
|
Negation: getSafeProp[bool](prop, "negation"),
|
|
DependencyChain: getSafeProp[[]string](prop, "dependency_chain"),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
pg.ParsedValues = parsed
|
|
return pg
|
|
}
|
|
|
|
// preDecodePayloads converts json.RawMessage payloads to strings once at load time,
|
|
// so evaluations can use the decoded strings directly without per-call allocation.
|
|
func preDecodePayloads(flags []FeatureFlag) {
|
|
for i := range flags {
|
|
if len(flags[i].Filters.Payloads) > 0 {
|
|
decoded := make(map[string]string, len(flags[i].Filters.Payloads))
|
|
for k, raw := range flags[i].Filters.Payloads {
|
|
decoded[k] = rawMessageToString(raw)
|
|
}
|
|
flags[i].Filters.DecodedPayloads = decoded
|
|
}
|
|
// Pre-compute variant lookup table for multivariate flags
|
|
flags[i].Filters.VariantLookupTable = getVariantLookupTable(flags[i])
|
|
}
|
|
}
|
|
|
|
// buildFlagsByKey creates a map from flag key to FeatureFlag for O(1) lookups.
|
|
func buildFlagsByKey(flags []FeatureFlag) map[string]FeatureFlag {
|
|
m := make(map[string]FeatureFlag, len(flags))
|
|
for _, f := range flags {
|
|
m[f.Key] = f
|
|
}
|
|
return m
|
|
}
|
|
|
|
// getFlagsByKey returns the pre-built flags-by-key index or nil if not initialized.
|
|
func (poller *FeatureFlagsPoller) getFlagsByKey() map[string]FeatureFlag {
|
|
state := poller.state.Load()
|
|
if state == nil {
|
|
return nil
|
|
}
|
|
return state.flagsByKey
|
|
}
|
|
|
|
// getGroups returns the current groups map or an empty map if not initialized.
|
|
func (poller *FeatureFlagsPoller) getGroups() map[string]string {
|
|
state := poller.state.Load()
|
|
if state == nil || state.groups == nil {
|
|
return map[string]string{}
|
|
}
|
|
return state.groups
|
|
}
|
|
|
|
func (poller *FeatureFlagsPoller) localEvaluationFlags(headers http.Header, etag string) (*http.Response, context.CancelFunc, error) {
|
|
u := url.URL(*poller.localEvalUrl)
|
|
searchParams := u.Query()
|
|
searchParams.Add("token", poller.projectApiKey)
|
|
searchParams.Add("send_cohorts", "true")
|
|
u.RawQuery = searchParams.Encode()
|
|
|
|
if etag != "" {
|
|
headers.Set("If-None-Match", etag)
|
|
}
|
|
|
|
return poller.request("GET", u.String(), nil, headers, poller.flagTimeout)
|
|
}
|
|
|
|
func (poller *FeatureFlagsPoller) request(method string, reqUrl string, requestData []byte, headers http.Header, timeout time.Duration) (*http.Response, context.CancelFunc, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, reqUrl, bytes.NewReader(requestData))
|
|
if err != nil {
|
|
poller.Logger.Errorf("creating request - %s", err)
|
|
cancel()
|
|
return nil, nil, err
|
|
}
|
|
|
|
version := getVersion()
|
|
|
|
req.Header.Add("User-Agent", SDKName+"/"+version)
|
|
req.Header.Add("Content-Type", "application/json")
|
|
req.Header.Add("Content-Length", fmt.Sprintf("%d", len(requestData)))
|
|
|
|
maps.Copy(req.Header, headers)
|
|
|
|
res, err := poller.http.Do(req)
|
|
if err != nil {
|
|
poller.Logger.Errorf("sending request - %s", err)
|
|
}
|
|
|
|
return res, cancel, err
|
|
}
|
|
|
|
// ForceReload requests an immediate reload of local feature flag definitions.
|
|
func (poller *FeatureFlagsPoller) ForceReload() {
|
|
poller.forceReload <- true
|
|
}
|
|
|
|
func (poller *FeatureFlagsPoller) shutdownPoller() {
|
|
close(poller.shutdown)
|
|
}
|
|
|
|
// getFeatureFlagVariants is a helper function to get the feature flag variants for
|
|
// a given distinctId, groups, personProperties, and groupProperties.
|
|
// This makes a request to the flags endpoint and returns the response.
|
|
// This is used in fallback scenarios where we can't compute the flag locally.
|
|
func (poller *FeatureFlagsPoller) getFeatureFlagVariants(distinctId string, deviceId *string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (*FlagsResponse, error) {
|
|
return poller.decider.makeFlagsRequest(distinctId, deviceId, groups, personProperties, groupProperties, poller.disableGeoIP, nil)
|
|
}
|
|
|
|
// getFeatureFlagVariantsLocalOnly evaluates all feature flags using only local evaluation
|
|
func (poller *FeatureFlagsPoller) getFeatureFlagVariantsLocalOnly(distinctId string, deviceId *string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (map[string]any, error) {
|
|
flags, err := poller.GetFeatureFlags()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make(map[string]any)
|
|
cohorts := poller.getCohorts()
|
|
|
|
for _, flag := range flags {
|
|
flagValue, err := poller.computeFlagLocally(
|
|
flag,
|
|
distinctId,
|
|
deviceId,
|
|
groups,
|
|
personProperties,
|
|
groupProperties,
|
|
cohorts,
|
|
)
|
|
|
|
// Skip flags that can't be evaluated locally (e.g., experience continuity flags)
|
|
if err != nil {
|
|
if isInconclusiveError(err) {
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Only include flags that are not false
|
|
if flagValue != false {
|
|
result[flag.Key] = flagValue
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (poller *FeatureFlagsPoller) getFeatureFlagVariant(key string, distinctId string, deviceId *string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (any, error) {
|
|
var result any = false
|
|
|
|
flagsResponse, variantErr := poller.getFeatureFlagVariants(distinctId, deviceId, groups, personProperties, groupProperties)
|
|
|
|
if variantErr != nil {
|
|
return false, variantErr
|
|
}
|
|
|
|
for flagKey, flagValue := range flagsResponse.FeatureFlags {
|
|
if key == flagKey {
|
|
return flagValue, nil
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (poller *FeatureFlagsPoller) getFeatureFlagPayload(key string, distinctId string, deviceId *string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (string, error) {
|
|
flagsResponse, err := poller.getFeatureFlagVariants(distinctId, deviceId, groups, personProperties, groupProperties)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if flagsResponse != nil {
|
|
if payload, ok := flagsResponse.FeatureFlagPayloads[key]; ok {
|
|
return rawMessageToString(payload), nil
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func getSafeProp[T any](properties map[string]any, key string) T {
|
|
switch v := properties[key].(type) {
|
|
case T:
|
|
return v
|
|
default:
|
|
var defaultValue T
|
|
return defaultValue
|
|
}
|
|
}
|