package insights import ( "bytes" "context" "fmt" "io" "net/http" "net/url" "slices" "time" json "github.com/goccy/go-json" ) // FlagsRequestData is the wire-format request body sent to the /flags endpoint. type FlagsRequestData 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"` // DeviceId optionally identifies the device for device-aware flag evaluation. DeviceId *string `json:"device_id,omitempty"` // 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"` // DisableGeoIP disables GeoIP enrichment for this flags request. DisableGeoIP bool `json:"geoip_disable,omitempty"` // FlagKeysToEvaluate asks the server to evaluate only these flag keys when non-empty. FlagKeysToEvaluate []string `json:"flag_keys_to_evaluate,omitempty"` } // FlagDetail represents one evaluated feature flag in the v4 /flags response format. type FlagDetail struct { // Key is the feature flag key. Key string `json:"key"` // Enabled reports whether the flag is enabled for the evaluated user. Enabled bool `json:"enabled"` // Variant is the matched variant key for multivariate flags, or nil for boolean flags. Variant *string `json:"variant"` // Reason explains why the flag matched or did not match, when provided by the API. Reason *FlagReason `json:"reason"` // Metadata contains IDs, version, payload, and optional flag description. Metadata FlagMetadata `json:"metadata"` // Failed is true when the API could not evaluate this flag due to a transient failure. Failed *bool `json:"failed,omitempty"` } // FlagReason represents why a flag was enabled or disabled. type FlagReason struct { // Code is the machine-readable reason code returned by the API. Code string `json:"code"` // Description is the human-readable reason returned by the API. Description string `json:"description"` // ConditionIndex is the matched condition index, when available. ConditionIndex *int `json:"condition_index"` } // FlagMetadata contains additional information about a flag evaluation. type FlagMetadata struct { // ID is the numeric feature flag ID in PostHog. ID int `json:"id"` // Version is the feature flag version evaluated by the API. Version int `json:"version"` // Payload is the raw JSON payload associated with the matched flag value. Payload json.RawMessage `json:"payload"` // Description is the feature flag description, when returned by the API. Description *string `json:"description,omitempty"` } // GetValue returns the variant string when Variant is set; otherwise it returns Enabled. func (f FlagDetail) GetValue() any { if f.Variant != nil { return *f.Variant } return f.Enabled } // NewFlagDetail creates a FlagDetail from a flag key, a bool or string value, // and an optional raw JSON payload. String values are treated as enabled variants; // bool values are treated as boolean flag results. func NewFlagDetail(key string, value any, payload json.RawMessage) FlagDetail { var variant *string var enabled bool switch v := value.(type) { case string: variant = &v enabled = true case bool: enabled = v default: enabled = false } return FlagDetail{ Key: key, Enabled: enabled, Variant: variant, Reason: nil, Metadata: FlagMetadata{ Payload: payload, }, } } // FlagsResponse represents a normalized /flags response. // It accepts both legacy v3 and current v4 response shapes. type FlagsResponse struct { // CommonResponseFields contains response metadata shared across formats. CommonResponseFields // Flags contains v4-format flag details keyed by flag key. Flags map[string]FlagDetail `json:"flags,omitempty"` // FeatureFlags contains legacy v3 flag values keyed by flag key. FeatureFlags map[string]any `json:"featureFlags"` // FeatureFlagPayloads contains legacy v3 raw payloads keyed by flag key. FeatureFlagPayloads map[string]json.RawMessage `json:"featureFlagPayloads"` } // CommonResponseFields contains fields common to all flags response versions. type CommonResponseFields struct { // QuotaLimited lists quota-limited resources returned by the API. QuotaLimited []string `json:"quota_limited"` // RequestId is the API request identifier for tracing flag evaluation. RequestId string `json:"requestId"` // EvaluatedAt is the server evaluation timestamp, when returned. EvaluatedAt *int64 `json:"evaluatedAt"` // ErrorsWhileComputingFlags reports whether the server had errors computing any flags. ErrorsWhileComputingFlags bool `json:"errorsWhileComputingFlags"` } // UnmarshalJSON implements custom unmarshaling to handle both v3 and v4 formats func (r *FlagsResponse) UnmarshalJSON(data []byte) error { // First try v4 format type V4Response struct { CommonResponseFields Flags map[string]FlagDetail `json:"flags"` } var v4 V4Response if err := json.Unmarshal(data, &v4); err == nil && v4.Flags != nil { // It's a v4 response r.Flags = v4.Flags r.CommonResponseFields = v4.CommonResponseFields // Calculate v3 format fields from Flags r.FeatureFlags = make(map[string]any) r.FeatureFlagPayloads = make(map[string]json.RawMessage) for key, flag := range r.Flags { r.FeatureFlags[key] = flag.GetValue() r.FeatureFlagPayloads[key] = flag.Metadata.Payload } return nil } // If not v4, try v3 format type V3Response struct { CommonResponseFields FeatureFlags map[string]any `json:"featureFlags"` FeatureFlagPayloads map[string]json.RawMessage `json:"featureFlagPayloads"` } var v3 V3Response if err := json.Unmarshal(data, &v3); err != nil { return err } // Store v3 format data r.FeatureFlags = v3.FeatureFlags r.FeatureFlagPayloads = v3.FeatureFlagPayloads r.CommonResponseFields = v3.CommonResponseFields // Construct v4 format from v3 data r.Flags = make(map[string]FlagDetail) for key, value := range v3.FeatureFlags { var payloadRaw json.RawMessage if payload, ok := v3.FeatureFlagPayloads[key]; ok { payloadRaw = payload } r.Flags[key] = NewFlagDetail(key, value, payloadRaw) } return nil } // decider defines the interface for making flags requests type decider interface { makeFlagsRequest(distinctId string, deviceId *string, groups Groups, personProperties Properties, groupProperties map[string]Properties, disableGeoIP bool, flagKeys []string) (*FlagsResponse, error) } // flagsClient implements the decider interface type flagsClient struct { apiKey string endpoint string http http.Client featureFlagRequestTimeout time.Duration logger Logger } // newFlagsClient creates a new flagsClient func newFlagsClient(apiKey string, endpoint string, httpClient http.Client, featureFlagRequestTimeout time.Duration, logger Logger) (*flagsClient, error) { // Try v2 endpoint first flagsEndpoint := "flags/?v=2" flagsEndpointURL, err := url.Parse(endpoint + "/" + flagsEndpoint) if err != nil { return nil, fmt.Errorf("creating url: %v", err) } return &flagsClient{ apiKey: apiKey, endpoint: flagsEndpointURL.String(), http: httpClient, featureFlagRequestTimeout: featureFlagRequestTimeout, logger: logger, }, nil } // makeFlagsRequest makes a request to the flags endpoint and deserializes the response // into a FlagsResponse struct. func (d *flagsClient) makeFlagsRequest(distinctId string, deviceId *string, groups Groups, personProperties Properties, groupProperties map[string]Properties, disableGeoIP bool, flagKeys []string) (*FlagsResponse, error) { // Ensure non-nil maps for JSON marshaling (nil marshals as "null", server expects "{}") if groups == nil { groups = Groups{} } if personProperties == nil { personProperties = Properties{} } if groupProperties == nil { groupProperties = map[string]Properties{} } requestData := FlagsRequestData{ ApiKey: d.apiKey, DistinctId: distinctId, DeviceId: deviceId, Groups: groups, PersonProperties: personProperties, GroupProperties: groupProperties, DisableGeoIP: disableGeoIP, FlagKeysToEvaluate: flagKeys, } requestDataBytes, err := json.Marshal(requestData) if err != nil { return nil, fmt.Errorf("unable to marshal flags endpoint request data: %v", err) } req, err := http.NewRequest("POST", d.endpoint, bytes.NewReader(requestDataBytes)) if err != nil { return nil, fmt.Errorf("creating request: %v", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", SDKName+"/"+Version) // Create a context with timeout ctx, cancel := context.WithTimeout(context.Background(), d.featureFlagRequestTimeout) defer cancel() req = req.WithContext(ctx) res, err := d.http.Do(req) if err != nil { return nil, fmt.Errorf("sending request: %v", err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { return nil, NewAPIError(res.StatusCode, fmt.Sprintf("unexpected status code from /flags/: %d", res.StatusCode)) } resBody, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("error reading response from /flags/: %v", err) } var flagsResponse FlagsResponse err = json.Unmarshal(resBody, &flagsResponse) if err != nil { return nil, fmt.Errorf("error parsing response from /flags/: %v", err) } if flagsResponse.ErrorsWhileComputingFlags { d.logger.Errorf("error while computing feature flags, some flags may be missing or incorrect") } return &flagsResponse, nil } // rawMessageToString converts a json.RawMessage to a string. // If the raw message is a JSON string, it returns the unquoted string. // Otherwise, it returns the JSON representation. func rawMessageToString(raw json.RawMessage) string { n := len(raw) if n == 0 { return "" } // Fast path for JSON strings: handle unquoting without json.Unmarshal if n >= 2 && raw[0] == '"' && raw[n-1] == '"' { inner := raw[1 : n-1] // Check for escape sequences hasEscape := slices.Contains(inner, '\\') if !hasEscape { // No escapes — direct conversion return string(inner) } // Handle common JSON escape sequences without json.Unmarshal. // This avoids 1-2 heap allocations from the JSON decoder. return unquoteJSONString(inner) } // Handle JSON null — return empty string (matches json.Unmarshal behavior for null → string) if n == 4 && raw[0] == 'n' && raw[1] == 'u' && raw[2] == 'l' && raw[3] == 'l' { return "" } // Non-string types (objects, arrays, numbers, booleans): return raw JSON representation return string(raw) } // unquoteJSONString processes JSON escape sequences in a byte slice. // Handles: \\, \", \/, \n, \r, \t, \b, \f. Falls back to json.Unmarshal for \uXXXX. func unquoteJSONString(data []byte) string { // Check if we need json.Unmarshal for unicode escapes for i := 0; i < len(data)-1; i++ { if data[i] == '\\' && data[i+1] == 'u' { // Unicode escape — fall back to json.Unmarshal raw := make([]byte, len(data)+2) raw[0] = '"' copy(raw[1:], data) raw[len(raw)-1] = '"' var s string if err := json.Unmarshal(raw, &s); err == nil { return s } return string(data) } } // Process simple escape sequences in a single pass buf := make([]byte, 0, len(data)) for i := 0; i < len(data); i++ { if data[i] == '\\' && i+1 < len(data) { i++ switch data[i] { case '"': buf = append(buf, '"') case '\\': buf = append(buf, '\\') case '/': buf = append(buf, '/') case 'n': buf = append(buf, '\n') case 'r': buf = append(buf, '\r') case 't': buf = append(buf, '\t') case 'b': buf = append(buf, '\b') case 'f': buf = append(buf, '\f') default: buf = append(buf, '\\', data[i]) } } else { buf = append(buf, data[i]) } } return string(buf) }