Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,22 @@ jobs:
# the lockfile's mcp 1.x (also exercised incidentally by the `tests`
# matrix β€” this leg exists as an explicit, named signal); the v2 leg
# (spec 2026-07-28) swaps in mcp>=2 and standalone FastMCP 4, which uses
# the v2 registry. posthog/test/mcp/conftest.py splits collection by major.
# the v2 registry. The v2-bare leg runs mcp>=2 without FastMCP: importing
# FastMCP 4 patches camelCase properties onto mcp_types models for the
# whole process, which hides reads that plain mcp 2.x rejects.
# posthog/test/mcp/conftest.py splits collection by major.
name: MCP SDK ${{ matrix.mcp-major }} (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
python-version: ['3.10', '3.14']
mcp-major: ['v1', 'v2']
mcp-major: ['v1', 'v2', 'v2-bare']
include:
- mcp-major: 'v2'
mcp-packages: "'mcp>=2,<3' 'fastmcp>=4,<5'"
- mcp-major: 'v2-bare'
mcp-packages: "'mcp>=2,<3'"

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -187,11 +195,11 @@ jobs:
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra test

- name: Swap in MCP SDK v2
if: matrix.mcp-major == 'v2'
if: matrix.mcp-major != 'v1'
shell: bash
run: |
uv pip uninstall --python "$pythonLocation" fastmcp
uv pip install --python "$pythonLocation" 'mcp>=2,<3' 'fastmcp>=4,<5'
uv pip install --python "$pythonLocation" ${{ matrix.mcp-packages }}

- name: Run MCP tests against SDK ${{ matrix.mcp-major }}
run: |
Expand Down
12 changes: 12 additions & 0 deletions openfeature-provider/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to `openfeature-provider-posthog` are documented here. This
file is maintained by [Sampo](https://github.com/bruits/sampo) from changesets in
`.sampo/changesets/` that target `pypi/openfeature-provider-posthog`.

## 0.1.142 β€” 2026-11-03

### Patch changes

- Updated dependencies: [email protected]

## 0.1.141 β€” 2026-11-03

### Patch changes

- Updated dependencies: [email protected]

## 0.1.140 β€” 2026-11-02

### Patch changes
Expand Down
2 changes: 1 addition & 1 deletion openfeature-provider/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "openfeature-provider-posthog"
version = "0.1.140"
version = "0.1.142"
description = "Official PostHog provider for the OpenFeature Python SDK."
readme = "README.md"
authors = [{ name = "PostHog", email = "[email protected]" }]
Expand Down
12 changes: 12 additions & 0 deletions posthog/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# posthog

## 7.83.1 β€” 2026-11-03

### Patch changes

- [0c12ca1](https://github.com/posthog/posthog-python/commit/0c12ca1521f6538873e00f5240cad306c9bba455) `PostHogMCP.prepare_tool_list` now adds the `context` argument to MCP SDK 2.x `Tool` objects. It only added it to dicts and 1.x `Tool` objects before, so `$mcp_intent` was never captured for 2.x tool objects. β€” Thanks @connor-grady!

## 7.83.0 β€” 2026-11-03

### Minor changes

- [e094dc5](https://github.com/posthog/posthog-python/commit/e094dc59522b8d7d7b68a74cac4259a61daef60f) Request `/flags?v=4` and read each flag's typed value, reason and metadata. They stay internal for now: this release adds no public methods, fields or types for them, and `repr`, equality and `dataclasses.asdict` of a flag show only its existing fields. Servers that still send the older response keep working, and a flag record with a field of the wrong JSON type fails only that flag. For valid flag records, existing getters, callbacks and `$feature_flag_called` properties return the same values as before: a number or object value reads as `True`, with the value as its payload. Two things can differ: the human-readable reason text of a config version 2 flag is the one the server sends at v4, and a record with a known field of the wrong JSON type (for example a string `reason`) now reads as a failed flag instead of being read anyway or failing the whole response. The SDK logs once per client when a server sends a response without typed values. `FlagMetadata.id` and `FlagMetadata.version` are `None` when a record has none, instead of `0`, so their type is now `Optional[int]`. β€” Thanks @andehen!

## 7.82.2 β€” 2026-11-02

### Patch changes
Expand Down
2 changes: 1 addition & 1 deletion posthog/_async_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ async def async_flags(
_serialize_flags_body, api_key, request_data
)
response = await http_client.post(
"/flags/?v=2", content=data, headers=headers, timeout=timeout
"/flags/?v=4", content=data, headers=headers, timeout=timeout
)
return await asyncio.to_thread(_process_flags_response, response)
except httpx_module.TransportError:
Expand Down
36 changes: 15 additions & 21 deletions posthog/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,6 @@
MAX_DICT_SIZE as _MAX_DICT_SIZE,
_MINIMAL_FLAG_CALLED_EVENT_PROPERTIES,
Client as _SyncClient,
_metadata_has_experiment,
_parse_flag_payload,
_add_context_session_id,
_context_tag_defaults,
_explicit_distinct_id,
Expand Down Expand Up @@ -94,11 +92,17 @@
from .feature_flag_evaluations import (
FeatureFlagEvaluations,
_EvaluatedFlagRecord,
_RemoteFlagRecord,
_FeatureFlagEvaluationsHost,
)
from .release_id import _resolve_release_id
from .request import QuotaLimitError, determine_server_host, normalize_host
from .types import FlagMetadata, FlagValue, normalize_flags_response
from .types import (
FlagValue,
_has_untyped_flag_records,
_UNTYPED_FLAGS_RESPONSE_MESSAGE,
normalize_flags_response,
)
from .utils import (
SizeLimitedDict,
_is_personal_api_key,
Expand Down Expand Up @@ -289,6 +293,8 @@ def __init__(
self.log.setLevel(logging.DEBUG)
else:
self.log.setLevel(logging.WARNING)
# Whether this client has logged that /flags sent records without typed values.
self._logged_untyped_flags_response = False

if not self.api_key:
self.log.error(
Expand Down Expand Up @@ -1107,6 +1113,11 @@ async def _get_flags_decision(
client=self._get_http_client(),
**request_data,
)
if not self._logged_untyped_flags_response and _has_untyped_flag_records(
response
):
self._logged_untyped_flags_response = True
self.log.info(_UNTYPED_FLAGS_RESPONSE_MESSAGE)
return normalize_flags_response(response)

async def evaluate_flags(
Expand Down Expand Up @@ -1184,24 +1195,7 @@ async def evaluate_flags(
for key, detail in response.get("flags", {}).items():
if requested_keys is not None and key not in requested_keys:
continue
metadata = detail.metadata
records[key] = _EvaluatedFlagRecord(
key=key,
enabled=detail.enabled,
variant=detail.variant,
payload=_parse_flag_payload(metadata.payload),
id=metadata.id if isinstance(metadata, FlagMetadata) else None,
version=(
metadata.version if isinstance(metadata, FlagMetadata) else None
),
reason=(
detail.reason.description
if detail.reason and detail.reason.description
else None
),
locally_evaluated=False,
has_experiment=_metadata_has_experiment(metadata),
)
records[key] = _RemoteFlagRecord(detail)
except QuotaLimitError:
self.log.warning(
"[FEATURE FLAGS] Feature flag evaluation was quota limited"
Expand Down
58 changes: 28 additions & 30 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@
)
from posthog.feature_flag_evaluations import (
FeatureFlagEvaluations,
_LocalFlagRecord,
_EvaluatedFlagRecord,
_RemoteFlagRecord,
_FeatureFlagEvaluationsHost,
)
from posthog.feature_flags import (
Expand Down Expand Up @@ -135,6 +137,8 @@
FlagValue,
SendFeatureFlagsOptions,
UnresolvedFlagReason,
_has_untyped_flag_records,
_UNTYPED_FLAGS_RESPONSE_MESSAGE,
normalize_flags_response,
to_flags_and_payloads,
to_payloads,
Expand Down Expand Up @@ -1122,6 +1126,8 @@ def __init__(
# the /flags v2 response and the local-evaluation payload. False until the
# server reports it, so full events are the fail-safe.
self._minimal_flag_called_events: bool = False
# Whether this client has logged that /flags sent records without typed values.
self._logged_untyped_flags_response = False

self.capture_trace_context = capture_trace_context
self.capture_exception_code_variables = capture_exception_code_variables
Expand Down Expand Up @@ -1837,6 +1843,11 @@ def _get_flags_decision(
**request_data,
)

if not self._logged_untyped_flags_response and _has_untyped_flag_records(
resp_data
):
self._logged_untyped_flags_response = True
self.log.info(_UNTYPED_FLAGS_RESPONSE_MESSAGE)
response = normalize_flags_response(resp_data)
# Server-controlled gate for minimal $feature_flag_called events. Only the
# v2 response shape carries it; absent (legacy shape, older server, team
Expand Down Expand Up @@ -4376,9 +4387,23 @@ def _get_feature_flag_result(
flag_details, override_match_value
)

if (
flag_details is not None
and flag_details._malformed_field is not None
):
# The record could not be read, so it reads as disabled. It must
# not replace the last good cached value, which it falls back to
# like a failed request, and it reports what a failed request
# reports: an unknown error and no request details.
feature_flag_error = FeatureFlagError.UNKNOWN_ERROR
request_id = None
evaluated_at = None
flag_result = (
self._get_stale_flag_fallback(distinct_id, key) or flag_result
)
# The request-start generation is an invalidation boundary, not
# a claim about the server's definitions. Refresh rejects late writes.
if self.flag_cache and flag_result:
elif self.flag_cache and flag_result:
self.flag_cache._set_cached_remote_flag(
distinct_id, key, flag_result, local_definition_version
)
Expand Down Expand Up @@ -5189,7 +5214,7 @@ def evaluate_flags(

for key, value in local_flags.items():
flag_def = feature_flags_by_key.get(key) or {}
records[key] = _EvaluatedFlagRecord(
records[key] = _LocalFlagRecord(
key=key,
enabled=value is not False,
variant=value if isinstance(value, str) else None,
Expand Down Expand Up @@ -5232,34 +5257,7 @@ def evaluate_flags(
continue
if key in locally_evaluated_keys:
continue
payload = _parse_flag_payload(
detail.metadata.payload
if isinstance(detail.metadata, FlagMetadata)
else getattr(detail.metadata, "payload", None)
)
records[key] = _EvaluatedFlagRecord(
key=key,
enabled=detail.enabled,
variant=detail.variant,
payload=payload,
id=(
detail.metadata.id
if isinstance(detail.metadata, FlagMetadata)
else None
),
version=(
detail.metadata.version
if isinstance(detail.metadata, FlagMetadata)
else None
),
reason=(
detail.reason.description
if detail.reason and detail.reason.description
else None
),
locally_evaluated=False,
has_experiment=_metadata_has_experiment(detail.metadata),
)
records[key] = _RemoteFlagRecord(detail)
except QuotaLimitError as e:
self.log.warning(f"[FEATURE FLAGS] Quota limit exceeded: {e}")
quota_limited = True
Expand Down
72 changes: 69 additions & 3 deletions posthog/feature_flag_evaluations.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
from types import MappingProxyType
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union

from posthog.types import FlagValue, UnresolvedFlagReason
from .types import FeatureFlag as _FeatureFlag
from .types import FlagMetadata as _FlagMetadata
from .types import FlagValue, UnresolvedFlagReason, _parse_flag_payload


@dataclass(frozen=True)
class _EvaluatedFlagRecord:
"""Internal per-flag record stored by a FeatureFlagEvaluations instance."""
class _LocalFlagRecord:
"""Internal per-flag record of a locally evaluated flag."""

key: str
enabled: bool
Expand All @@ -29,6 +31,70 @@ class _EvaluatedFlagRecord:
has_experiment: Optional[bool] = None


_UNDECODED: Any = object()


class _RemoteFlagRecord:
"""Internal per-flag record of a remotely evaluated flag.

The parsed ``/flags`` record is the only copy of the flag: the record fields are
read from it, and the payload is decoded on its first read and then kept.
"""

__slots__ = ("details", "_payload")

locally_evaluated = False

def __init__(self, details: _FeatureFlag) -> None:
# The parsed /flags record, with its typed value, reason and metadata.
self.details = details
self._payload: Any = _UNDECODED

@property
def key(self) -> str:
return self.details.key

@property
def enabled(self) -> bool:
return self.details.enabled

@property
def variant(self) -> Optional[str]:
return self.details.variant

@property
def payload(self) -> Optional[Any]:
if self._payload is _UNDECODED:
self._payload = _parse_flag_payload(
getattr(self.details.metadata, "payload", None)
)
return self._payload

@property
def id(self) -> Optional[int]:
metadata = self.details.metadata
return metadata.id if isinstance(metadata, _FlagMetadata) else None

@property
def version(self) -> Optional[int]:
metadata = self.details.metadata
return metadata.version if isinstance(metadata, _FlagMetadata) else None

@property
def reason(self) -> Optional[str]:
reason = self.details.reason
return reason.description if reason and reason.description else None

@property
def has_experiment(self) -> Optional[bool]:
metadata = self.details.metadata
return metadata.has_experiment if isinstance(metadata, _FlagMetadata) else None


# The per-flag record stored by a FeatureFlagEvaluations instance.
_EvaluatedFlagRecord = Union[_LocalFlagRecord, _RemoteFlagRecord]


@dataclass
class _FeatureFlagEvaluationsHost:
"""Callbacks the evaluations object uses to talk back to the client.
Expand Down
4 changes: 2 additions & 2 deletions posthog/mcp/posthog_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -771,10 +771,10 @@ def _inject_context(self, tool: Any, description: Optional[str]) -> Any:
if self._is_sdk_virtual_tool(tool):
return tool
new_schema = add_context_parameter_to_schema(
getattr(tool, "inputSchema", None), name, description
_tool_schema(tool), name, description
)
try:
tool.inputSchema = new_schema
_set_tool_schema(tool, new_schema)
except Exception: # noqa: BLE001
pass
return tool
Expand Down
2 changes: 1 addition & 1 deletion posthog/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ def flags(
res = post(
api_key,
host,
"/flags/?v=2",
"/flags/?v=4",
timeout=timeout,
session=_get_flags_session(),
**kwargs,
Expand Down
Loading
Loading