Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/minimal-flag-called-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-go": minor
---

Send minimal `$feature_flag_called` events when the server enables it. When the `/flags?v=2` response carries `minimalFlagCalledEvents: true` (remote evaluation) or the local-evaluation definitions payload carries `minimal_flag_called_events: true`, and the evaluated flag has `has_experiment: false`, the event keeps only a strict allowlist of evaluation properties (`$feature_flag`, `$feature_flag_response`, `$feature_flag_has_experiment`, `$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, `$feature_flag_request_id`, `$feature_flag_evaluated_at`, `$feature_flag_error`, `locally_evaluated`, `$groups`, `$process_person_profile`, `$geoip_disable`, `$is_server`, `$session_id`, `$window_id`, `$device_id`, `$lib`, `$lib_version`) plus static system context (`$os`, `$os_version`, `$os_distro`, `$go_version`); everything else β€” including `Config.DefaultEventProperties` and the snapshot path's `$feature/<key>` and `$feature_flag_payload` β€” is stripped. Any missing signal (gate absent, `has_experiment` unknown, experiment-linked flag, legacy response shapes) keeps today's full event shape. The gate is server-controlled per project; no SDK configuration is added.
3 changes: 3 additions & 0 deletions api/public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ type Capture struct {
SendFeatureFlags SendFeatureFlagsValue
Flags *FeatureFlagEvaluations
IsServer bool

}

func (msg Capture) APIfy() APIMessage
Expand Down Expand Up @@ -277,6 +278,7 @@ type CommonResponseFields struct {
RequestId string `json:"requestId"`
EvaluatedAt *int64 `json:"evaluatedAt"`
ErrorsWhileComputingFlags bool `json:"errorsWhileComputingFlags"`
MinimalFlagCalledEvents bool `json:"minimalFlagCalledEvents"`
}

type CompressionMode uint8
Expand Down Expand Up @@ -578,6 +580,7 @@ type FeatureFlagsResponse struct {
Flags []FeatureFlag `json:"flags"`
GroupTypeMapping *map[string]string `json:"group_type_mapping"`
Cohorts map[string]PropertyGroup `json:"cohorts"`
MinimalFlagCalledEvents bool `json:"minimal_flag_called_events"`
}

type FieldError struct {
Expand Down
75 changes: 74 additions & 1 deletion capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ type Capture struct {
// IsServer controls whether the event includes the $is_server property.
// Enqueue overwrites it from Config.GetIsServer.
IsServer bool
// minimalFlagCalledEvent marks a $feature_flag_called event for the minimal
// shape: serialization keeps only the allowlisted evaluation properties and
// skips system context. It is set only when the server enabled
// minimal_flag_called_events and the flag has no linked experiment. This is
// the resolved per-event decision (shouldMinimizeFlagCalledEvent's output),
// distinct from the plural minimalFlagCalledEvents gate that decision reads.
minimalFlagCalledEvent bool
}

func (msg Capture) internal() {
Expand Down Expand Up @@ -164,12 +171,78 @@ type CaptureInApi struct {
SendFeatureFlags SendFeatureFlagsValue `json:"-"`
}

// minimalFlagCalledEventAllowlist lists the only event properties kept on a
// minimal $feature_flag_called event, per the cross-SDK contract. Everything
// else β€” Config.DefaultEventProperties and request-context properties
// included β€” is stripped so the minimal shape stays predictable.
// $geoip_disable is kept because, like $process_person_profile, it is a
// processing-control sentinel: stripping it would silently re-enable GeoIP
// enrichment for events from clients that disabled it. $session_id,
// $window_id, and $device_id are linkage identifiers the contract preserves.
// $is_server is kept so server-event classification still works. System
// context ($os, $os_version, $os_distro, $go_version) isn't filtered through
// this allowlist β€” APIfy merges it into minimal events the same way it does
// for full events, since those are cheap, low-cardinality dimensions kept for
// platform/runtime breakdowns on flag-call debugging.
var minimalFlagCalledEventAllowlist = []string{
"$feature_flag",
"$feature_flag_response",
"$feature_flag_has_experiment",
"$feature_flag_id",
"$feature_flag_version",
"$feature_flag_reason",
"$feature_flag_request_id",
"$feature_flag_evaluated_at",
"$feature_flag_error",
"locally_evaluated",
// $groups is listed for cross-SDK contract parity even though it currently
// has no effect here: APIfy/apifyEvent set it from Capture.Groups after
// this allowlist runs, not from a raw "$groups" key in Properties.
"$groups",
propertyProcessPersonProfile,
propertyGeoipDisable,
propertyIsServer,
propertySessionID,
propertyWindowID,
"$device_id",
}

// minimalFlagCalledEventProperties builds a fresh property set containing only
// the allowlisted minimal $feature_flag_called properties present in props.
func minimalFlagCalledEventProperties(props Properties) Properties {
minimal := NewProperties()
for _, key := range minimalFlagCalledEventAllowlist {
if value, ok := props[key]; ok {
minimal[key] = value
}
}
return minimal
}

// shouldMinimizeFlagCalledEvent reports whether a $feature_flag_called event
// should use the minimal shape: the server-controlled gate must be on and the
// flag must be known to have no linked experiment. Any missing signal keeps
// the full event shape.
func shouldMinimizeFlagCalledEvent(minimalFlagCalledEvents bool, hasExperiment *bool) bool {
return minimalFlagCalledEvents && hasExperiment != nil && !*hasExperiment
}

// selectedProperties returns the source properties for serialization: the
// allowlisted minimal subset when minimalFlagCalledEvent is set, or the full
// set otherwise.
func (msg Capture) selectedProperties() Properties {
if msg.minimalFlagCalledEvent {
return minimalFlagCalledEventProperties(msg.Properties)
}
return msg.Properties
}

// APIfy converts a Capture message into the PostHog batch API representation.
func (msg Capture) APIfy() APIMessage {
libraryVersion := getVersion()

myProperties := Properties{}.
Merge(msg.Properties).
Merge(msg.selectedProperties()).
Set("$lib", SDKName).
Set("$lib_version", libraryVersion).
Merge(getSystemContext().ToProperties())
Expand Down
2 changes: 1 addition & 1 deletion capture_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ func prepareForSendV1(msg Message, logger Logger) (json.RawMessage, APIMessage,
// header is the authoritative SDK identity in v1).
func (msg Capture) apifyEvent() apiEvent {
myProperties := baseV1Props(msg.IsServer, false).
Merge(msg.Properties).
Merge(msg.selectedProperties()).
mergeDefaults(getSystemContext().ToProperties())

if msg.Groups != nil {
Expand Down
3 changes: 3 additions & 0 deletions feature_flag_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ type featureFlagEvaluationResult struct {
RequestID *string
EvaluatedAt *int64
FlagDetail *FlagDetail
// MinimalFlagCalledEvents carries the minimal $feature_flag_called gate
// from the /flags response that produced this result.
MinimalFlagCalledEvents bool
}

// classifyError determines the error type string for a given error.
Expand Down
9 changes: 7 additions & 2 deletions feature_flag_evaluations.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ type evaluatedFlagRecord struct {
LocallyEvaluated bool
HasExperiment *bool
Error *string
// MinimalFlagCalledEvents is the minimal $feature_flag_called gate from
// the source that produced this record (local definitions or the remote
// /flags response).
MinimalFlagCalledEvents bool
}

// featureFlagEvaluationsHost is the small callback surface a snapshot uses to
Expand All @@ -57,7 +61,7 @@ type evaluatedFlagRecord struct {
// the SDK's Logger; users who want them silenced should pass a Logger that
// drops Warnf calls.
type featureFlagEvaluationsHost struct {
captureFlagCalledIfNeeded func(distinctId, key string, featureFlagResponse interface{}, deviceId *string, properties Properties, groups Groups)
captureFlagCalledIfNeeded func(distinctId, key string, featureFlagResponse interface{}, deviceId *string, properties Properties, groups Groups, minimal bool)
logger Logger
}

Expand Down Expand Up @@ -301,7 +305,8 @@ func (e *FeatureFlagEvaluations) recordAccess(key string) {
if alreadyAccessed {
return
}
e.host.captureFlagCalledIfNeeded(e.distinctId, key, response, e.deviceId, properties, e.groups)
minimal := found && shouldMinimizeFlagCalledEvent(flag.MinimalFlagCalledEvents, flag.HasExperiment)
e.host.captureFlagCalledIfNeeded(e.distinctId, key, response, e.deviceId, properties, e.groups, minimal)
}

// cloneWith builds a child snapshot with the given flag set. The accessed set
Expand Down
1 change: 1 addition & 0 deletions feature_flag_evaluations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,7 @@ func TestCaptureFlagCalled_DedupesByFlagValue(t *testing.T) {
nil,
NewProperties().Set("$feature_flag", "changing-flag").Set("$feature_flag_response", tc.response),
nil,
false,
)
}
})
Expand Down
50 changes: 37 additions & 13 deletions featureflags.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ type flagsState struct {
cohorts map[string]PropertyGroup
groups map[string]string
flagsEtag string
// minimalFlagCalledEvents is the server-controlled gate for minimal
// $feature_flag_called events, cached from the local-evaluation payload.
minimalFlagCalledEvents bool
}

// FeatureFlagsPoller periodically loads feature flag definitions for local evaluation.
Expand Down Expand Up @@ -219,6 +222,10 @@ type FeatureFlagsResponse struct {
GroupTypeMapping *map[string]string `json:"group_type_mapping"`
// Cohorts contains cohort definitions referenced by local feature flags.
Cohorts map[string]PropertyGroup `json:"cohorts"`
// MinimalFlagCalledEvents reports whether the server enabled minimal
// $feature_flag_called events for this project. The server sends it only
// when the gate is on; absence means full events.
MinimalFlagCalledEvents bool `json:"minimal_flag_called_events"`
}

// DecideRequestData is the legacy wire-format request body for flag decide calls.
Expand Down Expand Up @@ -529,11 +536,12 @@ func (poller *FeatureFlagsPoller) fetchNewFeatureFlags() {
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,
featureFlags: currentState.featureFlags,
flagsByKey: currentState.flagsByKey,
cohorts: currentState.cohorts,
groups: currentState.groups,
flagsEtag: newEtag,
minimalFlagCalledEvents: currentState.minimalFlagCalledEvents,
}
poller.state.Store(newState)
}
Expand Down Expand Up @@ -590,14 +598,23 @@ func (poller *FeatureFlagsPoller) fetchNewFeatureFlags() {

// Atomic swap of entire state
poller.state.Store(&flagsState{
featureFlags: newFlags,
flagsByKey: flagsByKey,
cohorts: parsedCohorts,
groups: groups,
flagsEtag: newEtag,
featureFlags: newFlags,
flagsByKey: flagsByKey,
cohorts: parsedCohorts,
groups: groups,
flagsEtag: newEtag,
minimalFlagCalledEvents: featureFlagsResponse.MinimalFlagCalledEvents,
})
}

// getMinimalFlagCalledEvents reports whether the local-evaluation payload
// enabled minimal $feature_flag_called events. False until definitions have
// been loaded, so missing state always yields full events.
func (poller *FeatureFlagsPoller) getMinimalFlagCalledEvents() bool {
state := poller.state.Load()
return state != nil && state.minimalFlagCalledEvents
}

// 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.
Expand Down Expand Up @@ -680,6 +697,10 @@ type flagValueAndPayload struct {
err error
locallyEvaluated bool
hasExperiment *bool
// minimalFlagCalledEvents carries the minimal $feature_flag_called gate
// from whichever source produced the value (local definitions or the
// remote /flags response).
minimalFlagCalledEvents bool
}

// GetFeatureFlagWithPayload evaluates a feature flag once and returns both its value
Expand Down Expand Up @@ -717,6 +738,7 @@ func (poller *FeatureFlagsPoller) GetFeatureFlagWithPayload(flagConfig FeatureFl

locallyEvaluated := err == nil && result != nil
hasExperiment := flag.HasExperiment
minimalFlagCalledEvents := poller.getMinimalFlagCalledEvents()

// Fall back to remote evaluation if local didn't produce a result
if (err != nil || result == nil) && !flagConfig.OnlyEvaluateLocally {
Expand All @@ -728,10 +750,12 @@ func (poller *FeatureFlagsPoller) GetFeatureFlagWithPayload(flagConfig FeatureFl
// Clear local eval error β€” we successfully made a remote request
err = nil
// The remote response is now the source of the flag value, so it is
// also the source of has_experiment: reset to unknown and only pick
// it up from the response when the flag is present there.
// also the source of has_experiment and of the minimal-event gate:
// reset both and only pick them up from the response.
hasExperiment = nil
minimalFlagCalledEvents = false
if flagsResponse != nil {
minimalFlagCalledEvents = flagsResponse.MinimalFlagCalledEvents
if flagValue, ok := flagsResponse.FeatureFlags[flagConfig.Key]; ok {
result = flagValue
} else {
Expand All @@ -749,7 +773,7 @@ func (poller *FeatureFlagsPoller) GetFeatureFlagWithPayload(flagConfig FeatureFl
}
}

return flagValueAndPayload{value: result, payload: payload, err: err, locallyEvaluated: locallyEvaluated, hasExperiment: hasExperiment}
return flagValueAndPayload{value: result, payload: payload, err: err, locallyEvaluated: locallyEvaluated, hasExperiment: hasExperiment, minimalFlagCalledEvents: minimalFlagCalledEvents}
}

func (poller *FeatureFlagsPoller) getFeatureFlag(flagConfig FeatureFlagPayload) (FeatureFlag, error) {
Expand Down
4 changes: 4 additions & 0 deletions flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ type CommonResponseFields struct {
EvaluatedAt *int64 `json:"evaluatedAt"`
// ErrorsWhileComputingFlags reports whether the server had errors computing any flags.
ErrorsWhileComputingFlags bool `json:"errorsWhileComputingFlags"`
// MinimalFlagCalledEvents reports whether the server enabled minimal
// $feature_flag_called events for this project. The server sends it only
// when the gate is on; absence means full events.
MinimalFlagCalledEvents bool `json:"minimalFlagCalledEvents"`
}

// UnmarshalJSON implements custom unmarshaling to handle both v3 and v4 formats
Expand Down
Loading
Loading