Skip to content

feat: add get_feature_flags for bulk flag evaluation with events#532

Closed
dmarticus wants to merge 1 commit intomainfrom
posthog-code/get-feature-flags-bulk
Closed

feat: add get_feature_flags for bulk flag evaluation with events#532
dmarticus wants to merge 1 commit intomainfrom
posthog-code/get-feature-flags-bulk

Conversation

@dmarticus
Copy link
Copy Markdown
Contributor

@dmarticus dmarticus commented Apr 23, 2026

Summary

Add a new get_feature_flags(keys, distinct_id, ...) method for evaluating a known subset of feature flags in one bulk pass while still emitting $feature_flag_called events per resolved flag. Also adds a send_feature_flag_events option to the existing get_all_flags / get_all_flags_and_payloads methods for opt-in per-flag event emission.

The motivation (per the original PR): customers loading a page server-side often need a specific ~10 flags. get_feature_flag per key is N network calls; get_all_flags is one call but emits no $feature_flag_called events and evaluates flags the caller doesn't care about. The new method is the sweet spot: at most one network round trip, per-flag usage events.

Ports PostHog/posthog-js#3447 from posthog-node to posthog-python.

Changes

  • New get_feature_flags method (posthog/client.py): evaluates a subset of flags in bulk. Locally-evaluated flags reuse the poller's cached definitions; keys that can't be resolved locally fall through to a single remote /flags call with flag_keys_to_evaluate.
  • Event deduplication: reuses the existing distinct_ids_feature_flags_reported cache so single-flag and bulk paths share one source of truth.
  • New send_feature_flag_events option on get_all_flags / get_all_flags_and_payloads. Defaults to False to preserve existing behavior.
  • Top-level proxy (posthog/__init__.py): adds posthog.get_feature_flags(...) and threads send_feature_flag_events through the module-level get_all_flags / get_all_flags_and_payloads wrappers.
  • Tests (posthog/test/test_feature_flags.py): new TestGetFeatureFlagsBulk class with 9 tests covering remote-only evaluation, local-only evaluation, hybrid scenarios, event deduplication, send_feature_flag_events=False suppression, missing-key handling, and the new get_all_flags option.

Test plan

  • uv run pytest posthog/test/test_feature_flags.py::TestGetFeatureFlagsBulk — 9/9 pass
  • uv run pytest posthog/test/ — 324/324 pass (no regressions; send_feature_flag_events=False default preserves historical behavior)
  • uv run mypy posthog/client.py posthog/__init__.py — no new errors vs. baseline
  • uvx ruff format — clean

Created with PostHog Code

Add a new get_feature_flags(keys, distinct_id, ...) method for evaluating
a known subset of feature flags in one bulk pass while still emitting
$feature_flag_called events per resolved flag. Locally-evaluated flags
reuse the poller's cached definitions; keys that can't be resolved
locally fall through to a single remote /flags call with
flag_keys_to_evaluate. Event dedup uses the existing
distinct_ids_feature_flags_reported cache so the single-flag and bulk
paths share one source of truth.

Also adds a send_feature_flag_events option to get_all_flags and
get_all_flags_and_payloads for opt-in per-flag event emission. Defaults
to False to preserve existing behavior.

Ports PostHog/posthog-js#3447 to posthog-python.

Generated-By: PostHog Code
Task-Id: b54693f6-498d-4193-bbc2-b9a97e07e69d
@dmarticus dmarticus requested a review from a team as a code owner April 23, 2026 22:00
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 23, 2026

Prompt To Fix All With AI
This is a comment left during a code review.
Path: posthog/test/test_feature_flags.py
Line: 6789-6801

Comment:
**Incorrect mock response format**

`patch_flags` simulates the `flags()` function, whose return type is `FlagsResponse` — a dict with a `"flags"` key, not `"featureFlags"`. The adjacent `test_get_all_flags_emits_events_when_send_feature_flag_events_enabled` correctly uses `{"flags": {...}, "requestId": "..."}`. This test passes only because it asserts a negative (`call_count == 0`), so the malformed response goes undetected. Using the correct format keeps the two tests consistent and ensures the mock reflects what the real endpoint returns.

