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

375 lines
12 KiB
Go

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