feat(node): add evaluateFlags() API for single-call flag evaluation#3476
Open
feat(node): add evaluateFlags() API for single-call flag evaluation#3476
Conversation
Introduce `posthog.evaluateFlags(distinctId, options)` returning a `FeatureFlagEvaluations` snapshot. Branch on `isEnabled()` / `getFlag()` and pass the snapshot to `capture()` via a new `flags` option so events carry the exact values the code branched on, with no extra /flags request per capture. Filtering helpers `onlyAccessed()` and `only([keys])` let callers shrink the flag set attached to events. A new `featureFlagsLogWarnings` option toggles the associated user-facing warnings. Existing `isFeatureEnabled` / `getFeatureFlag` / `sendFeatureFlags` continue to work unchanged; `sendFeatureFlags` is marked deprecated in JSDoc ahead of a future major-version removal. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
Contributor
|
Size Change: +28.3 kB (+0.4%) Total Size: 7.03 MB
ℹ️ View Unchanged
|
Allow callers to scope the underlying /flags request to a subset of flags. The chained `flags.only([...])` filter still exists for event-attachment scoping after evaluation; `flagKeys` reduces the network payload itself. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703
…examples Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703
…called events - Plumb $feature_flag_definitions_loaded_at into the snapshot at construction so locally-evaluated flag access via the new API emits the same event schema as the existing single-flag path. - Short-circuit $feature_flag_called emission when the snapshot has no resolvable distinctId, so the safety-fallback empty snapshot doesn't leak events with empty distinct_id values. - Demote the shared dedup helper from public to protected; the only external caller is a closure with `this`-scoped access. - Document the onlyAccessed() empty-fallback behavior and clarify that the local-evaluation flag definition has no version field. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703
Contributor
Prompt To Fix All With AIThis is a comment left during a code review.
Path: packages/node/src/feature-flag-evaluations.ts
Line: 248-260
Comment:
**Filtered snapshot branching fires misleading `flag_missing` events**
When `_recordAccess` is called on a filtered clone (returned by `onlyAccessed()` or `only()`) for a key that was excluded from the filter but actually exists in the original snapshot, `this._flags[key]` is `undefined` here — triggering the `$feature_flag_error: 'flag_missing'` path even though the flag was fully evaluated. This pollutes the analytics stream with spurious error events.
The test at line 341 of `evaluate-flags.spec.ts` exercises exactly this path (`filtered.isEnabled('variant-flag')` on a clone that only holds `boolean-flag`) but doesn't assert on the resulting events, so the misleading capture goes undetected.
Filtered views are intended for `capture()`, not for further branching. Consider guarding this in one of two ways: either document it explicitly and add an assertion in the test, or short-circuit `_recordAccess` when the key is absent from `_flags` on a filtered clone (e.g. by tracking whether the instance is a slice).
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: packages/node/src/__tests__/evaluate-flags.spec.ts
Line: 100-113
Comment:
**Repeated PostHog initialisation violates OnceAndOnlyOnce**
The pattern
```ts
posthog = new PostHog('TEST_API_KEY', {
host: 'http://example.com',
...posthogImmediateResolveOptions,
})
```
appears verbatim in eight tests inside the `remote evaluation` describe block. Similarly, the `captures: any[]` + `posthog.on('capture', …)` setup repeats in four of them. A shared `beforeEach` for the common case would remove the duplication and make per-test deviations (e.g. `featureFlagsLogWarnings: false`, `personalApiKey`) stand out clearly.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(node): close parity gaps on FeatureF..." | Re-trigger Greptile |
This was referenced Apr 27, 2026
Closed
Open
…tions slices Address review feedback on PR #3476: - Filtered snapshots from `only()` / `onlyAccessed()` no longer fire misleading `$feature_flag_called` events with `flag_missing` when branching on a key that was excluded from the slice. The slice tracks whether it's a filtered view via an `_isSlice` flag and short-circuits `_recordAccess` for absent keys. Document this behavior on the filter helpers' JSDoc — slices are intended for `capture()`, not branching. Add a regression test covering the path. - Refactor `evaluate-flags.spec.ts` to extract a `setup(overrides)` helper used by all suites, replacing eight repeated `new PostHog(...)` blocks plus four duplicated capture-listener setups. Per-test deviations (`featureFlagsLogWarnings: false`, `personalApiKey: ...`) now stand out as explicit overrides. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703
…ularity, captureException flags) Mirrors fixes from PostHog/posthog-python#539: - `onlyAccessed()` returns empty when nothing has been accessed (no fallback to all flags). The previous fallback contradicted the method name and surprised reviewers. - Propagate response-level errors (`errors_while_computing_flags`, `quota_limited`) into `$feature_flag_called` events so each access carries the granular error code(s) the single-flag path emits. - Make `flags` vs `sendFeatureFlags` precedence explicit on `capture()`: `flags` always wins, and we log a warning when both are passed. - Phase 2 deprecation warnings: `getFeatureFlag`, `isFeatureEnabled`, `getFeatureFlagPayload`, and `capture({ sendFeatureFlags })` now log a deduped `[PostHog] ... is deprecated` console warning the first time they're used. `isFeatureEnabled` is restructured to call `_getFeatureFlagResult` directly so a single user-level call emits exactly one warning instead of cascading. - `captureException` and `captureExceptionImmediate` accept an optional `flags` snapshot so `$exception` events carry the same flag context as the rest of the request's events. Adds a process-wide dedup helper `emitDeprecationWarningOnce` matching Python's `warnings.warn` default-dedup behavior. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Phase 1 + Phase 2 of the Server SDK Feature Flag Evaluations RFC for
posthog-node. Companion to the Python SDK PR (PostHog/posthog-python#539).Today every flag check fires its own
/flagsrequest, andcapture({ sendFeatureFlags: true })silently fires yet another on every captured event. The flag values on a captured event can diverge from the ones the code actually branched on when person/group properties differ between calls.sendFeatureFlagsalso attaches every evaluated flag to every event, which bloats properties on high-volume events.Changes
New API (Phase 1)
posthog.evaluateFlags(distinctId, ...)returns aFeatureFlagEvaluationssnapshot:A single
/flagsrequest powers both branching and event enrichment.isEnabled()andgetFlag()fire$feature_flag_calledevents (deduped through the existing cache) with the full metadata —$feature_flag_id,$feature_flag_version,$feature_flag_reason,$feature_flag_request_id— so experiment exposure tracking keeps working.Two layers of scoping
Network-level (
flagKeysoption): scopes the underlying/flagsrequest itself.Event-level (filter helpers): narrow which flags get attached to a captured event without re-fetching.
onlyAccessed()honors its name — if nothing has been accessed, it returns an empty snapshot (no fallback to all flags).only([...])warns and drops unknown keys; the warning is silenceable via thefeatureFlagsLogWarnings: falseSDK option.Slices are for capture, not branching
onlyAccessed()/only([...])return filtered snapshots intended forcapture(). CallingisEnabled()/getFlag()on a slice for a key that was filtered out is a no-op (no event fires) — the flag wasn't actually missing, it was excluded from the slice.Granular
$feature_flag_errorreportingResponse-level errors (
errors_while_computing_flags,quota_limited) are propagated into$feature_flag_calledevents from the snapshot. A missing flag during a quota-limited response now reportsquota_limited,flag_missinginstead of justflag_missing, matching the single-flag path's granularity.Exception captures carry flag context
captureException()andcaptureExceptionImmediate()accept an optionalflagsargument so$exceptionevents carry the same flag context as the rest of your request's events:flagsvssendFeatureFlagsprecedenceWhen both are passed,
flagsalways wins and we log a warning so the precedence isn't surprising:Deprecation warnings (Phase 2)
The legacy single-flag surface keeps working but now emits a deduped
[PostHog] ... is deprecatedconsole warning the first time it's used:getFeatureFlag()isFeatureEnabled()getFeatureFlagPayload()capture({ sendFeatureFlags })(only when truthy)isFeatureEnabledis restructured to call_getFeatureFlagResultdirectly instead of routing throughgetFeatureFlag, so a single user-level call emits exactly one warning instead of cascading two. The dedup is process-wide viaemitDeprecationWarningOnce, matching Python'swarnings.warndefault-dedup behavior. Phase 3 (removal in next major) ships separately.Local evaluation
Transparent. When the poller resolves a flag, the snapshot carries
locally_evaluated: trueand reason"Evaluated locally", matching whatgetFeatureFlag()emits today.Backwards compatibility
No breaking changes. All existing call paths return the same values they did before — the only behavior changes are:
onlyAccessed()no longer falls back to all flags when nothing was accessed (empty input → empty output)Internals
_getFeatureFlagResultwas refactored: the dedup + capture portion is extracted into_captureFlagCalledEventIfNeeded, which is shared between the single-flag path and the newFeatureFlagEvaluationsobject. Both paths now dedupe identically.evaluateFlagsusesgetFeatureFlagDetailsStateless(the rich-detail endpoint) rather than the bare values endpoint so the snapshot carries full per-flag metadata.Tests
packages/node/src/__tests__/evaluate-flags.spec.ts— 32 tests covering remote evaluation, local evaluation, filtering helpers, capture integration,flagKeysround-trip, empty-distinctId safety, error-granularity propagation, deprecation warning emission (with no-cascade verification), andcaptureException/captureExceptionImmediateflag forwarding.Full Node SDK suite: zero regressions on this branch (the same pre-existing failures from
mainremain).pnpm lintclean.Created with PostHog Code