Skip to content

Commit 3df7ae7

Browse files
Merge pull request #25 from splitio/fme-12896
Add events metadata
2 parents 4efc6de + a3ec602 commit 3df7ae7

File tree

6 files changed

+499
-9
lines changed

6 files changed

+499
-9
lines changed

CHANGES.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
CHANGES
22

3+
1.1.0 (Feb 27 2026)
4+
- Split SDK 10.5.1 remains supported. Provider lifecycle events (PROVIDER_READY, PROVIDER_CONFIGURATION_CHANGED, PROVIDER_ERROR) require Split SDK 10.6.0 or later; on 10.5.1 the provider works as before without emitting those events.
5+
- Provider now emits OpenFeature provider events (PROVIDER_READY, PROVIDER_CONFIGURATION_CHANGED, PROVIDER_ERROR) when Split SDK 10.6+ fires ready/update/timeout. Event details include OpenFeature-friendly metadata (see docs/EVENTS_MAPPING.md).
6+
37
1.0.0 (Nov 10 2025)
48
- BREAKING CHANGE: Passing the SplitClient object to Provider constructor is now only through the initialization context dictionary
59
- BREAKING CHANGE: Provider will throw exception when ObjectDetail and ObjectValue evaluation is used, since it will attempt to parse the treatment as a JSON structure.

README.md

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@
55
This Provider is designed to allow the use of OpenFeature with Split, the platform for controlled rollouts, serving features to your users via the Split feature flag to manage your complete customer experience.
66

