Skip to content

feat(flags): report unresolved local flags on EvaluateFlags snapshots - #396

Merged
gustavohstrassburger merged 6 commits into
mainfrom
posthog/unresolved-local-flags
Oct 29, 2026
Merged

gustavohstrassburger merged 6 commits into
mainfrom
posthog/unresolved-local-flags

Conversation

@gustavohstrassburger

@gustavohstrassburger gustavohstrassburger commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Why: sdk-specs #85 defines how a server SDK reports flags that local evaluation could not resolve. Today such a flag is absent from the EvaluateFlags snapshot, so it looks the same as a flag that does not exist, and $feature_flag_called reports flag_missing for both. With OnlyEvaluateLocally this is permanent. posthog-node already implements this (PostHog/posthog-js#5234).

Changes:

  • FeatureFlagEvaluations.UnresolvedFlags() returns a copy of a map from each unresolved flag to an UnresolvedFlagReason (experience_continuity, unsupported_definition, unresolved_dependency, missing_context). When several causes apply, the spec's fixed precedence picks one. Only / OnlyAccessed snapshots have no entries, and reading the map is not an access.
  • A snapshot read of an unresolved flag reports local_evaluation_inconclusive instead of flag_missing.
  • InconclusiveMatchError and RequiresServerEvaluationError have a new Reason field, set at every raise site and kept through the wrappers. A missing group key is now an InconclusiveMatchError with missing_context, so it is reported; other plain errors.New failures are unchanged.
  • When definitions load, the SDK logs a Logger.Warnf line for flags local evaluation can never resolve. The fingerprint is the reason plus a hash of the flag's JSON with sorted keys, so 304 responses and cache re-applies do not repeat it.

Behavior changes (in the changeset):

  • A condition no longer stops at the first filter it cannot check. If another filter does not match, the condition resolves locally as no match. A server-required filter still forces the fallback only when it is the first unresolved filter in its condition, as in posthog-node.
  • An inactive flag with experience continuity resolves to false locally.

Divergences from the spec or posthog-node:

  • Unchanged by design: a device-bucketed flag without a device id still uses distinct_id (so the device-id acceptance row does not apply), and an invalid regex still resolves as no match. A missing group key is reported as missing_context (posthog-node resolves it to false), with the same fallback and no local value as before.
  • These stay plain errors and so are not listed: an unknown group type index (posthog-node reports unsupported_definition), and non-numeric or malformed-date values.
  • Go-only unsupported_definition causes: an exact filter whose values are all integral JSON numbers, a regex RE2 cannot run like the service, and a cross-aggregation dependency.
  • A holdout without a device id gives missing_context unless the definition has a higher reason.

💚 How did you test it?

New testify tests mirror every scenario tagged @unresolved_flags_capable and @unresolvable_flag_warning_capable in acceptance/public/evaluate-flags.feature, plus precedence, the condition loop, dependency reasons, holdouts, warning dedup (cache and 304) and unexpected errors. One existing case in TestFeatureFlagWithDependencies now resolves locally, as the spec requires. Run locally:

  • go build ./..., go vet ./..., gofmt -l .
  • go test -count=1 ./... and go test -race -count=1 ./...
  • make api-diff
  • With GOTOOLCHAIN=go1.21.13 (the CI minimum): go vet ., go test ., go mod tidy (no changes)

No manual testing in an app.

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written with Claude Code in PostHog Desktop, from a cross-SDK plan to apply sdk-specs feat(feature-flags): support quota limiting for feature flags #85 to all server SDKs. It follows posthog-node and the posthog-python (#1133), posthog-server (#896), posthog-ruby (#319) and posthog-php (#281) PRs from the same plan.
  • Resolution behavior for missing group keys, device ids and invalid regexes is left as is on purpose, so this PR only adds reporting plus the condition-loop and Active order changes the spec needs.

Created with PostHog Desktop

🤖 Generated with Claude Code