```suggestion
        patch_flags.return_value = {
            "flags": {
                "bulk-a": {
                    "key": "bulk-a",
                    "enabled": True,
                    "variant": None,
                    "reason": {"description": "Matched"},
                    "metadata": {"id": 1, "version": 1},
                },
                "bulk-b": {
                    "key": "bulk-b",
                    "enabled": True,
                    "variant": "b-variant",
                    "reason": {"description": "Matched"},
                    "metadata": {"id": 2, "version": 1},
                },
            },
            "requestId": "req-all-default",
        }
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: posthog/test/test_feature_flags.py
Line: 6531-6540

Comment:
**Prefer parameterised tests**

The codebase's style guide requires parameterised tests. Several pairs within `TestGetFeatureFlagsBulk` share the same scaffolding and differ only in one parameter, making them natural candidates:

- `test_no_events_when_send_feature_flag_events_is_false` / `test_emits_feature_flag_called_per_resolved_flag_with_metadata` → vary `send_feature_flag_events` and expected `call_count`.
- `test_get_all_flags_emits_events_when_send_feature_flag_events_enabled` / `test_get_all_flags_default_does_not_emit_events` → same pattern, differ on `send_feature_flag_events`.

Consider collapsing these (and similar pairs) with `@parameterized.expand` or `subTest`.

**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat: add get_feature_flags for bulk fla..." | Re-trigger Greptile

Comment on lines +6789 to +6801
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.flags")
def test_get_all_flags_default_does_not_emit_events(
self, patch_flags, patch_capture
):
patch_flags.return_value = {
"featureFlags": {"bulk-a": True, "bulk-b": "b-variant"},
}
client = Client(FAKE_TEST_API_KEY)

client.get_all_flags("user-1")

self.assertEqual(patch_capture.call_count, 0)
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 Incorrect mock response format

patch_flags simulates the flags() function, whose return type is FlagsResponse — a dict with a "flags" key, not "featureFlags". The adjacent test_get_all_flags_emits_events_when_send_feature_flag_events_enabled correctly uses {"flags": {...}, "requestId": "..."}. This test passes only because it asserts a negative (call_count == 0), so the malformed response goes undetected. Using the correct format keeps the two tests consistent and ensures the mock reflects what the real endpoint returns.

Suggested change
@mock.patch.object(Client, "capture")
@mock.patch("posthog.client.flags")
def test_get_all_flags_default_does_not_emit_events(
self, patch_flags, patch_capture
):
patch_flags.return_value = {
"featureFlags": {"bulk-a": True, "bulk-b": "b-variant"},
}
client = Client(FAKE_TEST_API_KEY)
client.get_all_flags("user-1")
self.assertEqual(patch_capture.call_count, 0)
patch_flags.return_value = {
"flags": {
"bulk-a": {
"key": "bulk-a",
"enabled": True,
"variant": None,
"reason": {"description": "Matched"},
"metadata": {"id": 1, "version": 1},
},
"bulk-b": {
"key": "bulk-b",
"enabled": True,
"variant": "b-variant",
"reason": {"description": "Matched"},
"metadata": {"id": 2, "version": 1},
},
},
"requestId": "req-all-default",
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/test/test_feature_flags.py
Line: 6789-6801

Comment:
**Incorrect mock response format**

`patch_flags` simulates the `flags()` function, whose return type is `FlagsResponse` — a dict with a `"flags"` key, not `"featureFlags"`. The adjacent `test_get_all_flags_emits_events_when_send_feature_flag_events_enabled` correctly uses `{"flags": {...}, "requestId": "..."}`. This test passes only because it asserts a negative (`call_count == 0`), so the malformed response goes undetected. Using the correct format keeps the two tests consistent and ensures the mock reflects what the real endpoint returns.

```suggestion
        patch_flags.return_value = {
            "flags": {
                "bulk-a": {
                    "key": "bulk-a",
                    "enabled": True,
                    "variant": None,
                    "reason": {"description": "Matched"},
                    "metadata": {"id": 1, "version": 1},
                },
                "bulk-b": {
                    "key": "bulk-b",
                    "enabled": True,
                    "variant": "b-variant",
                    "reason": {"description": "Matched"},
                    "metadata": {"id": 2, "version": 1},
                },
            },
            "requestId": "req-all-default",
        }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 6531 to +6540
self.assertNotIn("flag2", result)


class TestGetFeatureFlagsBulk(unittest.TestCase):
"""Tests for the bulk `get_feature_flags` method and the `send_feature_flag_events`
option on `get_all_flags` / `get_all_flags_and_payloads`.
"""

def _remote_flags_response(self):
return {
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 Prefer parameterised tests

The codebase's style guide requires parameterised tests. Several pairs within TestGetFeatureFlagsBulk share the same scaffolding and differ only in one parameter, making them natural candidates:

  • test_no_events_when_send_feature_flag_events_is_false / test_emits_feature_flag_called_per_resolved_flag_with_metadata → vary send_feature_flag_events and expected call_count.
  • test_get_all_flags_emits_events_when_send_feature_flag_events_enabled / test_get_all_flags_default_does_not_emit_events → same pattern, differ on send_feature_flag_events.

Consider collapsing these (and similar pairs) with @parameterized.expand or subTest.

Context Used: Do not attempt to comment on incorrect alphabetica... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/test/test_feature_flags.py
Line: 6531-6540

Comment:
**Prefer parameterised tests**

The codebase's style guide requires parameterised tests. Several pairs within `TestGetFeatureFlagsBulk` share the same scaffolding and differ only in one parameter, making them natural candidates:

- `test_no_events_when_send_feature_flag_events_is_false` / `test_emits_feature_flag_called_per_resolved_flag_with_metadata` → vary `send_feature_flag_events` and expected `call_count`.
- `test_get_all_flags_emits_events_when_send_feature_flag_events_enabled` / `test_get_all_flags_default_does_not_emit_events` → same pattern, differ on `send_feature_flag_events`.

Consider collapsing these (and similar pairs) with `@parameterized.expand` or `subTest`.

**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@dmarticus dmarticus marked this pull request as draft April 23, 2026 22:12
@dmarticus
Copy link
Copy Markdown
Contributor Author

Closing in favor of a fresh branch rebased onto current main (original branch was based on a stale commit before several unsigned release commits on main, which prevented the rebase from being pushed under the repo's signature policy). New PR incoming.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant