Repository navigation
Conversation
Add get_boolean_value, get_string_value, get_number_value, get_integer_value, get_float_value and get_object_value to the evaluate_flags() snapshot, each with a _details form that returns a FlagEvaluationDetails with an OpenFeature reason, error_code and flag_metadata. The accessors never coerce: they return the caller default when the flag is missing, failed, has no value, or has a value of another type.
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 3f8c315: 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.
| "object": lambda value: ( | ||
| copy.deepcopy(value) if isinstance(value, dict) else _NOT_ACCEPTED | ||
| ), |
There was a problem hiding this comment.
P2 · Handle recursion failures when copying object flag values
A valid JSON object nested 600 levels deep passes FeatureFlag.from_json, but copy.deepcopy exceeds Python's default recursion limit. Both get_object_value and get_object_details then propagate RecursionError into application code instead of returning a safe result, defeating the accessor's fallback behavior. Use an iterative copy or handle copy failures with the caller default.
| id=metadata.id, | ||
| version=metadata.version, | ||
| config_version=metadata.config_version, |
There was a problem hiding this comment.
P2 · Preserve absent metadata fields in evaluation details
When a typed record omits metadata.id or metadata.version, or sets either to null, parsing replaces it with 0. _resolve_flag_details then exposes that fabricated value in flag_metadata, violating the documented presence contract and giving consumers an identifier or version the server never supplied. Preserve field absence through parsing and omit those entries.
posthog-python Compliance ReportDate: 2026-10-30T18:38:54.250111+00:00 ✅ All Tests Passed!121/121 tests passed Capture_V1 Tests✅ 95/95 tests passed View Details
Capture_Ai Tests✅ 5/5 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
Feature_Flags_Local_Evaluation Tests✅ 4/4 tests passed View Details
|
The integer accessors reject numbers beyond 2**53 - 1 in magnitude, such as 1e21, so every SDK returns the same result for the same flag. A test also pins that a throwing before_send does not break a typed read.
# Conflicts: # posthog/test/test_flags_v4_response.py # posthog/types.py # references/public_api_snapshot.txt
The object accessors copy the value so a caller cannot change the snapshot. A value nested deeply enough made that copy raise RecursionError; it now returns the caller default with PARSE_ERROR, so a typed read never throws.
…t/flags-typed-accessors # Conflicts: # posthog/test/test_flags_v4_response.py
#1031 keeps the v4 value and rule metadata fields private. The typed accessors make them public, so this branch keeps them public and the changeset lists them.
…t/flags-typed-accessors
… into feat/flags-typed-accessors
…d accessors Every typed value and details accessor on the evaluate_flags() snapshot now takes the keyword-only send_feature_flag_events option that is_enabled() and get_flag() take. With send_feature_flag_events=False the read sends no $feature_flag_called and leaves the dedupe cache alone, so a later read that sends events still reports the flag. The key still counts as accessed for only_accessed(). The value form passes the option to its details form, so both forms behave the same.
Typed details of a locally evaluated flag left the flag version out of flag_metadata. The local flag definition carries the version, but the evaluate_flags() snapshot record set it to None. The record now takes the version from the definition, and the details add it to flag_metadata when it is present. The record change is the same one #1149 makes, so $feature_flag_called for a snapshot read of a locally evaluated flag also carries $feature_flag_version.
…ccessor Numbers are IEEE-754 doubles in every PostHog SDK. get_number_value and get_number_details returned an integer beyond 2**53 - 1 in magnitude exactly, so Python could return a value JavaScript cannot. They now return the nearest float. Integers within range keep their int type, the integer accessors keep TYPE_MISMATCH beyond the range, and the float accessors are unchanged. An integer too large for any double is a TYPE_MISMATCH, as in the float accessor.
A typed accessor that resolves to TYPE_MISMATCH returned the caller's default and still sent $feature_flag_called, which took the dedupe slot, so the later read of the right type sent nothing. A read of the wrong type is a bug in the calling code, not a use of the flag. It now sends no event and takes no dedupe slot, so the next read of the right type, or a get_flag() or is_enabled() read, reports the flag. The client logs the error_message once per flag key and requested type, in a cache bounded like the flag-event dedupe. The read still counts as accessed for only_accessed(). A false or null value read as another type, missing, failed and unreadable flags, and reads with send_feature_flag_events=False are unchanged.
The float accessor returned TYPE_MISMATCH for an integer too large to convert to a float, and the number accessor followed it. JavaScript parses such a number as an infinity, the nearest IEEE-754 double. Both accessors now return that infinity, with its sign, so every PostHog SDK reads the same value. The integer accessors keep TYPE_MISMATCH.
… state Move the client's record of logged type mismatches next to _logged_untyped_flags_response, the other once-per-client flag log state, in both clients.
Problem
#1031 reads the
/flags?v=4response and keeps each flag's typed value, reason and metadata, but adds no accessors for reading them. Callers still cannot read a number or object flag value as such, or see why a flag returned what it did.Changes
The typed accessor API, split out of #1031 so that change can ship without new public methods:
get_boolean_value,get_string_value,get_number_value,get_integer_value,get_float_valueandget_object_valueon theevaluate_flags()snapshot, each with a_detailsform.send_feature_flag_eventsoption, with the same meaning as onis_enabled()andget_flag()(feat: add a per-read send_feature_flag_events option to snapshot reads #1148). Withsend_feature_flag_events=False, the read sends no$feature_flag_calledand leaves the dedupe cache alone, so a later read that sends events still reports the flag. The key still counts as accessed foronly_accessed(). The value form passes the option to its details form.FlagEvaluationDetails,FlagEvaluationReasonandFlagEvaluationErrorCode, with OpenFeature reasons and error codes. The accessors never coerce: they return the caller default when the flag is missing, failed, unreadable, has no value, or has a value of another type.TYPE_MISMATCH, any of the 12 methods, either client) returns the caller default with the same details as before, but sends no$feature_flag_calledand takes no dedupe slot. With feat(flags): send $experiment_exposure for v2 experiment splits #1154 that also means no$experiment_exposure. The next read of the right type, or aget_flag()oris_enabled()read, sends the event as a first read. The client logs the details'error_messageas an error once per (flag key, requested type), in a cache bounded like the flag-event dedupe cache. The read still counts as accessed foronly_accessed(), as before. Afalseornullvalue read through a non-boolean accessor, missing, failed and unreadable flags, andsend_feature_flag_events=Falsekeep their behaviour.get_number_value/get_number_detailsreturn an integer beyond2**53 - 1in magnitude as the nearestfloat, so Python returns what JavaScript does; an integer within range stays anint.get_float_valuereturns every number as the nearestfloat. An integer beyond the range of a double reads as an infinity, with its sign, through both. The integer accessors keepTYPE_MISMATCHbeyond2**53 - 1.idandversioninflag_metadata. The snapshot record used to setversiontoNonefor a local flag. It now takes it from the definition, which is the same one-line change fix: send flag id and version on locally evaluated $feature_flag_called #1149 makes, so the$feature_flag_calledevent of a local snapshot read also carries$feature_flag_version. This branch and fix: send flag id and version on locally evaluated $feature_flag_called #1149 merge without conflicts.error_codeanderror_messageonFeatureFlag, including the cause for a flag local evaluation could not resolve.valueandfailedonFeatureFlag, andconfig_version,rule_type,rule_id,variant_keyandforced_variantonFlagMetadata.This stacks on #1031 (base
feat/flags-v3-response) and waits for the cross-SDK accessor spec in PostHog/sdk-specs#104. Names and semantics may change to match it.This branch includes #1148 (merged from
feat/snapshot-flag-event-option), because the typed accessors use its_record_access(key, send_feature_flag_events). Until #1148 lands onmainandmainis merged into #1031, #1148's diff (the option onis_enabled()andget_flag(), its tests and its changeset) also shows here. Review that part on #1148.Testing
test_flags_v4_response.py, for each of the 12 typed methods on the sync and the async client:send_feature_flag_events=False, the read returns the flag's value, sends no$feature_flag_calledand marks the key inonly_accessed(). A later read with the default then sends exactly one event.send_feature_flag_events=Truesends no second one.TYPE_MISMATCH, sends no$feature_flag_called, still marks the key inonly_accessed(), and logs itserror_messageonce although read twice. A later right-type read (value and details form),get_flag()oris_enabled()then sends exactly one event, on both clients. Two wrong types on one key log twice, a second snapshot from the same client logs nothing new, a read withsend_feature_flag_events=Falselogs nothing new, and another client logs again. Anullorfalsevalue read as another type, and a missing, failed or unreadable flag, still send their event and log nothing.int, and at ±253 and ±(253 + 1) thefloat±253. The integer accessor returnsTYPE_MISMATCHat ±253 and ±(253 + 1), and the float accessor returns the nearestfloatat all six. ±10**400 reads as ±infinity through the number and float accessors.test_locally_evaluated_details_carry_the_definition_version: local boolean (value and details forms) and multivariate details carry the definition'sversion, a definition without one leaves it out, and the$feature_flag_calledevent reports the same version. The test fails without either half of the change.send_feature_flag_events=Falseon a v2 experiment split sends neither$feature_flag_callednor$experiment_exposure, as docs(flags): specify SDK-sent $experiment_exposure for config version 2 flags sdk-specs#162 asks. A later default read sends each event once. This holds for all 12 methods on both clients. The flag test modules also pass on the combined tree.$feature_flag_callednor$experiment_exposure, for each typed method on both clients, and the next right-type orget_flag()/is_enabled()read sends both. The flag test modules pass on the combined tree, which merges without conflicts.pytest posthog/test: 6210 passed, 25 skipped, 3 failed. The 3 failures are the forked-child tests intest_metrics_autocapture.py, a macOS fork-safety crash. They also fail onmainin a full run and pass when that file runs alone.ruff format --check,ruff check, mypy (baseline filter) and the public API snapshot check pass.🤖 Generated with Claude Code