Implements the "Snapshot unresolved flag reporting" and "Warning for flags local evaluation never resolves" requirements of the evaluate-flags spec (PostHog/sdk-specs#85).

- Add `FeatureFlagEvaluations.UnresolvedFlags()`, with an `UnresolvedFlagReason` for each flag and the spec's fixed precedence.
- Report `local_evaluation_inconclusive` instead of `flag_missing` when a snapshot read hits an unresolved flag.
- Do not stop a condition at the first inconclusive filter, so a filter that does not match resolves the condition locally.
- Check `Active` before experience continuity.
- Log one warning per flag that local evaluation can never resolve, repeated only when its definition changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Generated-By: PostHog Desktop
Task-Id: 39793b31-809e-4689-a5d8-b4359cbb22c1

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 7eb1932: 2 Should fix.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment thread featureflags.go Outdated
Comment thread featureflags.go
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

posthog-go Compliance Report

Date: 2026-10-28 20:12:40 UTC
Duration: 169277ms

✅ All Tests Passed!

111/111 tests passed


Capture_V1 Tests

✅ 94/94 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 19ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 7ms
Required Headers.Has Authorization Bearer Header ✅ 7ms
Required Headers.Has Content Type Json ✅ 6ms
Required Headers.Has Posthog Sdk Info Format ✅ 6ms
Required Headers.Has Posthog Attempt Header ✅ 7ms
Required Headers.Has Posthog Request Id ✅ 6ms
Required Headers.Has Posthog Request Timestamp ✅ 6ms
Required Headers.Has User Agent ✅ 7ms
Body Format.Body Has Created At And Batch ✅ 6ms
Body Format.No Api Key In Body ✅ 6ms
Body Format.No Sent At In Body ✅ 6ms
Event Format.Event Has Required Root Fields ✅ 6ms
Event Format.Event Uuid Is Valid ✅ 6ms
Event Format.Event Timestamp Is Rfc3339 ✅ 7ms
Event Format.Distinct Id Is String ✅ 6ms
Event Format.Distinct Id At Root Not Properties ✅ 6ms
Event Format.Custom Properties Preserved ✅ 6ms
Event Format.Set Properties Preserved ✅ 6ms
Event Format.Set Once Properties Preserved ✅ 6ms
Event Format.Groups Properties Preserved ✅ 6ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 6ms
Event Format.Event Has Required Root Fields Batch ✅ 8ms
Event Format.Event Uuid Is Valid Batch ✅ 19ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 9ms
Event Format.Distinct Id Is String Batch ✅ 19ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 9ms
Event Format.Custom Properties Preserved Batch ✅ 18ms
Event Format.Set Properties Preserved Batch ✅ 9ms
Event Format.Set Once Properties Preserved Batch ✅ 8ms
Event Format.Groups Properties Preserved Batch ✅ 9ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 9ms
Batch Behavior.Multiple Events In Single Batch ✅ 31ms
Batch Behavior.Batch Envelope Smoke ✅ 10ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 4ms
Batch Behavior.Flush At Triggers Batch ✅ 1007ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 9ms
Deduplication.Generates Unique Uuids ✅ 14ms
Deduplication.Different Events Same Content Different Uuids ✅ 9ms
Deduplication.Preserves Uuid On Retry ✅ 5135ms
Deduplication.Preserves Timestamp On Retry ✅ 5135ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5137ms
Deduplication.No Duplicate Events In Batch ✅ 32ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 6ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 10133ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 5134ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 2024ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 5135ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 8ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 28ms
Response Format Validation.Success No Retry After When All Ok ✅ 27ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 131ms
Response Format Validation.Success No Retry After When Drop Only ✅ 28ms
Response Format Validation.Response Echoes Request Id ✅ 6ms
Retry Behavior.Retries On 408 ✅ 5136ms
Retry Behavior.Retries On 500 ✅ 5133ms
Retry Behavior.Retries On 503 ✅ 5136ms
Retry Behavior.Retries On 504 ✅ 5136ms
Retry Behavior.Retryable Errors Have Retry After ✅ 2133ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 8139ms
Retry Behavior.Does Not Retry On 400 ✅ 2134ms
Retry Behavior.Does Not Retry On 401 ✅ 2133ms
Retry Behavior.Does Not Retry On 402 ✅ 2133ms
Retry Behavior.Does Not Retry On 413 ✅ 2133ms
Retry Behavior.Does Not Retry On 415 ✅ 2134ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2133ms
Retry Behavior.Implements Backoff ✅ 15143ms
Retry Behavior.Max Retries Respected ✅ 15138ms
Partial Batch Handling.Handles 200 Full Success ✅ 2010ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3030ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3030ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3031ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 5136ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 5136ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 5134ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 5135ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 5134ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 5137ms
Partial Batch Handling.Respects Retry After On Partial ✅ 5135ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3032ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3034ms
Compression.Sends Gzip Content Encoding ✅ 18ms
Compression.No Content Encoding When Disabled ✅ 8ms
Compression.Compressed Body Is Decompressible ✅ 7ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2131ms
Event Options.Cookieless Mode Override ✅ 9ms
Event Options.Disable Skew Correction Override ✅ 6ms
Event Options.Process Person Profile Override ✅ 7ms
Event Options.Product Tour Id Override ✅ 6ms
Event Options.Unset Options Omitted ✅ 7ms
Event Options.Options Override In Batch ✅ 9ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 6ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 7ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 7ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 16ms
Request Payload.Flags Request Uses V2 Query Param ✅ 18ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 17ms
Request Payload.Flags Request Omits Authorization Header ✅ 17ms
Request Payload.Token In Flags Body Matches Init ✅ 18ms
Request Payload.Groups Round Trip ✅ 18ms
Request Payload.Groups Default To Empty Object ✅ 18ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 17ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 17ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 17ms
Request Lifecycle.No Flags Request On Init Alone ✅ 4ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 6ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 30ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 18ms
Retry Behavior.Retries Flags On 502 ✅ 19ms
Retry Behavior.Retries Flags On 504 ✅ 19ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 19ms

@gustavohstrassburger
gustavohstrassburger marked this pull request as ready for review October 28, 2026 16:18
@gustavohstrassburger
gustavohstrassburger requested a review from a team as a code owner October 28, 2026 16:18

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed cb9c7a1: 2 Should fix.

Comment thread featureflags.go
Comment thread featureflags.go

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The payload-fingerprint change looks sound. One reporting-contract decision remains before approval: known group flags with missing group keys are still omitted from unresolved reporting. Cohort reason precedence is smaller, optional feedback. I’m not repeating the mixed-group comment following the explicit Node-parity decision.

AI-assisted review.

Comment thread posthog.go
)
if err != nil {
c.debugf("Unable to compute flag '%s' locally - %s", storedFlag.Key, err)
if reason, ok := unresolvedReasonOf(err); ok {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Include missing flag-level group context in snapshot reporting

Disposition: REQUIRED reporting-contract correction or explicit public limitation acceptance
Location: posthog.go:1371–1373, RIGHT
Relationship: New reporting gap introduced by this PR; unchanged by the follow-up.

For a loaded active company flag, omitting its required group key leaves the flag absent from both Keys() and UnresolvedFlags(). Reading it still reports flag_missing.

Expected: An unresolved entry with missing_context, followed by local_evaluation_inconclusive exposure metadata.
Source-inferred result: No unresolved entry and flag_missing; the flag value remains absent.

The group-key branch at featureflags.go:1577–1581 returns a plain error. The new collector recognizes only dedicated SDK evaluator errors, so it omits this known flag.

The canonical reporting contract explicitly includes absent group keys in missing_context. Public wording at feature_flag_evaluations.go:136–142 and posthog.go:1175–1178 also promises broader reporting.

Base comparison and impact: Base already returned this plain error and omitted the value. This is not a new incorrect flag decision; the new API fails its central known-versus-missing distinction for ordinary group context.

Consider classifying this case for snapshot reporting while preserving legacy getter errors and values. Alternatively, explicitly accept and publicly document the reporting exception. Documentation would disclose—not satisfy—the current canonical contract.

Regression sketch — NOT EXECUTED:

func TestReviewMissingGroupKey(t *testing.T) {
    const defs = `{"flags":[{"key":"checkout","active":true,"filters":{"aggregation_group_type_index":0,"groups":[{"properties":[],"rollout_percentage":100}]}}],"group_type_mapping":{"0":"company"},"cohorts":{}}`
    c, capture, calls := newUnresolvedFlagsClient(t, defs, remoteNoFlags)

    control, err := c.EvaluateFlags(EvaluateFlagsPayload{
        DistinctId: "control", Groups: Groups{"company": "acme"},
        OnlyEvaluateLocally: true,
    })
    require.NoError(t, err)
    require.Equal(t, []string{"checkout"}, control.Keys())

    s, err := c.EvaluateFlags(EvaluateFlagsPayload{
        DistinctId: "user", OnlyEvaluateLocally: true,
    })
    require.NoError(t, err)
    require.Equal(t, UnresolvedFlagReasonMissingContext,
        s.UnresolvedFlags()["checkout"])
    require.Nil(t, s.GetFlag("checkout"))
    require.Equal(t, FeatureFlagErrorLocalEvaluationInconclusive,
        flagCalledError(t, c, capture, "checkout"))
    require.Zero(t, calls.Load())
}

Intended command: go test -v -count=1 -run '^TestReviewMissingGroupKey$' .

The supplied-group control is silent, avoiding interference with the exposure assertion. Base has no unresolved accessor and serves as an existing-value/exposure control, not a compilation-failure regression.

Execution blocker: No working isolated sandbox; Docker unavailable and sandbox-exec aborts with exit 134. No test failure or passing fix is claimed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 239c619: a flag-level group flag whose group key is missing now returns an InconclusiveMatchError with missing_context (a definition reason such as experience continuity or a static cohort still wins), so EvaluateFlags lists it in UnresolvedFlags() and a read reports local_evaluation_inconclusive. The legacy getters keep the same value, error message, log line, $feature_flag_error and /flags fallback. Covered by TestEvaluateFlags_GroupFlagWithoutGroupKey and TestLegacyGettersGroupFlagWithoutGroupKey.

if isServerEvalError(err) {
return false, err
} else if isInconclusiveError(err) {
if serverErr, ok := err.(*RequiresServerEvaluationError); ok {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Merge cohort definition reasons before returning its server error

Disposition: OPTIONAL
Location: feature_flag_property_group.go:78–80, RIGHT; nested counterpart at lines 45–47
Relationship: New reason-reporting gap introduced by this PR; unchanged by the follow-up.

A dynamic AND cohort containing a server-required flag dependency followed by an unsupported property criterion reports unresolved_dependency. Reverse the same leaves and it reports unsupported_definition.

Expected: unsupported_definition in either order under the fixed precedence contract.
Source-inferred result: The reason depends on cohort-leaf order.

A concrete supported definition uses is_date_exact: the pinned backend’s filters_schema.py:28–53 lists it as supported, while this Go evaluator lacks that operator. Its absence is pre-existing and is not a separate finding; it provides a realistic unsupported-definition trigger.

The dependency’s server error is tagged unresolved_dependency, then returned immediately from the cohort wrapper. The later-condition scan does not inspect the skipped remainder of the current cohort. Existing cohort-dependency tests and cache allocation establish the supported public path.

Base comparison and impact: Both orders already lacked a local value and required fallback. Only the new remediation category is wrong; no new value or fallback regression is asserted.

Consider merging definition-only reasons from the skipped cohort subtree while preserving the server-required error type and matching/fallback behavior.

Regression sketch — NOT EXECUTED:

func TestReviewCohortPrecedence(t *testing.T) {
    dep := flagDefinition("dep", flagCondition(100, staticCohortFilter))
    target := flagDefinition("checkout", flagCondition(100,
        `{"key":"id","type":"cohort","value":1}`))
    dependency := flagDependencyFilter("dep", true, "dep")
    unsupported := `{"key":"signup","type":"person","operator":"is_date_exact","value":"2025-01-01"}`

    for _, leaves := range []string{
        unsupported + "," + dependency,
        dependency + "," + unsupported,
    } {
        defs := `{"flags":[` + dep + `,` + target +
            `],"group_type_mapping":{},"cohorts":{"1":{"type":"AND","values":[` +
            leaves + `]}}}`
        c, _, calls := newUnresolvedFlagsClient(t, defs, remoteNoFlags)
        s, err := c.EvaluateFlags(EvaluateFlagsPayload{
            DistinctId: "user", FlagKeys: []string{"checkout"},
            PersonProperties: Properties{"signup": "2025-01-01"},
            OnlyEvaluateLocally: true,
        })
        require.NoError(t, err)
        require.Empty(t, s.Keys())
        require.Equal(t, UnresolvedFlagReasonUnsupportedDefinition,
            s.UnresolvedFlags()["checkout"])
        require.Zero(t, calls.Load())
    }
}

Intended command: go test -v -count=1 -run '^TestReviewCohortPrecedence$' .

Definition-first is the control; dependency-first is inferred to fail. The equivalent base controls retain the same missing-value/exposure behavior without treating the absent new API as a regression.

Execution blocker: The same unavailable isolation environment as F1. No reviewer-test execution result is claimed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 55d276b. When a server-required error leaves the condition loop, the reason now merges the whole-flag definition reason from definitionUnresolvedReason, which walks every condition and cohort subtree, so the narrower later-conditions helper is gone. Covered in TestConditionLoopDoesNotStopAtFirstInconclusiveFilter by "server-required dependency then a higher reason in a cohort", its reversed order, and "server-required dependency does not hide a higher reason in a condition ruled out earlier".

@dustinbyrne
dustinbyrne requested a review from a team October 28, 2026 19:29

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 55d276b: 1 Should fix.

This is review round 3 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

Comment thread featureflags.go

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier findings are fixed, and the regression, legacy-getter and fallback checks pass. No further actionable findings.

AI-assisted review.

@github-project-automation github-project-automation Bot moved this from In Review to Approved in Feature Flags Oct 29, 2026
@gustavohstrassburger
gustavohstrassburger merged commit 401e68f into main Oct 29, 2026
36 checks passed
@gustavohstrassburger
gustavohstrassburger deleted the posthog/unresolved-local-flags branch October 29, 2026 17:04
@github-project-automation github-project-automation Bot moved this from Approved to Done in Feature Flags Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants