Files
zeekayandHanzo Dev 8ee8950230 Go 1.27.1, and the code it can say more simply
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]>
2026-09-16 19:52:31 -07:00

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