From a93d4bed078ef935b29c38d9fcaf80bd4cca0671 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 13:00:20 -0700 Subject: [PATCH] feat: add EvaluateFlags() API for single-call flag evaluation Adds a Phase 1 implementation of the Server SDK Feature Flag Evaluations RFC, mirroring posthog-js#3476 and posthog-python#539. Client.EvaluateFlags returns a FeatureFlagEvaluations snapshot built from at most one /flags request. The snapshot powers IsEnabled, GetFlag, and GetFlagPayload checks, fires deduped $feature_flag_called events with full v4 metadata (id, version, reason, request_id), and can be attached to a Capture event via the new Capture.Flags field to populate $feature/ and $active_feature_flags with no extra network call. The dedup logic for $feature_flag_called is extracted into captureFlagCalledIfNeeded so the existing single-flag path and the new snapshot path share the same per-distinct_id LRU cache and behave identically. OnlyAccessed and Only return filtered child snapshots with independent access tracking, so filtering for a Capture does not back-propagate to the parent. A new Config.FeatureFlagsLogWarnings option silences their warnings for callers that prefer quieter helpers. Capture.SendFeatureFlags is unchanged and not deprecated; Phase 2 will follow up with deprecations and migration guidance. Generated-By: PostHog Code Task-Id: b9d98122-fe61-462a-bfb3-6e1be3a8966a --- CHANGELOG.md | 12 + capture.go | 7 + feature_flag_evaluations.go | 325 ++++++++++++ feature_flag_evaluations_test.go | 833 +++++++++++++++++++++++++++++++ featureflags.go | 2 +- flags.go | 34 +- posthog.go | 292 ++++++++++- 7 files changed, 1471 insertions(+), 34 deletions(-) create mode 100644 feature_flag_evaluations.go create mode 100644 feature_flag_evaluations_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 48d2768..0ef6bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +### New Features + +* **`EvaluateFlags`**: New method on `Client` that returns a `FeatureFlagEvaluations` snapshot for a user using a single `/flags` request. The snapshot powers any number of `IsEnabled` / `GetFlag` / `GetFlagPayload` checks, fires deduped `$feature_flag_called` events with full v4 metadata (id, version, reason, request_id), and can be attached to a `Capture` event via the new `Capture.Flags` field to populate `$feature/` and `$active_feature_flags` without another network call. +* **`Capture.Flags`**: New optional field on `Capture` that accepts a `*FeatureFlagEvaluations` snapshot. Takes precedence over `SendFeatureFlags`, avoids a hidden `/flags` request per event, and lets caller-supplied `Properties` override the auto-generated `$feature/` values on conflict. + +### Internal + +* Refactored the `$feature_flag_called` dedup logic into a shared helper so the existing single-flag path and the new snapshot path use identical semantics against the same per-distinct_id LRU cache. +* `$feature_flag_called` events from the snapshot path combine response-level errors (`errors_while_computing_flags`, `quota_limited`) with per-flag errors (`flag_missing`) comma-joined in `$feature_flag_error`, matching the granularity of the legacy single-flag path. + ## 1.12.3 - 2026-04-21 * [Full Changelog](https://github.com/PostHog/posthog-go/compare/1.12.2...1.12.3) diff --git a/capture.go b/capture.go index d34947b..be1ee2e 100644 --- a/capture.go +++ b/capture.go @@ -83,6 +83,13 @@ type Capture struct { Properties Properties Groups Groups SendFeatureFlags SendFeatureFlagsValue + // Flags, when set, attaches $feature/ and $active_feature_flags + // properties from a snapshot returned by Client.EvaluateFlags. It is + // preferred over SendFeatureFlags: the snapshot guarantees the event + // carries the exact values the application branched on and avoids a + // hidden /flags request on every capture. Flags takes precedence when + // both are set. + Flags *FeatureFlagEvaluations } func (msg Capture) internal() { diff --git a/feature_flag_evaluations.go b/feature_flag_evaluations.go new file mode 100644 index 0000000..f8bc476 --- /dev/null +++ b/feature_flag_evaluations.go @@ -0,0 +1,325 @@ +package posthog + +import ( + "sort" + "strings" + "sync" +) + +// FeatureFlagEvaluations is a snapshot of feature-flag evaluations for a +// single distinct_id, returned by Client.EvaluateFlags. It is the recommended +// way to evaluate many flags for a single user with a single network request: +// the snapshot can power any number of IsEnabled / GetFlag checks and can be +// attached to a Capture event via Capture.Flags so the event carries the +// exact values the application branched on, with no additional /flags call. +// +// Calls to IsEnabled and GetFlag fire $feature_flag_called (deduped per +// (distinct_id, key, device_id)). GetFlagPayload does not fire an event. +type FeatureFlagEvaluations struct { + host featureFlagEvaluationsHost + distinctId string + deviceId *string + groups Groups + flags map[string]evaluatedFlagRecord + requestId string + evaluatedAt *int64 + flagDefinitionsLoadedAt *int64 + // errorsWhileComputing and quotaLimited capture response-level error + // signals from the /flags request. They are combined with per-flag + // errors in the $feature_flag_called event so consumers see the same + // granularity the legacy single-flag path emits. + errorsWhileComputing bool + quotaLimited bool + + mu sync.Mutex + accessed map[string]struct{} +} + +// evaluatedFlagRecord is the per-flag entry stored in a snapshot. All fields +// are optional except Key and Enabled, mirroring the v4 /flags response shape +// with an additional LocallyEvaluated marker for poller-resolved flags. +type evaluatedFlagRecord struct { + Key string + Enabled bool + Variant *string + Payload *string + ID *int + Version *int + Reason *string + LocallyEvaluated bool + Error *string +} + +// featureFlagEvaluationsHost is the small callback surface a snapshot uses to +// talk back to the client. It is intentionally narrow so tests can construct +// snapshots without spinning up a full client. Warnings are routed through +// 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, deviceId *string, properties Properties, groups Groups) + logger Logger +} + +// IsEnabled reports whether the flag is enabled for this snapshot's user. +// Unknown flags return false. The first call for a given key fires +// $feature_flag_called with full evaluation metadata; subsequent calls with +// the same response are deduped against the client's per-distinct_id cache. +func (e *FeatureFlagEvaluations) IsEnabled(key string) bool { + if e == nil { + return false + } + flag, ok := e.flags[key] + e.recordAccess(key) + if !ok { + return false + } + return flag.Enabled +} + +// GetFlag returns the value of the flag: a string for multivariate flags, +// true or false for boolean flags, or nil for unknown flags. The first call +// for a given key fires $feature_flag_called with full evaluation metadata; +// subsequent calls with the same response are deduped. +func (e *FeatureFlagEvaluations) GetFlag(key string) interface{} { + if e == nil { + return nil + } + flag, ok := e.flags[key] + e.recordAccess(key) + if !ok { + return nil + } + if !flag.Enabled { + return false + } + if flag.Variant != nil { + return *flag.Variant + } + return true +} + +// GetFlagPayload returns the raw JSON payload string for the flag, or "" if +// no payload is configured or the flag is unknown. Unlike IsEnabled and +// GetFlag, it does not record an access and does not fire an event. +func (e *FeatureFlagEvaluations) GetFlagPayload(key string) string { + if e == nil { + return "" + } + flag, ok := e.flags[key] + if !ok || flag.Payload == nil { + return "" + } + return *flag.Payload +} + +// Keys returns the sorted list of evaluated flag keys. +func (e *FeatureFlagEvaluations) Keys() []string { + if e == nil { + return nil + } + keys := make([]string, 0, len(e.flags)) + for k := range e.flags { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// OnlyAccessed returns a filtered copy of this snapshot containing only the +// flags that were accessed via IsEnabled or GetFlag before this call. +// +// The method honors its name: if nothing has been accessed yet, the returned +// snapshot is empty. Pre-access the flags you want to attach before calling. +// +// The returned snapshot tracks access independently from the receiver; +// further accesses on the child do not influence what the parent sees. +func (e *FeatureFlagEvaluations) OnlyAccessed() *FeatureFlagEvaluations { + if e == nil { + return nil + } + e.mu.Lock() + filtered := make(map[string]evaluatedFlagRecord, len(e.accessed)) + for k := range e.accessed { + if flag, ok := e.flags[k]; ok { + filtered[k] = flag + } + } + e.mu.Unlock() + return e.cloneWith(filtered) +} + +// Only returns a filtered copy of this snapshot keeping only the named flags +// that were actually evaluated. Unknown keys are dropped with a warning. +// +// The returned snapshot tracks access independently from the receiver. +func (e *FeatureFlagEvaluations) Only(keys []string) *FeatureFlagEvaluations { + if e == nil { + return nil + } + filtered := make(map[string]evaluatedFlagRecord, len(keys)) + var missing []string + for _, k := range keys { + if flag, ok := e.flags[k]; ok { + filtered[k] = flag + } else { + missing = append(missing, k) + } + } + if len(missing) > 0 && e.host.logger != nil { + e.host.logger.Warnf("FeatureFlagEvaluations.Only() was called with flag keys that are not in the evaluation set and will be dropped: %s", strings.Join(missing, ", ")) + } + return e.cloneWith(filtered) +} + +// eventProperties returns the $feature/ and $active_feature_flags +// properties for the snapshot, suitable for merging into a Capture event. +func (e *FeatureFlagEvaluations) eventProperties() Properties { + props := NewProperties() + if e == nil || len(e.flags) == 0 { + return props + } + active := make([]string, 0, len(e.flags)) + for key, flag := range e.flags { + var value interface{} + switch { + case !flag.Enabled: + value = false + case flag.Variant != nil: + value = *flag.Variant + default: + value = true + } + props.Set("$feature/"+key, value) + if flag.Enabled { + active = append(active, key) + } + } + sort.Strings(active) + if len(active) > 0 { + props.Set("$active_feature_flags", active) + } + return props +} + +// recordAccess marks key as accessed, builds the $feature_flag_called event +// properties, and forwards them to the client's dedup helper. Access from a +// snapshot bound to an empty distinct_id is silently dropped — those events +// would be useless and would pollute analytics with empty distinct_ids. +func (e *FeatureFlagEvaluations) recordAccess(key string) { + e.mu.Lock() + if e.accessed == nil { + e.accessed = make(map[string]struct{}) + } + _, alreadyAccessed := e.accessed[key] + e.accessed[key] = struct{}{} + e.mu.Unlock() + + if e.distinctId == "" { + return + } + if e.host.captureFlagCalledIfNeeded == nil { + return + } + + flag, found := e.flags[key] + + var response interface{} + switch { + case !found: + response = nil + case !flag.Enabled: + response = false + case flag.Variant != nil: + response = *flag.Variant + default: + response = true + } + + properties := NewProperties(). + Set("$feature_flag", key). + Set("$feature_flag_response", response). + Set("$feature/"+key, response). + Set("locally_evaluated", found && flag.LocallyEvaluated) + + if e.deviceId != nil { + properties.Set("$device_id", *e.deviceId) + } + if e.requestId != "" { + properties.Set("$feature_flag_request_id", e.requestId) + } + if found { + if flag.Payload != nil { + properties.Set("$feature_flag_payload", *flag.Payload) + } + if flag.ID != nil { + properties.Set("$feature_flag_id", *flag.ID) + } + if flag.Version != nil { + properties.Set("$feature_flag_version", *flag.Version) + } + if flag.Reason != nil { + properties.Set("$feature_flag_reason", *flag.Reason) + } + if flag.LocallyEvaluated { + if e.flagDefinitionsLoadedAt != nil { + properties.Set("$feature_flag_definitions_loaded_at", *e.flagDefinitionsLoadedAt) + } + } else if e.evaluatedAt != nil { + properties.Set("$feature_flag_evaluated_at", *e.evaluatedAt) + } + } + + // Build the comma-joined $feature_flag_error matching the single-flag + // path's granularity: response-level errors (errors-while-computing, + // quota-limited) are combined with per-flag errors so consumers can + // filter by type. + var errs []string + if e.errorsWhileComputing { + errs = append(errs, FeatureFlagErrorErrorsWhileComputing) + } + if e.quotaLimited { + errs = append(errs, FeatureFlagErrorQuotaLimited) + } + if !found { + errs = append(errs, FeatureFlagErrorFlagMissing) + } else if flag.Error != nil { + errs = append(errs, *flag.Error) + } + if len(errs) > 0 { + properties.Set("$feature_flag_error", strings.Join(errs, ",")) + } + + // Dedup is owned by the client cache; alreadyAccessed is a per-snapshot + // hint that lets us skip the client call when we know we have already + // fired for this key from this snapshot. + if alreadyAccessed { + return + } + e.host.captureFlagCalledIfNeeded(e.distinctId, key, e.deviceId, properties, e.groups) +} + +// cloneWith builds a child snapshot with the given flag set. The accessed set +// is copied so further access on the child does not leak back into the +// parent's accessed set. +func (e *FeatureFlagEvaluations) cloneWith(flags map[string]evaluatedFlagRecord) *FeatureFlagEvaluations { + e.mu.Lock() + accessedCopy := make(map[string]struct{}, len(e.accessed)) + for k := range e.accessed { + accessedCopy[k] = struct{}{} + } + e.mu.Unlock() + + return &FeatureFlagEvaluations{ + host: e.host, + distinctId: e.distinctId, + deviceId: e.deviceId, + groups: e.groups, + flags: flags, + requestId: e.requestId, + evaluatedAt: e.evaluatedAt, + flagDefinitionsLoadedAt: e.flagDefinitionsLoadedAt, + errorsWhileComputing: e.errorsWhileComputing, + quotaLimited: e.quotaLimited, + accessed: accessedCopy, + } +} diff --git a/feature_flag_evaluations_test.go b/feature_flag_evaluations_test.go new file mode 100644 index 0000000..25656f0 --- /dev/null +++ b/feature_flag_evaluations_test.go @@ -0,0 +1,833 @@ +package posthog + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + json "github.com/goccy/go-json" +) + +// flagsServer wraps an httptest server that responds with the v4 flags fixture. +// It records every call to the /flags endpoint and exposes the request body so +// tests can assert on flag_keys_to_evaluate and other forwarded fields. +type flagsServer struct { + server *httptest.Server + mu sync.Mutex + calls []FlagsRequestData + rawCalls []string +} + +func newFlagsServer(t *testing.T, fixtureName string) *flagsServer { + t.Helper() + fs := &flagsServer{} + fs.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/flags") { + body := new(bytes.Buffer) + body.ReadFrom(r.Body) + fs.mu.Lock() + fs.rawCalls = append(fs.rawCalls, body.String()) + var parsed FlagsRequestData + _ = json.Unmarshal(body.Bytes(), &parsed) + fs.calls = append(fs.calls, parsed) + fs.mu.Unlock() + w.Write([]byte(fixture(fixtureName))) + } + })) + return fs +} + +func (fs *flagsServer) close() { fs.server.Close() } + +func (fs *flagsServer) callCount() int { + fs.mu.Lock() + defer fs.mu.Unlock() + return len(fs.calls) +} + +func (fs *flagsServer) lastCall() (FlagsRequestData, string) { + fs.mu.Lock() + defer fs.mu.Unlock() + if len(fs.calls) == 0 { + return FlagsRequestData{}, "" + } + return fs.calls[len(fs.calls)-1], fs.rawCalls[len(fs.rawCalls)-1] +} + +// captureLogger collects warnings logged via the SDK Logger interface so tests +// can assert on filter-helper warnings. +type captureLogger struct { + mu sync.Mutex + warnings []string +} + +func (l *captureLogger) Debugf(string, ...interface{}) {} +func (l *captureLogger) Logf(string, ...interface{}) {} +func (l *captureLogger) Errorf(string, ...interface{}) {} +func (l *captureLogger) Warnf(format string, args ...interface{}) { + l.mu.Lock() + defer l.mu.Unlock() + l.warnings = append(l.warnings, formatLog(format, args...)) +} + +func (l *captureLogger) snapshot() []string { + l.mu.Lock() + defer l.mu.Unlock() + out := make([]string, len(l.warnings)) + copy(out, l.warnings) + return out +} + +// formatLog mimics fmt.Sprintf without pulling fmt into the test body. +func formatLog(format string, args ...interface{}) string { + if len(args) == 0 { + return format + } + // Use json marshal as a cheap stringifier for our test purposes. + parts := []string{format} + for _, a := range args { + b, _ := json.Marshal(a) + parts = append(parts, string(b)) + } + return strings.Join(parts, "|") +} + +func newEvalClient(t *testing.T, server *httptest.Server, opts ...func(*Config)) (Client, *eventCapture, *captureLogger) { + t.Helper() + capture := &eventCapture{} + logger := &captureLogger{} + cfg := Config{ + Endpoint: server.URL, + BatchSize: 1, // deliver each enqueued event immediately for inspection + Callback: capture, + Logger: logger, + } + for _, opt := range opts { + opt(&cfg) + } + cli, err := NewWithConfig("test-api-key", cfg) + if err != nil { + t.Fatalf("NewWithConfig failed: %v", err) + } + t.Cleanup(func() { cli.Close() }) + return cli, capture, logger +} + +func waitForEventCount(capture *eventCapture, want int, timeout time.Duration) []CaptureInApi { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + capture.mu.Lock() + if len(capture.events) >= want { + out := make([]CaptureInApi, len(capture.events)) + copy(out, capture.events) + capture.mu.Unlock() + return out + } + capture.mu.Unlock() + time.Sleep(10 * time.Millisecond) + } + capture.mu.Lock() + defer capture.mu.Unlock() + out := make([]CaptureInApi, len(capture.events)) + copy(out, capture.events) + return out +} + +func findEvent(events []CaptureInApi, eventName, flagKey string) *CaptureInApi { + for i := range events { + if events[i].Event != eventName { + continue + } + if flagKey == "" { + return &events[i] + } + if events[i].Properties["$feature_flag"] == flagKey { + return &events[i] + } + } + return nil +} + +func TestEvaluateFlags_ReturnsSnapshotWithSingleRequest(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, _, _ := newEvalClient(t, fs.server) + + snap, err := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + if err != nil { + t.Fatalf("EvaluateFlags returned error: %v", err) + } + if snap == nil { + t.Fatal("expected non-nil snapshot") + } + if fs.callCount() != 1 { + t.Fatalf("expected exactly 1 /flags request, got %d", fs.callCount()) + } + keys := snap.Keys() + if len(keys) == 0 { + t.Fatal("expected at least one flag key") + } +} + +func TestEvaluateFlags_NoEventsUntilAccessed(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, capture, _ := newEvalClient(t, fs.server) + + if _, err := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}); err != nil { + t.Fatalf("EvaluateFlags error: %v", err) + } + + time.Sleep(50 * time.Millisecond) + capture.mu.Lock() + defer capture.mu.Unlock() + for _, ev := range capture.events { + if ev.Event == "$feature_flag_called" { + t.Fatalf("did not expect any $feature_flag_called events, got %+v", ev) + } + } +} + +func TestIsEnabled_FiresEventWithFullMetadataAndDedupes(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, capture, _ := newEvalClient(t, fs.server) + + snap, err := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + if err != nil { + t.Fatalf("EvaluateFlags error: %v", err) + } + + if !snap.IsEnabled("enabled-flag") { + t.Fatal("expected enabled-flag to be enabled") + } + // Second call should be deduped. + snap.IsEnabled("enabled-flag") + + events := waitForEventCount(capture, 1, 5*time.Second) + matching := 0 + var event *CaptureInApi + for i := range events { + if events[i].Event == "$feature_flag_called" && events[i].Properties["$feature_flag"] == "enabled-flag" { + matching++ + event = &events[i] + } + } + if matching != 1 { + t.Fatalf("expected exactly 1 $feature_flag_called for enabled-flag, got %d", matching) + } + if event.Properties["$feature_flag_response"] != true { + t.Errorf("expected $feature_flag_response=true, got %v", event.Properties["$feature_flag_response"]) + } + if event.Properties["$feature_flag_id"] != 1 { + t.Errorf("expected $feature_flag_id=1, got %v", event.Properties["$feature_flag_id"]) + } + if event.Properties["$feature_flag_version"] != 23 { + t.Errorf("expected $feature_flag_version=23, got %v", event.Properties["$feature_flag_version"]) + } + if event.Properties["$feature_flag_reason"] != "Matched conditions set 3" { + t.Errorf("expected reason 'Matched conditions set 3', got %v", event.Properties["$feature_flag_reason"]) + } + if event.Properties["$feature_flag_request_id"] != "42853c54-1431-4861-996e-3a548989fa2c" { + t.Errorf("expected $feature_flag_request_id, got %v", event.Properties["$feature_flag_request_id"]) + } + if event.Properties["$feature/enabled-flag"] != true { + t.Errorf("expected $feature/enabled-flag=true, got %v", event.Properties["$feature/enabled-flag"]) + } + if got := event.Properties["$feature_flag_payload"]; got != `{"foo": 1}` { + t.Errorf("expected $feature_flag_payload to be the raw JSON payload, got %v", got) + } +} + +func TestGetFlag_FiresEventWithVariant(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, capture, _ := newEvalClient(t, fs.server) + + snap, err := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + if err != nil { + t.Fatalf("EvaluateFlags error: %v", err) + } + + val := snap.GetFlag("multi-variate-flag") + if val != "hello" { + t.Errorf("expected variant 'hello', got %v", val) + } + + events := waitForEventCount(capture, 1, 5*time.Second) + event := findEvent(events, "$feature_flag_called", "multi-variate-flag") + if event == nil { + t.Fatal("expected $feature_flag_called event for multi-variate-flag") + } + if event.Properties["$feature_flag_response"] != "hello" { + t.Errorf("expected $feature_flag_response='hello', got %v", event.Properties["$feature_flag_response"]) + } +} + +func TestGetFlagPayload_DoesNotFireEvent(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, capture, _ := newEvalClient(t, fs.server) + + snap, err := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + if err != nil { + t.Fatalf("EvaluateFlags error: %v", err) + } + + payload := snap.GetFlagPayload("simple-flag") + if payload == "" { + t.Errorf("expected non-empty payload for simple-flag, got %q", payload) + } + + time.Sleep(50 * time.Millisecond) + capture.mu.Lock() + for _, ev := range capture.events { + if ev.Event == "$feature_flag_called" { + capture.mu.Unlock() + t.Fatalf("GetFlagPayload should not fire $feature_flag_called, got %+v", ev) + } + } + capture.mu.Unlock() + + // And the access set should not have recorded simple-flag — OnlyAccessed + // must return an empty snapshot, since GetFlagPayload doesn't count as an + // access. + if filtered := snap.OnlyAccessed(); len(filtered.Keys()) != 0 { + t.Errorf("OnlyAccessed should be empty when GetFlagPayload was the only call; got keys=%v", filtered.Keys()) + } +} + +func TestOnlyAccessed_FiltersToAccessedFlags(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, _, _ := newEvalClient(t, fs.server) + + snap, _ := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + snap.IsEnabled("enabled-flag") + snap.GetFlag("multi-variate-flag") + + filtered := snap.OnlyAccessed() + keys := filtered.Keys() + if len(keys) != 2 { + t.Fatalf("expected 2 accessed keys, got %d (%v)", len(keys), keys) + } + wantSet := map[string]bool{"enabled-flag": true, "multi-variate-flag": true} + for _, k := range keys { + if !wantSet[k] { + t.Errorf("unexpected key in OnlyAccessed: %s", k) + } + } +} + +func TestOnlyAccessed_ReturnsEmptyWhenNoFlagsAccessed(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, _, logger := newEvalClient(t, fs.server) + + snap, _ := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + filtered := snap.OnlyAccessed() + if got := len(filtered.Keys()); got != 0 { + t.Errorf("expected empty snapshot when no flags accessed, got %d keys", got) + } + + for _, w := range logger.snapshot() { + if strings.Contains(w, "OnlyAccessed") { + t.Errorf("expected no warning for empty OnlyAccessed; method honors its name: %q", w) + } + } +} + +func TestOnly_DropsUnknownKeysWithWarning(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, _, logger := newEvalClient(t, fs.server) + + snap, _ := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + filtered := snap.Only([]string{"enabled-flag", "totally-missing"}) + keys := filtered.Keys() + if len(keys) != 1 || keys[0] != "enabled-flag" { + t.Errorf("expected only enabled-flag, got %v", keys) + } + + warns := logger.snapshot() + found := false + for _, w := range warns { + if strings.Contains(w, "totally-missing") { + found = true + break + } + } + if !found { + t.Errorf("expected warning naming totally-missing, got %v", warns) + } +} + +func TestFilteredSnapshots_DoNotBackPropagateAccess(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, _, _ := newEvalClient(t, fs.server) + + snap, _ := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + snap.IsEnabled("enabled-flag") + + child := snap.OnlyAccessed() + // Access another flag on the child only. + child.IsEnabled("disabled-flag") + + parentFiltered := snap.OnlyAccessed() + if len(parentFiltered.Keys()) != 1 || parentFiltered.Keys()[0] != "enabled-flag" { + t.Errorf("expected parent OnlyAccessed to still hold only enabled-flag, got %v", parentFiltered.Keys()) + } +} + +func TestCaptureWithFlags_AttachesPropertiesNoExtraRequest(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, capture, _ := newEvalClient(t, fs.server) + + snap, _ := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + if err := client.Enqueue(Capture{ + DistinctId: "user-1", + Event: "thing-happened", + Flags: snap, + }); err != nil { + t.Fatalf("Enqueue failed: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + var thing *CaptureInApi + for time.Now().Before(deadline) { + capture.mu.Lock() + for i := range capture.events { + if capture.events[i].Event == "thing-happened" { + thing = &capture.events[i] + break + } + } + capture.mu.Unlock() + if thing != nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if thing == nil { + t.Fatal("did not receive thing-happened event") + } + + if thing.Properties["$feature/enabled-flag"] != true { + t.Errorf("expected $feature/enabled-flag=true, got %v", thing.Properties["$feature/enabled-flag"]) + } + if thing.Properties["$feature/multi-variate-flag"] != "hello" { + t.Errorf("expected $feature/multi-variate-flag='hello', got %v", thing.Properties["$feature/multi-variate-flag"]) + } + active, ok := thing.Properties["$active_feature_flags"].([]string) + if !ok { + t.Fatalf("expected $active_feature_flags to be []string, got %T", thing.Properties["$active_feature_flags"]) + } + for i := 1; i < len(active); i++ { + if active[i-1] > active[i] { + t.Errorf("$active_feature_flags should be sorted, got %v", active) + } + } + + // Critically: only one /flags request (from EvaluateFlags), none from the + // Capture path. + if got := fs.callCount(); got != 1 { + t.Errorf("expected exactly 1 /flags request, got %d", got) + } +} + +func TestEvaluateFlags_ForwardsFlagKeys(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, _, _ := newEvalClient(t, fs.server) + + if _, err := client.EvaluateFlags(EvaluateFlagsPayload{ + DistinctId: "user-1", + FlagKeys: []string{"enabled-flag", "disabled-flag"}, + }); err != nil { + t.Fatalf("EvaluateFlags error: %v", err) + } + + parsed, raw := fs.lastCall() + if !strings.Contains(raw, "flag_keys_to_evaluate") { + t.Errorf("expected raw body to contain flag_keys_to_evaluate, got %s", raw) + } + if len(parsed.FlagKeysToEvaluate) != 2 { + t.Fatalf("expected 2 flag keys, got %v", parsed.FlagKeysToEvaluate) + } + if parsed.FlagKeysToEvaluate[0] != "enabled-flag" || parsed.FlagKeysToEvaluate[1] != "disabled-flag" { + t.Errorf("unexpected flag keys: %v", parsed.FlagKeysToEvaluate) + } +} + +func TestEvaluateFlags_EmptyDistinctId_NoEvents(t *testing.T) { + t.Parallel() + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/flags") { + calls.Add(1) + w.Write([]byte(fixture("test-flags-v4.json"))) + } + })) + defer server.Close() + + client, capture, _ := newEvalClient(t, server) + + snap, err := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: ""}) + if err != nil { + t.Fatalf("EvaluateFlags error: %v", err) + } + if snap == nil { + t.Fatal("expected non-nil snapshot") + } + if got := calls.Load(); got != 0 { + t.Errorf("expected zero /flags requests for empty distinct_id, got %d", got) + } + + // Accessing a flag on an empty snapshot should be safe and silent. + snap.IsEnabled("enabled-flag") + snap.GetFlag("multi-variate-flag") + + time.Sleep(50 * time.Millisecond) + capture.mu.Lock() + defer capture.mu.Unlock() + for _, ev := range capture.events { + if ev.Event == "$feature_flag_called" { + t.Fatalf("empty-distinct_id snapshot should not fire $feature_flag_called, got %+v", ev) + } + } +} + +func TestEvaluateFlags_LocalEvaluation_TagsLocallyEvaluated(t *testing.T) { + t.Parallel() + var localCalls atomic.Int32 + var remoteCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/flags/definitions") || strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation"): + localCalls.Add(1) + w.Write([]byte(fixture("feature_flag/test-simple-flag-person-prop.json"))) + case strings.HasPrefix(r.URL.Path, "/flags"): + remoteCalls.Add(1) + w.Write([]byte(fixture("test-flags-v4.json"))) + } + })) + defer server.Close() + + client, capture, _ := newEvalClient(t, server, func(c *Config) { + c.PersonalApiKey = "personal-key" + }) + + // Wait for the poller's first fetch. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && localCalls.Load() == 0 { + time.Sleep(10 * time.Millisecond) + } + if localCalls.Load() == 0 { + t.Fatal("local evaluation poller never called /flags/definitions") + } + + snap, err := client.EvaluateFlags(EvaluateFlagsPayload{ + DistinctId: "user-1", + PersonProperties: NewProperties().Set("region", "USA"), + }) + if err != nil { + t.Fatalf("EvaluateFlags error: %v", err) + } + if !snap.IsEnabled("simple-flag") { + t.Fatal("expected simple-flag to be enabled locally") + } + + events := waitForEventCount(capture, 1, 5*time.Second) + event := findEvent(events, "$feature_flag_called", "simple-flag") + if event == nil { + t.Fatal("expected $feature_flag_called for simple-flag") + } + if event.Properties["locally_evaluated"] != true { + t.Errorf("expected locally_evaluated=true, got %v", event.Properties["locally_evaluated"]) + } + if event.Properties["$feature_flag_reason"] != "Evaluated locally" { + t.Errorf("expected $feature_flag_reason='Evaluated locally', got %v", event.Properties["$feature_flag_reason"]) + } +} + +// silentLogger drops all log calls. Callers who want to silence +// FeatureFlagEvaluations filter-helper warnings can pass one as Config.Logger. +type silentLogger struct{} + +func (silentLogger) Debugf(string, ...interface{}) {} +func (silentLogger) Logf(string, ...interface{}) {} +func (silentLogger) Warnf(string, ...interface{}) {} +func (silentLogger) Errorf(string, ...interface{}) {} + +func TestFilterWarnings_SilencedByQuietLogger(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + // Override Logger entirely so Only's warning has nowhere to go. + cfg := Config{ + Endpoint: fs.server.URL, + BatchSize: 1, + Logger: silentLogger{}, + } + cli, err := NewWithConfig("test-api-key", cfg) + if err != nil { + t.Fatalf("NewWithConfig failed: %v", err) + } + t.Cleanup(func() { cli.Close() }) + + snap, _ := cli.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + // Should not panic and should not log anywhere observable. + _ = snap.OnlyAccessed() + _ = snap.Only([]string{"definitely-not-here"}) +} + +func TestRefactor_LegacyAndSnapshotPathsDedupeIdentically(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, capture, _ := newEvalClient(t, fs.server) + + // Legacy single-flag path fires once. + if _, err := client.IsFeatureEnabled(FeatureFlagPayload{Key: "enabled-flag", DistinctId: "user-1"}); err != nil { + t.Fatalf("IsFeatureEnabled error: %v", err) + } + + // Snapshot path with the same (distinct_id, key, response) must NOT fire + // a second event because the LRU cache is shared. + snap, _ := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + snap.IsEnabled("enabled-flag") + + time.Sleep(150 * time.Millisecond) + + capture.mu.Lock() + defer capture.mu.Unlock() + count := 0 + for _, ev := range capture.events { + if ev.Event == "$feature_flag_called" && ev.Properties["$feature_flag"] == "enabled-flag" { + count++ + } + } + if count != 1 { + t.Errorf("expected exactly 1 deduped $feature_flag_called event, got %d", count) + } +} + +func TestErrorsWhileComputingFlags_PropagatesToEvent(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/flags") { + w.Write([]byte(`{ + "flags": { + "enabled-flag": { + "key": "enabled-flag", + "enabled": true, + "variant": null, + "reason": null, + "metadata": {"id": 1, "version": 2, "payload": null} + } + }, + "requestId": "req-err-1", + "errorsWhileComputingFlags": true + }`)) + } + })) + defer server.Close() + + client, capture, _ := newEvalClient(t, server) + + snap, err := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + if err != nil { + t.Fatalf("EvaluateFlags error: %v", err) + } + + // Known flag — only the response-level error should be emitted. + snap.IsEnabled("enabled-flag") + // Missing flag — both errors must be combined comma-joined. + snap.IsEnabled("missing-flag") + + events := waitForEventCount(capture, 2, 5*time.Second) + + known := findEvent(events, "$feature_flag_called", "enabled-flag") + if known == nil { + t.Fatal("expected event for enabled-flag") + } + if got := known.Properties["$feature_flag_error"]; got != FeatureFlagErrorErrorsWhileComputing { + t.Errorf("expected $feature_flag_error=%q for known flag, got %v", FeatureFlagErrorErrorsWhileComputing, got) + } + + missing := findEvent(events, "$feature_flag_called", "missing-flag") + if missing == nil { + t.Fatal("expected event for missing-flag") + } + wantCombined := FeatureFlagErrorErrorsWhileComputing + "," + FeatureFlagErrorFlagMissing + if got := missing.Properties["$feature_flag_error"]; got != wantCombined { + t.Errorf("expected combined $feature_flag_error=%q, got %v", wantCombined, got) + } +} + +func TestCaptureWarnsAndUsesFlagsWhenBothFlagsAndSendFeatureFlagsSet(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, _, logger := newEvalClient(t, fs.server) + + snap, _ := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + callsBefore := fs.callCount() + + if err := client.Enqueue(Capture{ + DistinctId: "user-1", + Event: "thing-happened", + Flags: snap, + SendFeatureFlags: SendFeatureFlags(true), + }); err != nil { + t.Fatalf("Enqueue failed: %v", err) + } + + time.Sleep(100 * time.Millisecond) + + if got := fs.callCount(); got != callsBefore { + t.Errorf("Flags must take precedence and avoid a second /flags request; got %d new calls", got-callsBefore) + } + + found := false + for _, w := range logger.snapshot() { + if strings.Contains(w, "Both Flags and SendFeatureFlags") { + found = true + break + } + } + if !found { + t.Errorf("expected warning about precedence, got %v", logger.snapshot()) + } +} + +func TestEvaluateFlags_RemoteErrorReturnsPartialLocalSnapshot(t *testing.T) { + t.Parallel() + // Mixed-resolution fixture: beta-feature resolves locally (rollout 100%), + // beta-feature2 needs a `country` property the caller doesn't supply so + // the poller can't compute it locally → falls back to remote. The remote + // /flags request 500s. The snapshot must still carry beta-feature so the + // caller doesn't lose the locally-resolved work. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/flags/definitions") || strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation"): + w.Write([]byte(fixture("feature_flag/test-get-all-flags-with-fallback-but-only-local-evaluation-set.json"))) + case strings.HasPrefix(r.URL.Path, "/flags"): + http.Error(w, "boom", http.StatusInternalServerError) + } + })) + defer server.Close() + + client, _, _ := newEvalClient(t, server, func(c *Config) { + c.PersonalApiKey = "personal-key" + }) + + // Wait for the poller's first definitions fetch. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + flags, _ := client.GetFeatureFlags() + if len(flags) > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + + snap, err := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + if err == nil { + t.Fatal("expected non-nil error from failing /flags request") + } + if snap == nil { + t.Fatal("expected non-nil snapshot even when remote /flags errors — local results should not be lost") + } + if !snap.IsEnabled("beta-feature") { + t.Errorf("expected beta-feature to be resolved locally despite remote error; got keys=%v", snap.Keys()) + } +} + +func TestCaptureWithFlags_UserPropertiesOverrideGenerated(t *testing.T) { + t.Parallel() + fs := newFlagsServer(t, "test-flags-v4.json") + defer fs.close() + + client, capture, _ := newEvalClient(t, fs.server) + + snap, _ := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "user-1"}) + + // Caller explicitly overrides the auto-generated $feature/enabled-flag + // property. Their value must win — generated props are merged first. + overrides := NewProperties(). + Set("$feature/enabled-flag", "user-override"). + Set("custom-prop", 42) + + if err := client.Enqueue(Capture{ + DistinctId: "user-1", + Event: "thing-happened", + Properties: overrides, + Flags: snap, + }); err != nil { + t.Fatalf("Enqueue failed: %v", err) + } + + deadline := time.Now().Add(5 * time.Second) + var event *CaptureInApi + for time.Now().Before(deadline) { + capture.mu.Lock() + for i := range capture.events { + if capture.events[i].Event == "thing-happened" { + event = &capture.events[i] + break + } + } + capture.mu.Unlock() + if event != nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if event == nil { + t.Fatal("did not receive thing-happened event") + } + + if got := event.Properties["$feature/enabled-flag"]; got != "user-override" { + t.Errorf("expected user-supplied $feature/enabled-flag override to win, got %v", got) + } + if got := event.Properties["custom-prop"]; got != 42 { + t.Errorf("expected custom-prop=42 to be preserved, got %v", got) + } + // Other generated flag properties from the snapshot must still be present. + if event.Properties["$feature/multi-variate-flag"] != "hello" { + t.Errorf("expected non-overridden $feature/multi-variate-flag to remain 'hello', got %v", event.Properties["$feature/multi-variate-flag"]) + } +} diff --git a/featureflags.go b/featureflags.go index d4b6d92..53824a2 100644 --- a/featureflags.go +++ b/featureflags.go @@ -2020,7 +2020,7 @@ func (poller *FeatureFlagsPoller) shutdownPoller() { // 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) + return poller.decider.makeFlagsRequest(distinctId, deviceId, groups, personProperties, groupProperties, poller.disableGeoIP, nil) } // getFeatureFlagVariantsLocalOnly evaluates all feature flags using only local evaluation diff --git a/flags.go b/flags.go index ca65571..d6156be 100644 --- a/flags.go +++ b/flags.go @@ -13,13 +13,14 @@ import ( ) type FlagsRequestData struct { - ApiKey string `json:"api_key"` - DistinctId string `json:"distinct_id"` - DeviceId *string `json:"device_id,omitempty"` - Groups Groups `json:"groups"` - PersonProperties Properties `json:"person_properties"` - GroupProperties map[string]Properties `json:"group_properties"` - DisableGeoIP bool `json:"geoip_disable,omitempty"` + ApiKey string `json:"api_key"` + DistinctId string `json:"distinct_id"` + DeviceId *string `json:"device_id,omitempty"` + Groups Groups `json:"groups"` + PersonProperties Properties `json:"person_properties"` + GroupProperties map[string]Properties `json:"group_properties"` + DisableGeoIP bool `json:"geoip_disable,omitempty"` + FlagKeysToEvaluate []string `json:"flag_keys_to_evaluate,omitempty"` } // FlagDetail represents a feature flag in v4 format @@ -158,7 +159,7 @@ func (r *FlagsResponse) UnmarshalJSON(data []byte) error { // 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) (*FlagsResponse, error) + groupProperties map[string]Properties, disableGeoIP bool, flagKeys []string) (*FlagsResponse, error) } // flagsClient implements the decider interface @@ -193,7 +194,7 @@ func newFlagsClient(apiKey string, endpoint string, httpClient http.Client, // 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) (*FlagsResponse, error) { + 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{} @@ -205,13 +206,14 @@ func (d *flagsClient) makeFlagsRequest(distinctId string, deviceId *string, grou groupProperties = map[string]Properties{} } requestData := FlagsRequestData{ - ApiKey: d.apiKey, - DistinctId: distinctId, - DeviceId: deviceId, - Groups: groups, - PersonProperties: personProperties, - GroupProperties: groupProperties, - DisableGeoIP: disableGeoIP, + ApiKey: d.apiKey, + DistinctId: distinctId, + DeviceId: deviceId, + Groups: groups, + PersonProperties: personProperties, + GroupProperties: groupProperties, + DisableGeoIP: disableGeoIP, + FlagKeysToEvaluate: flagKeys, } requestDataBytes, err := json.Marshal(requestData) diff --git a/posthog.go b/posthog.go index 72c621c..9e4af17 100644 --- a/posthog.go +++ b/posthog.go @@ -82,6 +82,19 @@ type Client interface { // GetAllFlags returns all flags for a user GetAllFlags(FeatureFlagPayloadNoKey) (map[string]interface{}, error) + // EvaluateFlags returns a snapshot of feature-flag evaluations for the + // given distinct_id using at most one /flags request. Pass the returned + // snapshot to a Capture event via Capture.Flags to attach $feature/ + // properties without another network call. Calls to IsEnabled and GetFlag + // on the snapshot fire deduped $feature_flag_called events; GetFlagPayload + // does not. + // + // If the remote /flags request fails after some flags were resolved + // locally, EvaluateFlags returns a non-nil snapshot containing the + // locally-evaluated flags alongside the error so the caller can still + // branch on what was resolved. + EvaluateFlags(EvaluateFlagsPayload) (*FeatureFlagEvaluations, error) + // ReloadFeatureFlags forces a reload of feature flags // NB: This is only available when using a PersonalApiKey ReloadFeatureFlags() error @@ -365,7 +378,16 @@ func (c *client) Enqueue(msg Message) (err error) { m.Type = "capture" m.Uuid = makeUUID(m.Uuid) m.Timestamp = makeTimestamp(m.Timestamp, ts) - if m.shouldSendFeatureFlags() { + if m.Flags != nil { + if m.shouldSendFeatureFlags() { + c.Warnf("[FEATURE FLAGS] Both Flags and SendFeatureFlags were set on Capture; using Flags and ignoring SendFeatureFlags.") + } + // Generated flag properties go down first so user-supplied + // Properties override them on conflict — matches the Python SDK's + // merge order so callers can manually overwrite $feature/ + // or $active_feature_flags if they need to. + m.Properties = m.Flags.eventProperties().Merge(m.Properties) + } else if m.shouldSendFeatureFlags() { // Add all feature variants to event personProperties := NewProperties() groupProperties := map[string]Properties{} @@ -559,12 +581,7 @@ func (c *client) getFeatureFlagResultWithContext(ctx context.Context, flagConfig return nil, ctx.Err() } - deviceID := "" - if flagConfig.DeviceId != nil { - deviceID = *flagConfig.DeviceId - } - cacheKey := flagUser{flagConfig.DistinctId, flagConfig.Key, deviceID} - if *flagConfig.SendFeatureFlagEvents && !c.distinctIdsFeatureFlagsReported.Contains(cacheKey) { + if *flagConfig.SendFeatureFlagEvents { var properties = NewProperties(). Set("$feature_flag", flagConfig.Key). Set("$feature_flag_response", flagValue). @@ -595,14 +612,7 @@ func (c *client) getFeatureFlagResultWithContext(ctx context.Context, flagConfig properties.Set("$feature_flag_error", errorString) } - if c.Enqueue(Capture{ - DistinctId: flagConfig.DistinctId, - Event: "$feature_flag_called", - Properties: properties, - Groups: flagConfig.Groups, - }) == nil { - c.distinctIdsFeatureFlagsReported.Add(cacheKey, struct{}{}) - } + c.captureFlagCalledIfNeeded(flagConfig.DistinctId, flagConfig.Key, flagConfig.DeviceId, properties, flagConfig.Groups) } if flagValue == nil { @@ -640,6 +650,31 @@ func (c *client) getFeatureFlagResultWithContext(ctx context.Context, flagConfig return result, err } +// captureFlagCalledIfNeeded fires a $feature_flag_called event if the +// (distinctId, key, deviceId) triple has not already been reported on this +// client. The caller is responsible for building the full properties dict; +// this helper only handles dedup and enqueue. It is shared by the legacy +// per-flag evaluation path and the FeatureFlagEvaluations snapshot path so +// both dedupe identically against the same per-distinct_id LRU cache. +func (c *client) captureFlagCalledIfNeeded(distinctId, key string, deviceId *string, properties Properties, groups Groups) { + deviceIDStr := "" + if deviceId != nil { + deviceIDStr = *deviceId + } + cacheKey := flagUser{distinctID: distinctId, flagKey: key, deviceID: deviceIDStr} + if c.distinctIdsFeatureFlagsReported.Contains(cacheKey) { + return + } + if err := c.Enqueue(Capture{ + DistinctId: distinctId, + Event: "$feature_flag_called", + Properties: properties, + Groups: groups, + }); err == nil { + c.distinctIdsFeatureFlagsReported.Add(cacheKey, struct{}{}) + } +} + func (c *client) GetRemoteConfigPayload(flagKey string) (string, error) { return c.makeRemoteConfigRequest(flagKey) } @@ -699,6 +734,229 @@ func (c *client) getAllFlagsWithContext(ctx context.Context, flagConfig FeatureF return flagsValue, err } +// EvaluateFlagsPayload is the input to Client.EvaluateFlags. +type EvaluateFlagsPayload struct { + DistinctId string + DeviceId *string + Groups Groups + PersonProperties Properties + GroupProperties map[string]Properties + OnlyEvaluateLocally bool + // DisableGeoIP, when non-nil, overrides the client-level DisableGeoIP for + // this evaluation only. + DisableGeoIP *bool + // FlagKeys, when non-empty, trims the network call by asking the server + // to evaluate only the named flags (sent as flag_keys_to_evaluate). + // This is server-side filtering; use FeatureFlagEvaluations.Only to do + // client-side filtering of which flags are attached to events from an + // existing snapshot. + FlagKeys []string +} + +func (c *client) EvaluateFlags(payload EvaluateFlagsPayload) (*FeatureFlagEvaluations, error) { + host := c.featureFlagEvaluationsHost() + + if payload.DistinctId == "" { + c.Warnf("EvaluateFlags called without a DistinctId — returning an empty snapshot") + return &FeatureFlagEvaluations{ + host: host, + distinctId: "", + flags: map[string]evaluatedFlagRecord{}, + accessed: map[string]struct{}{}, + }, nil + } + + if payload.Groups == nil { + payload.Groups = Groups{} + } + if payload.PersonProperties == nil { + payload.PersonProperties = NewProperties() + } + if payload.GroupProperties == nil { + payload.GroupProperties = map[string]Properties{} + } + + disableGeoIP := c.GetDisableGeoIP() + if payload.DisableGeoIP != nil { + disableGeoIP = *payload.DisableGeoIP + } + + records := make(map[string]evaluatedFlagRecord) + locallyEvaluated := make(map[string]struct{}) + fallbackToRemote := true + + if c.featureFlagsPoller != nil { + fallbackToRemote = c.populateLocalEvaluations(records, locallyEvaluated, payload) + } + + var requestId string + var evaluatedAt *int64 + var errorsWhileComputing bool + var quotaLimited bool + + // remoteErr is returned alongside the snapshot when the /flags request + // fails, so callers still get any locally-evaluated flags collected above. + var remoteErr error + if fallbackToRemote && !payload.OnlyEvaluateLocally { + flagsResponse, err := c.decider.makeFlagsRequest( + payload.DistinctId, + payload.DeviceId, + payload.Groups, + payload.PersonProperties, + payload.GroupProperties, + disableGeoIP, + payload.FlagKeys, + ) + if err != nil { + remoteErr = err + } else if flagsResponse != nil { + requestId = flagsResponse.RequestId + evaluatedAt = flagsResponse.EvaluatedAt + errorsWhileComputing = flagsResponse.ErrorsWhileComputingFlags + quotaLimited = c.isFeatureFlagsQuotaLimited(flagsResponse) + if !quotaLimited { + for key, detail := range flagsResponse.Flags { + if _, alreadyLocal := locallyEvaluated[key]; alreadyLocal { + continue + } + records[key] = recordFromFlagDetail(detail) + } + } + } + } + + return &FeatureFlagEvaluations{ + host: host, + distinctId: payload.DistinctId, + deviceId: payload.DeviceId, + groups: payload.Groups, + flags: records, + requestId: requestId, + evaluatedAt: evaluatedAt, + errorsWhileComputing: errorsWhileComputing, + quotaLimited: quotaLimited, + accessed: map[string]struct{}{}, + }, remoteErr +} + +// populateLocalEvaluations fills records with locally-resolved flags. It +// returns whether the caller should fall back to a remote /flags request to +// fill in the rest. The local-evaluation loop here mirrors +// FeatureFlagsPoller.GetAllFlags but stores the rich record needed to power +// $feature_flag_called events with locally_evaluated=true. +func (c *client) populateLocalEvaluations(records map[string]evaluatedFlagRecord, locallyEvaluated map[string]struct{}, payload EvaluateFlagsPayload) bool { + poller := c.featureFlagsPoller + featureFlags, err := poller.GetFeatureFlags() + if err != nil { + return true + } + if len(featureFlags) == 0 { + return true + } + + flagKeyFilter := map[string]struct{}{} + for _, k := range payload.FlagKeys { + flagKeyFilter[k] = struct{}{} + } + + cohorts := poller.getCohorts() + fallbackToRemote := false + const localReason = "Evaluated locally" + + for _, storedFlag := range featureFlags { + if len(flagKeyFilter) > 0 { + if _, ok := flagKeyFilter[storedFlag.Key]; !ok { + continue + } + } + value, err := poller.computeFlagLocally( + storedFlag, + payload.DistinctId, + payload.DeviceId, + payload.Groups, + payload.PersonProperties, + payload.GroupProperties, + cohorts, + ) + if err != nil { + c.debugf("Unable to compute flag '%s' locally - %s", storedFlag.Key, err) + fallbackToRemote = true + continue + } + + record := evaluatedFlagRecord{ + Key: storedFlag.Key, + LocallyEvaluated: true, + Reason: ptrString(localReason), + } + switch v := value.(type) { + case bool: + record.Enabled = v + case string: + record.Enabled = true + variant := v + record.Variant = &variant + default: + record.Enabled = false + } + + if record.Enabled { + variantKey := "true" + if record.Variant != nil { + variantKey = *record.Variant + } + if rawPayload, ok := storedFlag.Filters.Payloads[variantKey]; ok { + if s := rawMessageToString(rawPayload); s != "" { + payloadStr := s + record.Payload = &payloadStr + } + } + } + + records[storedFlag.Key] = record + locallyEvaluated[storedFlag.Key] = struct{}{} + } + + return fallbackToRemote +} + +// recordFromFlagDetail builds an evaluatedFlagRecord from a v4 FlagDetail. +func recordFromFlagDetail(detail FlagDetail) evaluatedFlagRecord { + record := evaluatedFlagRecord{ + Key: detail.Key, + Enabled: detail.Enabled, + Variant: detail.Variant, + } + if detail.Failed != nil && *detail.Failed { + record.Enabled = false + errStr := FeatureFlagErrorEvaluationFailed + record.Error = &errStr + } + id := detail.Metadata.ID + record.ID = &id + version := detail.Metadata.Version + record.Version = &version + if detail.Reason != nil { + reason := detail.Reason.Description + record.Reason = &reason + } + if s := rawMessageToString(detail.Metadata.Payload); s != "" { + payloadStr := s + record.Payload = &payloadStr + } + return record +} + +func ptrString(s string) *string { return &s } + +// featureFlagEvaluationsHost wires the snapshot's callbacks to this client. +func (c *client) featureFlagEvaluationsHost() featureFlagEvaluationsHost { + return featureFlagEvaluationsHost{ + captureFlagCalledIfNeeded: c.captureFlagCalledIfNeeded, + logger: c.Logger, + } +} + // Close gracefully shuts down the client, flushing any pending messages. // If ShutdownTimeout is set to a positive duration, Close waits up to that // duration for in-flight requests to complete. Otherwise, it waits indefinitely. @@ -1239,7 +1497,7 @@ func (c *client) getFeatureFlagFromRemote(key string, distinctId string, deviceI Value: nil, } - flagsResponse, err := c.decider.makeFlagsRequest(distinctId, deviceId, groups, personProperties, groupProperties, c.GetDisableGeoIP()) + flagsResponse, err := c.decider.makeFlagsRequest(distinctId, deviceId, groups, personProperties, groupProperties, c.GetDisableGeoIP(), nil) if err != nil { result.Err = err @@ -1277,7 +1535,7 @@ func (c *client) getFeatureFlagFromRemote(key string, distinctId string, deviceI } func (c *client) getAllFeatureFlagsFromRemote(distinctId string, deviceId *string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (map[string]interface{}, error) { - flagsResponse, err := c.decider.makeFlagsRequest(distinctId, deviceId, groups, personProperties, groupProperties, c.GetDisableGeoIP()) + flagsResponse, err := c.decider.makeFlagsRequest(distinctId, deviceId, groups, personProperties, groupProperties, c.GetDisableGeoIP(), nil) if err != nil { return nil, err }