77
## Compatibility
8-
This SDK is compatible with Python 3.9 and higher.
8+
- Python 3.9 and higher.
9+
- **Split SDK**: [Split Python SDK](https://github.com/splitio/python-client) **10.5.1 or later**. Provider lifecycle events (PROVIDER_READY, PROVIDER_CONFIGURATION_CHANGED, PROVIDER_ERROR) require **10.6.0 or later**; on 10.5.1 the provider works without emitting those events.
910

1011
## Getting started
1112

1213
This package replaces the previous `split-openfeature-provider` Python provider in [Pypi](https://pypi.org/project/split-openfeature-provider/).
1314

1415
### Pip Installation
1516
```python
16-
pip install split-openfeature-provider==1.0.0
17+
pip install split-openfeature-provider==1.1.0
1718
```
1819
### Configure it
1920
Below is a simple example that describes using the Split Provider. Please see the [OpenFeature Documentation](https://docs.openfeature.dev/docs/reference/concepts/evaluation-api) for details on how to use the OpenFeature SDK.
@@ -22,7 +23,7 @@ Below is a simple example that describes using the Split Provider. Please see th
2223
from openfeature import api
2324
from split_openfeature_provider import SplitProvider
2425
config = {
25-
'impressionsMode': 'OPTIMIZED',
26+
'impressionsMode': 'optimized',
2627
'impressionsRefreshRate': 30,
2728
}
2829
provider = SplitProvider({"SdkKey": "YOUR_API_KEY", "ConfigOptions": config, "ReadyBlockTime": 5})
@@ -36,14 +37,42 @@ from split_openfeature_provider import SplitProvider
3637
from splitio import get_factory
3738

3839
config = {
39-
'impressionsMode': 'OPTIMIZED',
40+
'impressionsMode': 'optimized',
4041
'impressionsRefreshRate': 30,
4142
}
4243
factory = get_factory("YOUR_API_KEY", config=config)
4344
factory.block_until_ready(5)
4445
api.set_provider(SplitProvider({"SplitClient": factory.client()}))
4546
```
4647

48+
## Example
49+
50+
A minimal end-to-end example (sync): initialize the provider, set a targeting context, and evaluate a flag.
51+
52+
```python
53+
from openfeature import api
54+
from openfeature.evaluation_context import EvaluationContext
55+
from split_openfeature_provider import SplitProvider
56+
57+
# Initialize with your SDK key (or use "localhost" + splitFile for local YAML)
58+
provider = SplitProvider({
59+
"SdkKey": "YOUR_API_KEY",
60+
"ConfigOptions": {"impressionsMode": "optimized"},
61+
"ReadyBlockTime": 5,
62+
})
63+
api.set_provider(provider)
64+
65+
# Get a client and set targeting context
66+
client = api.get_client()
67+
client.context = EvaluationContext(targeting_key="user-123")
68+
69+
# Evaluate flags
70+
show_new_ui = client.get_boolean_value("my_feature_flag", False)
71+
print("show_new_ui:", show_new_ui)
72+
```
73+
74+
With **asyncio**, use `SplitProviderAsync`, `await provider.create()`, and `await client.get_boolean_value_async(...)` as shown in the Asyncio mode section below.
75+
4776
## Use of OpenFeature with Split
4877
After the initial setup you can use OpenFeature according to their [documentation](https://docs.openfeature.dev/docs/reference/concepts/evaluation-api/).
4978

@@ -77,10 +106,10 @@ Example below shows using the provider in asyncio
77106
from openfeature import api
78107
from split_openfeature_provider import SplitProviderAsync
79108
config = {
80-
'impressionsMode': 'OPTIMIZED',
109+
'impressionsMode': 'optimized',
81110
'impressionsRefreshRate': 30,
82111
}
83-
provider = SplitProvider({"SdkKey": "YOUR_API_KEY", "ConfigOptions": config, "ReadyBlockTime": 5})
112+
provider = SplitProviderAsync({"SdkKey": "YOUR_API_KEY", "ConfigOptions": config, "ReadyBlockTime": 5})
84113
await provider.create()
85114
api.set_provider(provider)
86115
```
@@ -92,7 +121,7 @@ from split_openfeature_provider import SplitProviderAsync
92121
from splitio import get_factory_async
93122

94123
config = {
95-
'impressionsMode': 'OPTIMIZED',
124+
'impressionsMode': 'optimized',
96125
'impressionsRefreshRate': 30,
97126
}
98127
factory = get_factory_async("YOUR_API_KEY", config=config)

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
openfeature_sdk==0.8.3
2-
splitio_client[cpphash,asyncio]==10.5.1
2+
splitio_client[cpphash,asyncio]>=10.5.1

split_openfeature_provider/split_client_wrapper.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@
22
from splitio.exceptions import TimeoutException
33
import logging
44

5+
try:
6+
from splitio.models.events import SdkEvent
7+
except ImportError:
8+
SdkEvent = None # type: ignore # Split < 10.6: no events API
9+
510
_LOGGER = logging.getLogger(__name__)
611

12+
# Sentinel for block_until_ready timeout (not a Split SdkEvent)
13+
SPLIT_EVENT_BUR_TIMEOUT = "block_until_ready_timeout"
14+
15+
716
class SplitClientWrapper():
817

918
def __init__(self, initial_context):
1019
self.sdk_ready = False
1120
self.split_client = None
21+
self._event_receiver = None
1222

1323
if not self._validate_context(initial_context):
1424
raise AttributeError()
@@ -39,13 +49,15 @@ def __init__(self, initial_context):
3949
self.sdk_ready = True
4050
except TimeoutException:
4151
_LOGGER.debug("Split SDK timed out")
52+
self._notify_receiver(SPLIT_EVENT_BUR_TIMEOUT, None)
4253

4354
self.split_client = self._factory.client()
4455

4556
async def create(self):
4657
if self._initial_context.get("SplitClient") != None:
4758
self.split_client = self._initial_context.get("SplitClient")
4859
self._factory = self.split_client._factory
60+
await self._register_split_events_async()
4961
return
5062

5163
try:
@@ -54,8 +66,10 @@ async def create(self):
5466
self.sdk_ready = True
5567
except TimeoutException:
5668
_LOGGER.debug("Split SDK timed out")
69+
await self._notify_receiver_async(SPLIT_EVENT_BUR_TIMEOUT, None)
5770

5871
self.split_client = self._factory.client()
72+
await self._register_split_events_async()
5973

6074
def is_sdk_ready(self):
6175
if self.sdk_ready:
@@ -69,9 +83,73 @@ def is_sdk_ready(self):
6983

7084
return self.sdk_ready
7185

86+
def set_event_receiver(self, receiver):
87+
"""Set the receiver that will be notified of Split SDK events (e.g. the provider)."""
88+
self._event_receiver = receiver
89+
90+
def register_for_split_events(self):
91+
"""Register for Split SDK events (SDK_READY, SDK_UPDATE). Pass the provider as receiver (or call set_event_receiver first)."""
92+
self._register_split_events()
93+
94+
def unregister_for_split_events(self):
95+
"""Stop receiving Split SDK events."""
96+
self._event_receiver = None
97+
98+
def _notify_receiver(self, split_event, event_metadata):
99+
if self._event_receiver is None:
100+
_LOGGER.debug("Split event %s: no receiver registered", split_event)
101+
return
102+
try:
103+
self._event_receiver._on_split_event(split_event, event_metadata)
104+
except Exception as ex:
105+
_LOGGER.debug("Split event callback error: %s", ex)
106+
107+
async def _notify_receiver_async(self, split_event, event_metadata):
108+
"""Async version for use when the receiver is used in asyncio context (e.g. async event registration)."""
109+
if self._event_receiver is None:
110+
_LOGGER.debug("Split event %s: no receiver registered", split_event)
111+
return
112+
try:
113+
await self._event_receiver._on_split_event_async(split_event, event_metadata)
114+
except Exception as ex:
115+
_LOGGER.debug("Split event callback error: %s", ex)
116+
117+
def _register_split_events(self):
118+
if self._factory is None:
119+
_LOGGER.warning("SplitClientWrapper: _factory is None, cannot register for SDK events")
120+
return
121+
if SdkEvent is None:
122+
_LOGGER.debug("SplitClientWrapper: SdkEvent not available (Split SDK < 10.6?), skipping event registration")
123+
return
124+
try:
125+
em = self._factory._events_manager
126+
if not hasattr(em, "register"):
127+
_LOGGER.warning("SplitClientWrapper: events_manager has no register method")
128+
return
129+
em.register(SdkEvent.SDK_READY, lambda m: self._notify_receiver(SdkEvent.SDK_READY, m))
130+
em.register(SdkEvent.SDK_UPDATE, lambda m: self._notify_receiver(SdkEvent.SDK_UPDATE, m))
131+
_LOGGER.info("SplitClientWrapper: registered for SDK_READY and SDK_UPDATE")
132+
except Exception as ex:
133+
_LOGGER.warning("Could not register Split events: %s", ex)
134+
72135
def destroy(self, destroy_event=None):
73136
self._factory.destroy(destroy_event)
74137

138+
async def _register_split_events_async(self):
139+
if self._factory is None or SdkEvent is None:
140+
return
141+
try:
142+
em = self._factory._events_manager
143+
if hasattr(em, "register"):
144+
async def handler_ready(m):
145+
await self._notify_receiver_async(SdkEvent.SDK_READY, m)
146+
async def handler_update(m):
147+
await self._notify_receiver_async(SdkEvent.SDK_UPDATE, m)
148+
await em.register(SdkEvent.SDK_READY, handler_ready)
149+
await em.register(SdkEvent.SDK_UPDATE, handler_update)
150+
except Exception as ex:
151+
_LOGGER.debug("Could not register Split events: %s", ex)
152+
75153
async def destroy_async(self):
76154
await self._factory.destroy()
77155

split_openfeature_provider/split_provider.py

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,120 @@
77
from openfeature.exception import ErrorCode, GeneralError, ParseError, OpenFeatureError, TargetingKeyMissingError
88
from openfeature.flag_evaluation import Reason, FlagResolutionDetails
99
from openfeature.provider import AbstractProvider, Metadata
10-
from split_openfeature_provider.split_client_wrapper import SplitClientWrapper
10+
from openfeature.event import ProviderEventDetails
11+
from split_openfeature_provider.split_client_wrapper import SplitClientWrapper, SPLIT_EVENT_BUR_TIMEOUT
1112

1213
_LOGGER = logging.getLogger(__name__)
1314

15+
try:
16+
from splitio.models.events import SdkEvent
17+
except ImportError:
18+
SdkEvent = None # type: ignore
19+
20+
21+
def _flags_changed_from_sdk_update(event_metadata):
22+
"""
23+
Extract list of updated flag/split names from Split SDK_UPDATE event metadata.
24+
OpenFeature expects flags_changed: list[str] for PROVIDER_CONFIGURATION_CHANGED.
25+
Handles: dict with "names", object with .metadata, or object with get_names() (Split EventsMetadata).
26+
"""
27+
if event_metadata is None:
28+
return None
29+
if hasattr(event_metadata, "metadata") and getattr(event_metadata, "metadata", None) is not None:
30+
event_metadata = getattr(event_metadata, "metadata")
31+
if isinstance(event_metadata, dict):
32+
val = event_metadata.get("names")
33+
if isinstance(val, list):
34+
return [str(x) for x in val if x is not None]
35+
return None
36+
if hasattr(event_metadata, "get_names"):
37+
names = event_metadata.get_names()
38+
if names is not None:
39+
return [str(x) for x in names if x is not None]
40+
return None
41+
42+
43+
def _metadata_from_split(split_event, event_metadata):
44+
"""Build OpenFeature event metadata dict from Split event (and optional Split metadata)."""
45+
meta = {"split_event": getattr(split_event, "value", str(split_event))}
46+
if event_metadata is not None and isinstance(event_metadata, dict):
47+
for k, v in event_metadata.items():
48+
if isinstance(v, (bool, str, int, float)):
49+
meta["split_%s" % k] = v
50+
# Split may pass an object with get_type/get_names (e.g. EventsMetadata)
51+
if event_metadata is not None and hasattr(event_metadata, "get_type"):
52+
t = event_metadata.get_type()
53+
meta["split_type"] = getattr(t, "value", str(t))
54+
if event_metadata is not None and hasattr(event_metadata, "get_names"):
55+
names = event_metadata.get_names()
56+
meta["split_names"] = list(names) if names is not None else []
57+
return meta
58+
59+
1460
class SplitProviderBase(AbstractProvider):
1561

1662
def get_metadata(self) -> Metadata:
1763
return Metadata("Split")
1864

65+
def attach(self, on_emit):
66+
super().attach(on_emit)
67+
self._split_client_wrapper.set_event_receiver(self)
68+
self._split_client_wrapper.register_for_split_events()
69+
70+
def detach(self):
71+
self._split_client_wrapper.unregister_for_split_events()
72+
super().detach()
73+
74+
def _on_split_event(self, split_event, event_metadata):
75+
"""Map Split SDK events to OpenFeature provider events with OpenFeature-friendly details."""
76+
_LOGGER.debug("SplitProvider: _on_split_event received %s", split_event)
77+
if split_event == SPLIT_EVENT_BUR_TIMEOUT:
78+
self.emit_provider_error(ProviderEventDetails(
79+
message="Block until ready timed out",
80+
error_code=ErrorCode.PROVIDER_NOT_READY,
81+
metadata=_metadata_from_split(split_event, event_metadata),
82+
))
83+
return
84+
if SdkEvent is None:
85+
return
86+
if split_event == SdkEvent.SDK_READY:
87+
self.emit_provider_ready(ProviderEventDetails(
88+
metadata=_metadata_from_split(split_event, event_metadata),
89+
))
90+
elif split_event == SdkEvent.SDK_UPDATE:
91+
flags_changed = _flags_changed_from_sdk_update(event_metadata)
92+
details = ProviderEventDetails(
93+
flags_changed=flags_changed,
94+
metadata=_metadata_from_split(split_event, event_metadata),
95+
)
96+
_LOGGER.info("SplitProvider: emitting PROVIDER_CONFIGURATION_CHANGED flags_changed=%s", flags_changed)
97+
self.emit_provider_configuration_changed(details)
98+
99+
async def _on_split_event_async(self, split_event, event_metadata):
100+
"""Async version for asyncio path; same logic as _on_split_event (emit_* are sync)."""
101+
_LOGGER.debug("SplitProvider: _on_split_event_async received %s", split_event)
102+
if split_event == SPLIT_EVENT_BUR_TIMEOUT:
103+
self.emit_provider_error(ProviderEventDetails(
104+
message="Block until ready timed out",
105+
error_code=ErrorCode.PROVIDER_NOT_READY,
106+
metadata=_metadata_from_split(split_event, event_metadata),
107+
))
108+
return
109+
if SdkEvent is None:
110+
return
111+
if split_event == SdkEvent.SDK_READY:
112+
self.emit_provider_ready(ProviderEventDetails(
113+
metadata=_metadata_from_split(split_event, event_metadata),
114+
))
115+
elif split_event == SdkEvent.SDK_UPDATE:
116+
flags_changed = _flags_changed_from_sdk_update(event_metadata)
117+
details = ProviderEventDetails(
118+
flags_changed=flags_changed,
119+
metadata=_metadata_from_split(split_event, event_metadata),
120+
)
121+
_LOGGER.info("SplitProvider: emitting PROVIDER_CONFIGURATION_CHANGED flags_changed=%s", flags_changed)
122+
self.emit_provider_configuration_changed(details)
123+
19124
def get_provider_hooks(self) -> typing.List[Hook]:
20125
return []
21126

0 commit comments

Comments
 (0)