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.
|
|
Size Change: +28 kB (+0.4%) Total Size: 7.11 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
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 |
…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
) * feat: add EvaluateFlagsAsync() API for single-call flag evaluation Adds a new EvaluateFlagsAsync(distinctId, options) method on the client that returns a FeatureFlagEvaluations snapshot. The snapshot powers IsEnabled / GetFlag / GetFlagPayload calls, fires $feature_flag_called lazily (deduped against the existing per-distinct-id cache), and can be forwarded to a new Capture(..., flags: snapshot) overload to attach $feature/<key> and $active_feature_flags to events without a second /flags request. Mirrors PostHog/posthog-js#3476 and PostHog/posthog-python#539. Also fixes a long-standing bug where the legacy single-flag path hard-coded locally_evaluated=false on every $feature_flag_called event. Locally-evaluated flags now correctly carry locally_evaluated=true, $feature_flag_reason="Evaluated locally", and a new $feature_flag_definitions_loaded_at timestamp surfaced via LocalFeatureFlagsLoader. The existing IsFeatureEnabledAsync / GetFeatureFlagAsync / Capture(..., sendFeatureFlags, ...) APIs are unchanged in this PR; a follow-up minor will mark them deprecated in favor of the snapshot API. Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40 * review: thread-safety, drop dead reason, parameterize tests, parse JSON - FeatureFlagEvaluations._accessed: HashSet<string> -> ConcurrentDictionary<string, byte> so callers may share a snapshot across parallel branches without corrupting it. - ToRecord: leave EvaluatedFlagRecord.Reason null for locally-evaluated flags; the "Evaluated locally" string is hardcoded inside BuildFeatureFlagCalledProperties and the host gates record.Reason with !LocallyEvaluated, so it was unread. - Collapse IsEnabledReturnsFalseForUnknownKey + GetFlagReturnsNullForUnknownKey into a parameterized [Theory] over the accessor under test. - Replace the brittle substring match on $active_feature_flags with a parsed, order-independent comparison; Dictionary iteration order isn't a guarantee. Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40 * review: DIMs, dedup fast-path, perf, coverage gaps Address PR feedback: - IPostHogClient: add default interface implementations for the new Capture(flags:), CaptureException(flags:), and EvaluateFlagsAsync members so external implementers don't see a source break. Conditionally compiled — DIMs only on netstandard2.1+ (the runtime requirement); netstandard2.0 keeps abstract members. - FeatureFlagEvaluations.RecordAccess: early-return on repeat access, dropping per-call dedup-cache lookups + property allocation when a key has already been seen by this snapshot. Cross-snapshot dedup still flows through the MemoryCache. - AddFeatureFlagsToCapturedEvent (snapshot path): single-pass enumeration over Records, skip the LINQ Where/Select/ToArray for $active_feature_flags. - FeatureFlagEvaluations._records: tighten field type to Dictionary so Keys is a clean expression-bodied getter (no IReadOnlyDictionary cast). - FeatureFlagEvaluations.Only(...): lazy missing-keys list — no allocation when every requested key is present. - EvaluationsHost: drop the redundant id/version/reason copy block — the values it would write are already populated by BuildFeatureFlagCalledProperties via the FeatureFlagWithMetadata pattern match. - EvaluatedFlagRecord: remove the now-unused Id/Version/Reason fields. The property dict is built from record.Flag (typed as FeatureFlagWithMetadata when present) rather than from duplicated record-level state. - EvaluateFlagsAsync: local-pass quota_limited preserves locally-evaluated records and surfaces FeatureFlagError.QuotaLimited (matches remote-pass behavior); previously it discarded local results entirely. Add a comment on the local-wins merge clarifying the divergence from GetAllFeatureFlagsAsync. - IPostHogClient.EvaluateFlagsAsync: <remarks> contrasting FlagKeysToEvaluate (request-body scoping) with FeatureFlagEvaluations.Only(...) (in-memory). - IFeatureFlagEvaluationsHost.TryCaptureFeatureFlagCalledEventIfNeeded -> CaptureFeatureFlagCalled (no return value, no try semantics). Tests added: - MixedLocalAndRemoteEvaluationMergesRecordsAndTagsSourceCorrectly: pins the local-wins merge with locally_evaluated tagged correctly per source. - UnknownKeyAccessAppendsFlagMissingErrorOnFeatureFlagCalled: pins the $feature_flag_error wiring through to the emitted event. - CaptureExceptionAttachesFeatureFlagsFromSnapshot: pins the new CaptureException(flags:) overload so a CaptureExceptionCore wiring mistake would be caught. Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40 * chore: deprecate single-flag and sendFeatureFlags APIs Mark the four legacy paths replaced by EvaluateFlagsAsync + snapshot [Obsolete(error: false)] so users see migration guidance the moment they update the package: - IPostHogClient.IsFeatureEnabledAsync / .GetFeatureFlagAsync - IPostHogClient.Capture(..., bool sendFeatureFlags, ...) - IPostHogClient.CaptureException(..., bool sendFeatureFlags, ...) Cascade [Obsolete] to wrapper extensions: - FeatureFlagExtensions: 5 IsFeatureEnabledAsync + 5 GetFeatureFlagAsync overloads - CaptureExtensions: bool-sendFeatureFlags overloads of Capture / CapturePageView / CaptureScreenView - CaptureExceptionExtensions: bool-sendFeatureFlags overloads Each extension delegates internally; suppress CS0618 inside the body so the warning surfaces at the user call site, not at the SDK call into itself. Internal call sites that always passed sendFeatureFlags: false migrate to the new Capture(..., flags: null, ...) overload — no behavioral change, but stops the SDK from internally calling its own deprecated path. Tests and samples that intentionally exercise the deprecated surface get a file-level #pragma warning disable CS0618. The new FeatureFlagEvaluationsTests cross-path dedup test wraps a single IsFeatureEnabledAsync call in a per-call pragma so the rest of the file still catches accidental new uses. PostHog.AI's OpenAI handler keeps the legacy Capture(..., sendFeatureFlags: false, ...) call with a #pragma + TODO; its tests assert the legacy mock shape and migrating them is its own change. PostHog.AspNetCore's PostHogVariantFeatureManager suppresses with a #pragma + TODO; the FeatureManager API is per-flag so a snapshot rewrite is non-trivial. All 781 unit tests, 26 AspNetCore tests, and 19 AI tests pass. Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40 * review: fix CI, propagate cancellation, tighten Records, polish comments - sdk_compliance_adapter Program.cs: switch the lone Capture(..., sendFeatureFlags: false, ...) call to the new flags: null overload so the Docker publish in the SDK-compliance CI job no longer hits CS0618 → exit 1. - EvaluateFlagsAsync: exclude OperationCanceledException from the catch-all so cancellation propagates instead of being logged as UnknownError. Matches GetFeatureFlagAsync's filter. - FeatureFlagEvaluations.Records: typed as IReadOnlyDictionary so the one consumer (PostHogClient.AddFeatureFlagsToCapturedEvent) can iterate but cannot mutate the snapshot's underlying state. - Local-quota comment in EvaluateFlagsAsync: clarify that `records` is always empty when the catch fires (the throwing call is the first inside the try). - Capture / CaptureException / DIM bodies: name every trailing argument (timestamp:, flags:) so non-trailing-named-argument call sites don't trip future IDE/bot warnings even though the C# 7.2+ rules accept them. - FeatureFlagEvaluationsTests: drop the unused Microsoft.Extensions.Options using directive. Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40 * chore: drop stray BOM after pragma in FeatureFlagExtensionsTests Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40 * chore: revert manual version bump; release workflow handles it RELEASING.md confirms the auto-release workflow bumps Directory.Build.props on merge based on the bump-* PR label, so leaving 2.6.0 in source would cause it to compound (e.g. to 2.7.0 after bump-minor). Restore to main's 2.5.0. Generated-By: PostHog Code Task-Id: 494d1c64-1b39-421a-9317-7ccd5992aa40
| /** | ||
| * @internal | ||
| */ | ||
| _getDistinctId(): string { | ||
| return this._distinctId | ||
| } | ||
|
|
||
| /** | ||
| * @internal | ||
| */ | ||
| _getGroups(): Record<string, string | number> | undefined { | ||
| return this._groups | ||
| } |
There was a problem hiding this comment.
Good catch — dropped both _getDistinctId() / _getGroups() in 67b3a02. Verified no callers across the repo before removing.
| * @param key - The feature flag key | ||
| * @param distinctId - The user's distinct ID | ||
| * @param matchValue - Optional match value to get payload for | ||
| * @param options - Optional configuration for flag evaluation | ||
| * @returns Promise that resolves to the flag payload or undefined | ||
| */ | ||
| async getFeatureFlagPayload( |
There was a problem hiding this comment.
We should JSDoc @deprecated these too
agent visibility and all
There was a problem hiding this comment.
Done in 67b3a02 — added JSDoc @deprecated tags to getFeatureFlag, isFeatureEnabled, and getFeatureFlagPayload on both the PostHogBackendClient impl (client.ts) and the IPostHog interface (types.ts). The runtime console.warn we already had handles users running code; the JSDoc tag adds IDE strike-through + agent-tooling visibility so code agents reading the public surface see the deprecation immediately, before any call is even made.
…ated tags Per dustin's feedback on PR #3476: - Remove unused public `_getDistinctId()` / `_getGroups()` methods on `FeatureFlagEvaluations`. They had no callers (verified via grep across the repo) and don't need to ship as part of the public surface. - Add JSDoc `@deprecated` tags to `getFeatureFlag`, `isFeatureEnabled`, and `getFeatureFlagPayload` on both the `PostHogBackendClient` impl (client.ts) and the `IPostHog` interface (types.ts). The runtime `console.warn` was already in place; the JSDoc tag adds IDE strike- through and agent-tooling visibility — code agents reading the public surface will see the deprecation immediately rather than waiting for a runtime call. Generated-By: PostHog Code Task-Id: b8a45b11-b41c-4995-8622-acea525e7703
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