posthog 7.0.1__py3-none-any.whl → 7.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- posthog/__init__.py +10 -0
- posthog/ai/gemini/__init__.py +3 -0
- posthog/ai/gemini/gemini.py +1 -1
- posthog/ai/gemini/gemini_async.py +423 -0
- posthog/ai/gemini/gemini_converter.py +87 -21
- posthog/ai/openai/openai_converter.py +6 -0
- posthog/ai/sanitization.py +27 -5
- posthog/client.py +213 -47
- posthog/exception_utils.py +49 -4
- posthog/flag_definition_cache.py +127 -0
- posthog/request.py +152 -21
- posthog/test/test_client.py +121 -21
- posthog/test/test_exception_capture.py +45 -1
- posthog/test/test_feature_flag_result.py +441 -2
- posthog/test/test_feature_flags.py +157 -18
- posthog/test/test_flag_definition_cache.py +612 -0
- posthog/test/test_request.py +265 -0
- posthog/test/test_utils.py +4 -1
- posthog/types.py +40 -0
- posthog/version.py +1 -1
- {posthog-7.0.1.dist-info → posthog-7.3.1.dist-info}/METADATA +2 -1
- {posthog-7.0.1.dist-info → posthog-7.3.1.dist-info}/RECORD +25 -22
- {posthog-7.0.1.dist-info → posthog-7.3.1.dist-info}/WHEEL +0 -0
- {posthog-7.0.1.dist-info → posthog-7.3.1.dist-info}/licenses/LICENSE +0 -0
- {posthog-7.0.1.dist-info → posthog-7.3.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flag Definition Cache Provider interface for multi-worker environments.
|
|
3
|
+
|
|
4
|
+
EXPERIMENTAL: This API may change in future minor version bumps.
|
|
5
|
+
|
|
6
|
+
This module provides an interface for external caching of feature flag definitions,
|
|
7
|
+
enabling multi-worker environments (Kubernetes, load-balanced servers, serverless
|
|
8
|
+
functions) to share flag definitions and reduce API calls.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
|
|
12
|
+
from posthog import Posthog
|
|
13
|
+
from posthog.flag_definition_cache import FlagDefinitionCacheProvider
|
|
14
|
+
|
|
15
|
+
cache = RedisFlagDefinitionCache(redis_client, "my-team")
|
|
16
|
+
posthog = Posthog(
|
|
17
|
+
"<project_api_key>",
|
|
18
|
+
personal_api_key="<personal_api_key>",
|
|
19
|
+
flag_definition_cache_provider=cache,
|
|
20
|
+
)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
|
24
|
+
|
|
25
|
+
from typing_extensions import Required, TypedDict
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FlagDefinitionCacheData(TypedDict):
|
|
29
|
+
"""
|
|
30
|
+
Data structure for cached flag definitions.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
flags: List of feature flag definition dictionaries from the API.
|
|
34
|
+
group_type_mapping: Mapping of group type indices to group names.
|
|
35
|
+
cohorts: Dictionary of cohort definitions for local evaluation.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
flags: Required[List[Dict[str, Any]]]
|
|
39
|
+
group_type_mapping: Required[Dict[str, str]]
|
|
40
|
+
cohorts: Required[Dict[str, Any]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@runtime_checkable
|
|
44
|
+
class FlagDefinitionCacheProvider(Protocol):
|
|
45
|
+
"""
|
|
46
|
+
Interface for external caching of feature flag definitions.
|
|
47
|
+
|
|
48
|
+
Enables multi-worker environments to share flag definitions, reducing API
|
|
49
|
+
calls while ensuring all workers have consistent data.
|
|
50
|
+
|
|
51
|
+
EXPERIMENTAL: This API may change in future minor version bumps.
|
|
52
|
+
|
|
53
|
+
The four methods handle the complete lifecycle of flag definition caching:
|
|
54
|
+
|
|
55
|
+
1. `should_fetch_flag_definitions()` - Called before each poll to determine
|
|
56
|
+
if this worker should fetch new definitions. Use for distributed lock
|
|
57
|
+
coordination to ensure only one worker fetches at a time.
|
|
58
|
+
|
|
59
|
+
2. `get_flag_definitions()` - Called when `should_fetch_flag_definitions()`
|
|
60
|
+
returns False. Returns cached definitions if available.
|
|
61
|
+
|
|
62
|
+
3. `on_flag_definitions_received()` - Called after successfully fetching
|
|
63
|
+
new definitions from the API. Store the data in your external cache
|
|
64
|
+
and release any locks.
|
|
65
|
+
|
|
66
|
+
4. `shutdown()` - Called when the PostHog client shuts down. Release any
|
|
67
|
+
distributed locks and clean up resources.
|
|
68
|
+
|
|
69
|
+
Error Handling:
|
|
70
|
+
All methods are wrapped in try/except. Errors will be logged but will
|
|
71
|
+
never break flag evaluation. On error:
|
|
72
|
+
- `should_fetch_flag_definitions()` errors default to fetching (fail-safe)
|
|
73
|
+
- `get_flag_definitions()` errors fall back to API fetch
|
|
74
|
+
- `on_flag_definitions_received()` errors are logged but flags remain in memory
|
|
75
|
+
- `shutdown()` errors are logged but shutdown continues
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
|
|
79
|
+
"""
|
|
80
|
+
Retrieve cached flag definitions.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Cached flag definitions if available and valid, None otherwise.
|
|
84
|
+
Returning None will trigger a fetch from the API if this worker
|
|
85
|
+
has no flags loaded yet.
|
|
86
|
+
"""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
def should_fetch_flag_definitions(self) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
Determine whether this instance should fetch new flag definitions.
|
|
92
|
+
|
|
93
|
+
Use this for distributed lock coordination. Only one worker should
|
|
94
|
+
return True to avoid thundering herd problems. A typical implementation
|
|
95
|
+
uses a distributed lock (e.g., Redis SETNX) that expires after the
|
|
96
|
+
poll interval.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True if this instance should fetch from the API, False otherwise.
|
|
100
|
+
When False, the client will call `get_flag_definitions()` to
|
|
101
|
+
retrieve cached data instead.
|
|
102
|
+
"""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Called after successfully receiving new flag definitions from PostHog.
|
|
108
|
+
|
|
109
|
+
Use this to store the data in your external cache and release any
|
|
110
|
+
distributed locks acquired in `should_fetch_flag_definitions()`.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
data: The flag definitions to cache, containing flags,
|
|
114
|
+
group_type_mapping, and cohorts.
|
|
115
|
+
"""
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
def shutdown(self) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Called when the PostHog client shuts down.
|
|
121
|
+
|
|
122
|
+
Use this to release any distributed locks and clean up resources.
|
|
123
|
+
This method is called even if `should_fetch_flag_definitions()`
|
|
124
|
+
returned False, so implementations should handle the case where
|
|
125
|
+
no lock was acquired.
|
|
126
|
+
"""
|
|
127
|
+
...
|
posthog/request.py
CHANGED
|
@@ -1,28 +1,125 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
|
+
import re
|
|
4
|
+
import socket
|
|
5
|
+
from dataclasses import dataclass
|
|
3
6
|
from datetime import date, datetime
|
|
4
7
|
from gzip import GzipFile
|
|
5
8
|
from io import BytesIO
|
|
6
|
-
from typing import Any, Optional, Union
|
|
9
|
+
from typing import Any, List, Optional, Tuple, Union
|
|
10
|
+
|
|
7
11
|
|
|
8
12
|
import requests
|
|
9
13
|
from dateutil.tz import tzutc
|
|
14
|
+
from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
|
|
15
|
+
from urllib3.connection import HTTPConnection
|
|
10
16
|
from urllib3.util.retry import Retry
|
|
11
17
|
|
|
12
18
|
from posthog.utils import remove_trailing_slash
|
|
13
19
|
from posthog.version import VERSION
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
SocketOptions = List[Tuple[int, int, Union[int, bytes]]]
|
|
22
|
+
|
|
23
|
+
KEEPALIVE_IDLE_SECONDS = 60
|
|
24
|
+
KEEPALIVE_INTERVAL_SECONDS = 60
|
|
25
|
+
KEEPALIVE_PROBE_COUNT = 3
|
|
26
|
+
|
|
27
|
+
# TCP keepalive probes idle connections to prevent them from being dropped.
|
|
28
|
+
# SO_KEEPALIVE is cross-platform, but timing options vary:
|
|
29
|
+
# - Linux: TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT
|
|
30
|
+
# - macOS: only SO_KEEPALIVE (uses system defaults)
|
|
31
|
+
# - Windows: TCP_KEEPIDLE, TCP_KEEPINTVL (since Windows 10 1709)
|
|
32
|
+
KEEP_ALIVE_SOCKET_OPTIONS: SocketOptions = list(
|
|
33
|
+
HTTPConnection.default_socket_options
|
|
34
|
+
) + [
|
|
35
|
+
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
|
|
36
|
+
]
|
|
37
|
+
for attr, value in [
|
|
38
|
+
("TCP_KEEPIDLE", KEEPALIVE_IDLE_SECONDS),
|
|
39
|
+
("TCP_KEEPINTVL", KEEPALIVE_INTERVAL_SECONDS),
|
|
40
|
+
("TCP_KEEPCNT", KEEPALIVE_PROBE_COUNT),
|
|
41
|
+
]:
|
|
42
|
+
if hasattr(socket, attr):
|
|
43
|
+
KEEP_ALIVE_SOCKET_OPTIONS.append((socket.SOL_TCP, getattr(socket, attr), value))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _mask_tokens_in_url(url: str) -> str:
|
|
47
|
+
"""Mask token values in URLs for safe logging, keeping first 10 chars visible."""
|
|
48
|
+
return re.sub(r"(token=)([^&]{10})[^&]*", r"\1\2...", url)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class GetResponse:
|
|
53
|
+
"""Response from a GET request with ETag support."""
|
|
54
|
+
|
|
55
|
+
data: Any
|
|
56
|
+
etag: Optional[str] = None
|
|
57
|
+
not_modified: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HTTPAdapterWithSocketOptions(HTTPAdapter):
|
|
61
|
+
"""HTTPAdapter with configurable socket options."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, *args, socket_options: Optional[SocketOptions] = None, **kwargs):
|
|
64
|
+
self.socket_options = socket_options
|
|
65
|
+
super().__init__(*args, **kwargs)
|
|
66
|
+
|
|
67
|
+
def init_poolmanager(self, *args, **kwargs):
|
|
68
|
+
if self.socket_options is not None:
|
|
69
|
+
kwargs["socket_options"] = self.socket_options
|
|
70
|
+
super().init_poolmanager(*args, **kwargs)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.Session:
|
|
74
|
+
adapter = HTTPAdapterWithSocketOptions(
|
|
75
|
+
max_retries=Retry(
|
|
76
|
+
total=2,
|
|
77
|
+
connect=2,
|
|
78
|
+
read=2,
|
|
79
|
+
),
|
|
80
|
+
socket_options=socket_options,
|
|
22
81
|
)
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
|
|
82
|
+
session = requests.sessions.Session()
|
|
83
|
+
session.mount("https://", adapter)
|
|
84
|
+
return session
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_session = _build_session()
|
|
88
|
+
_socket_options: Optional[SocketOptions] = None
|
|
89
|
+
_pooling_enabled = True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _get_session() -> requests.Session:
|
|
93
|
+
if _pooling_enabled:
|
|
94
|
+
return _session
|
|
95
|
+
return _build_session(_socket_options)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Configure socket options for all HTTP connections.
|
|
101
|
+
|
|
102
|
+
Example:
|
|
103
|
+
from posthog import set_socket_options
|
|
104
|
+
set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
|
|
105
|
+
"""
|
|
106
|
+
global _session, _socket_options
|
|
107
|
+
if socket_options == _socket_options:
|
|
108
|
+
return
|
|
109
|
+
_socket_options = socket_options
|
|
110
|
+
_session = _build_session(socket_options)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def enable_keep_alive() -> None:
|
|
114
|
+
"""Enable TCP keepalive to prevent idle connections from being dropped."""
|
|
115
|
+
set_socket_options(KEEP_ALIVE_SOCKET_OPTIONS)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def disable_connection_reuse() -> None:
|
|
119
|
+
"""Disable connection reuse, creating a fresh connection for each request."""
|
|
120
|
+
global _pooling_enabled
|
|
121
|
+
_pooling_enabled = False
|
|
122
|
+
|
|
26
123
|
|
|
27
124
|
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
|
|
28
125
|
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
|
|
@@ -68,7 +165,7 @@ def post(
|
|
|
68
165
|
gz.write(data.encode("utf-8"))
|
|
69
166
|
data = buf.getvalue()
|
|
70
167
|
|
|
71
|
-
res =
|
|
168
|
+
res = _get_session().post(url, data=data, headers=headers, timeout=timeout)
|
|
72
169
|
|
|
73
170
|
if res.status_code == 200:
|
|
74
171
|
log.debug("data uploaded successfully")
|
|
@@ -139,12 +236,13 @@ def remote_config(
|
|
|
139
236
|
timeout: int = 15,
|
|
140
237
|
) -> Any:
|
|
141
238
|
"""Get remote config flag value from remote_config API endpoint"""
|
|
142
|
-
|
|
239
|
+
response = get(
|
|
143
240
|
personal_api_key,
|
|
144
241
|
f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}",
|
|
145
242
|
host,
|
|
146
243
|
timeout,
|
|
147
244
|
)
|
|
245
|
+
return response.data
|
|
148
246
|
|
|
149
247
|
|
|
150
248
|
def batch_post(
|
|
@@ -162,15 +260,42 @@ def batch_post(
|
|
|
162
260
|
|
|
163
261
|
|
|
164
262
|
def get(
|
|
165
|
-
api_key: str,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
263
|
+
api_key: str,
|
|
264
|
+
url: str,
|
|
265
|
+
host: Optional[str] = None,
|
|
266
|
+
timeout: Optional[int] = None,
|
|
267
|
+
etag: Optional[str] = None,
|
|
268
|
+
) -> GetResponse:
|
|
269
|
+
"""
|
|
270
|
+
Make a GET request with optional ETag support.
|
|
271
|
+
|
|
272
|
+
If an etag is provided, sends If-None-Match header. Returns GetResponse with:
|
|
273
|
+
- not_modified=True and data=None if server returns 304
|
|
274
|
+
- not_modified=False and data=response if server returns 200
|
|
275
|
+
"""
|
|
276
|
+
log = logging.getLogger("posthog")
|
|
277
|
+
full_url = remove_trailing_slash(host or DEFAULT_HOST) + url
|
|
278
|
+
headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}
|
|
279
|
+
|
|
280
|
+
if etag:
|
|
281
|
+
headers["If-None-Match"] = etag
|
|
282
|
+
|
|
283
|
+
res = _get_session().get(full_url, headers=headers, timeout=timeout)
|
|
284
|
+
|
|
285
|
+
masked_url = _mask_tokens_in_url(full_url)
|
|
286
|
+
|
|
287
|
+
# Handle 304 Not Modified
|
|
288
|
+
if res.status_code == 304:
|
|
289
|
+
log.debug(f"GET {masked_url} returned 304 Not Modified")
|
|
290
|
+
response_etag = res.headers.get("ETag")
|
|
291
|
+
return GetResponse(data=None, etag=response_etag or etag, not_modified=True)
|
|
292
|
+
|
|
293
|
+
# Handle normal response
|
|
294
|
+
data = _process_response(
|
|
295
|
+
res, success_message=f"GET {masked_url} completed successfully"
|
|
172
296
|
)
|
|
173
|
-
|
|
297
|
+
response_etag = res.headers.get("ETag")
|
|
298
|
+
return GetResponse(data=data, etag=response_etag, not_modified=False)
|
|
174
299
|
|
|
175
300
|
|
|
176
301
|
class APIError(Exception):
|
|
@@ -187,6 +312,12 @@ class QuotaLimitError(APIError):
|
|
|
187
312
|
pass
|
|
188
313
|
|
|
189
314
|
|
|
315
|
+
# Re-export requests exceptions for use in client.py
|
|
316
|
+
# This keeps all requests library imports centralized in this module
|
|
317
|
+
RequestsTimeout = requests.exceptions.Timeout
|
|
318
|
+
RequestsConnectionError = requests.exceptions.ConnectionError
|
|
319
|
+
|
|
320
|
+
|
|
190
321
|
class DatetimeSerializer(json.JSONEncoder):
|
|
191
322
|
def default(self, obj: Any):
|
|
192
323
|
if isinstance(obj, (date, datetime)):
|
posthog/test/test_client.py
CHANGED
|
@@ -9,7 +9,7 @@ from parameterized import parameterized
|
|
|
9
9
|
|
|
10
10
|
from posthog.client import Client
|
|
11
11
|
from posthog.contexts import get_context_session_id, new_context, set_context_session
|
|
12
|
-
from posthog.request import APIError
|
|
12
|
+
from posthog.request import APIError, GetResponse
|
|
13
13
|
from posthog.test.test_utils import FAKE_TEST_API_KEY
|
|
14
14
|
from posthog.types import FeatureFlag, LegacyFlagMetadata
|
|
15
15
|
from posthog.version import VERSION
|
|
@@ -198,12 +198,6 @@ class TestClient(unittest.TestCase):
|
|
|
198
198
|
print(capture_call)
|
|
199
199
|
self.assertEqual(capture_call[1]["distinct_id"], "distinct_id")
|
|
200
200
|
self.assertEqual(capture_call[0][0], "$exception")
|
|
201
|
-
self.assertEqual(
|
|
202
|
-
capture_call[1]["properties"]["$exception_type"], "Exception"
|
|
203
|
-
)
|
|
204
|
-
self.assertEqual(
|
|
205
|
-
capture_call[1]["properties"]["$exception_message"], "test exception"
|
|
206
|
-
)
|
|
207
201
|
self.assertEqual(
|
|
208
202
|
capture_call[1]["properties"]["$exception_list"][0]["mechanism"][
|
|
209
203
|
"type"
|
|
@@ -752,6 +746,96 @@ class TestClient(unittest.TestCase):
|
|
|
752
746
|
|
|
753
747
|
self.assertEqual(patch_flags.call_count, 0)
|
|
754
748
|
|
|
749
|
+
@mock.patch("posthog.client.flags")
|
|
750
|
+
def test_capture_with_send_feature_flags_true_and_local_evaluation_uses_local_flags(
|
|
751
|
+
self, patch_flags
|
|
752
|
+
):
|
|
753
|
+
"""Test that send_feature_flags=True with local evaluation enabled uses local flags without API call"""
|
|
754
|
+
patch_flags.return_value = {"featureFlags": {"remote-flag": "remote-variant"}}
|
|
755
|
+
|
|
756
|
+
multivariate_flag = {
|
|
757
|
+
"id": 1,
|
|
758
|
+
"name": "Beta Feature",
|
|
759
|
+
"key": "beta-feature-local",
|
|
760
|
+
"active": True,
|
|
761
|
+
"rollout_percentage": 100,
|
|
762
|
+
"filters": {
|
|
763
|
+
"groups": [
|
|
764
|
+
{
|
|
765
|
+
"rollout_percentage": 100,
|
|
766
|
+
},
|
|
767
|
+
],
|
|
768
|
+
"multivariate": {
|
|
769
|
+
"variants": [
|
|
770
|
+
{
|
|
771
|
+
"key": "first-variant",
|
|
772
|
+
"name": "First Variant",
|
|
773
|
+
"rollout_percentage": 50,
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
"key": "second-variant",
|
|
777
|
+
"name": "Second Variant",
|
|
778
|
+
"rollout_percentage": 50,
|
|
779
|
+
},
|
|
780
|
+
]
|
|
781
|
+
},
|
|
782
|
+
},
|
|
783
|
+
}
|
|
784
|
+
simple_flag = {
|
|
785
|
+
"id": 2,
|
|
786
|
+
"name": "Simple Flag",
|
|
787
|
+
"key": "simple-flag",
|
|
788
|
+
"active": True,
|
|
789
|
+
"filters": {
|
|
790
|
+
"groups": [
|
|
791
|
+
{
|
|
792
|
+
"rollout_percentage": 100,
|
|
793
|
+
}
|
|
794
|
+
],
|
|
795
|
+
},
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
with mock.patch("posthog.client.batch_post") as mock_post:
|
|
799
|
+
client = Client(
|
|
800
|
+
FAKE_TEST_API_KEY,
|
|
801
|
+
on_error=self.set_fail,
|
|
802
|
+
personal_api_key=FAKE_TEST_API_KEY,
|
|
803
|
+
sync_mode=True,
|
|
804
|
+
)
|
|
805
|
+
client.feature_flags = [multivariate_flag, simple_flag]
|
|
806
|
+
|
|
807
|
+
msg_uuid = client.capture(
|
|
808
|
+
"python test event",
|
|
809
|
+
distinct_id="distinct_id",
|
|
810
|
+
send_feature_flags=True,
|
|
811
|
+
)
|
|
812
|
+
self.assertIsNotNone(msg_uuid)
|
|
813
|
+
self.assertFalse(self.failed)
|
|
814
|
+
|
|
815
|
+
# Get the enqueued message from the mock
|
|
816
|
+
mock_post.assert_called_once()
|
|
817
|
+
batch_data = mock_post.call_args[1]["batch"]
|
|
818
|
+
msg = batch_data[0]
|
|
819
|
+
|
|
820
|
+
self.assertEqual(msg["event"], "python test event")
|
|
821
|
+
self.assertEqual(msg["distinct_id"], "distinct_id")
|
|
822
|
+
|
|
823
|
+
# Verify local flags are included in the event
|
|
824
|
+
self.assertIn("$feature/beta-feature-local", msg["properties"])
|
|
825
|
+
self.assertIn("$feature/simple-flag", msg["properties"])
|
|
826
|
+
self.assertEqual(msg["properties"]["$feature/simple-flag"], True)
|
|
827
|
+
|
|
828
|
+
# Verify active feature flags are set correctly
|
|
829
|
+
active_flags = msg["properties"]["$active_feature_flags"]
|
|
830
|
+
self.assertIn("beta-feature-local", active_flags)
|
|
831
|
+
self.assertIn("simple-flag", active_flags)
|
|
832
|
+
|
|
833
|
+
# The remote flag should NOT be included since we used local evaluation
|
|
834
|
+
self.assertNotIn("$feature/remote-flag", msg["properties"])
|
|
835
|
+
|
|
836
|
+
# CRITICAL: Verify the /flags API was NOT called
|
|
837
|
+
self.assertEqual(patch_flags.call_count, 0)
|
|
838
|
+
|
|
755
839
|
@mock.patch("posthog.client.flags")
|
|
756
840
|
def test_capture_with_send_feature_flags_options_only_evaluate_locally_true(
|
|
757
841
|
self, patch_flags
|
|
@@ -2095,13 +2179,21 @@ class TestClient(unittest.TestCase):
|
|
|
2095
2179
|
self, patch_get, patch_poller
|
|
2096
2180
|
):
|
|
2097
2181
|
"""Test that when enable_local_evaluation=False, the poller is not started"""
|
|
2098
|
-
patch_get.return_value =
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2182
|
+
patch_get.return_value = GetResponse(
|
|
2183
|
+
data={
|
|
2184
|
+
"flags": [
|
|
2185
|
+
{
|
|
2186
|
+
"id": 1,
|
|
2187
|
+
"name": "Beta Feature",
|
|
2188
|
+
"key": "beta-feature",
|
|
2189
|
+
"active": True,
|
|
2190
|
+
}
|
|
2191
|
+
],
|
|
2192
|
+
"group_type_mapping": {},
|
|
2193
|
+
"cohorts": {},
|
|
2194
|
+
},
|
|
2195
|
+
etag='"test-etag"',
|
|
2196
|
+
)
|
|
2105
2197
|
|
|
2106
2198
|
client = Client(
|
|
2107
2199
|
FAKE_TEST_API_KEY,
|
|
@@ -2123,13 +2215,21 @@ class TestClient(unittest.TestCase):
|
|
|
2123
2215
|
@mock.patch("posthog.client.get")
|
|
2124
2216
|
def test_enable_local_evaluation_true_starts_poller(self, patch_get, patch_poller):
|
|
2125
2217
|
"""Test that when enable_local_evaluation=True (default), the poller is started"""
|
|
2126
|
-
patch_get.return_value =
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2218
|
+
patch_get.return_value = GetResponse(
|
|
2219
|
+
data={
|
|
2220
|
+
"flags": [
|
|
2221
|
+
{
|
|
2222
|
+
"id": 1,
|
|
2223
|
+
"name": "Beta Feature",
|
|
2224
|
+
"key": "beta-feature",
|
|
2225
|
+
"active": True,
|
|
2226
|
+
}
|
|
2227
|
+
],
|
|
2228
|
+
"group_type_mapping": {},
|
|
2229
|
+
"cohorts": {},
|
|
2230
|
+
},
|
|
2231
|
+
etag='"test-etag"',
|
|
2232
|
+
)
|
|
2133
2233
|
|
|
2134
2234
|
client = Client(
|
|
2135
2235
|
FAKE_TEST_API_KEY,
|
|
@@ -59,8 +59,29 @@ def test_code_variables_capture(tmpdir):
|
|
|
59
59
|
my_number = 42
|
|
60
60
|
my_bool = True
|
|
61
61
|
my_dict = {"name": "test", "value": 123}
|
|
62
|
+
my_sensitive_dict = {
|
|
63
|
+
"safe_key": "safe_value",
|
|
64
|
+
"password": "secret123", # key matches pattern -> should be masked
|
|
65
|
+
"other_key": "contains_password_here", # value matches pattern -> should be masked
|
|
66
|
+
}
|
|
67
|
+
my_nested_dict = {
|
|
68
|
+
"level1": {
|
|
69
|
+
"level2": {
|
|
70
|
+
"api_key": "nested_secret", # deeply nested key matches
|
|
71
|
+
"data": "contains_token_here", # deeply nested value matches
|
|
72
|
+
"safe": "visible",
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
my_list = ["safe_item", "has_password_inside", "another_safe"]
|
|
77
|
+
my_tuple = ("tuple_safe", "secret_in_value", "tuple_also_safe")
|
|
78
|
+
my_list_of_dicts = [
|
|
79
|
+
{"id": 1, "password": "list_dict_secret"},
|
|
80
|
+
{"id": 2, "value": "safe_value"},
|
|
81
|
+
]
|
|
62
82
|
my_obj = UnserializableObject()
|
|
63
|
-
my_password = "secret123" # Should be masked by default
|
|
83
|
+
my_password = "secret123" # Should be masked by default (name matches)
|
|
84
|
+
my_innocent_var = "contains_password_here" # Should be masked by default (value matches)
|
|
64
85
|
__should_be_ignored = "hidden" # Should be ignored by default
|
|
65
86
|
|
|
66
87
|
1/0 # Trigger exception
|
|
@@ -96,8 +117,31 @@ def test_code_variables_capture(tmpdir):
|
|
|
96
117
|
assert b"'my_number': 42" in output
|
|
97
118
|
assert b"'my_bool': 'True'" in output
|
|
98
119
|
assert b'"my_dict": "{\\"name\\": \\"test\\", \\"value\\": 123}"' in output
|
|
120
|
+
assert (
|
|
121
|
+
b'{\\"safe_key\\": \\"safe_value\\", \\"password\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"other_key\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\"}'
|
|
122
|
+
in output
|
|
123
|
+
)
|
|
124
|
+
assert (
|
|
125
|
+
b'{\\"level1\\": {\\"level2\\": {\\"api_key\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"data\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"safe\\": \\"visible\\"}}}'
|
|
126
|
+
in output
|
|
127
|
+
)
|
|
128
|
+
assert (
|
|
129
|
+
b'[\\"safe_item\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"another_safe\\"]'
|
|
130
|
+
in output
|
|
131
|
+
)
|
|
132
|
+
assert (
|
|
133
|
+
b'[\\"tuple_safe\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"tuple_also_safe\\"]'
|
|
134
|
+
in output
|
|
135
|
+
)
|
|
136
|
+
assert (
|
|
137
|
+
b'[{\\"id\\": 1, \\"password\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\"}, {\\"id\\": 2, \\"value\\": \\"safe_value\\"}]'
|
|
138
|
+
in output
|
|
139
|
+
)
|
|
99
140
|
assert b"<__main__.UnserializableObject object at" in output
|
|
100
141
|
assert b"'my_password': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
|
|
142
|
+
assert (
|
|
143
|
+
b"'my_innocent_var': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
|
|
144
|
+
)
|
|
101
145
|
assert b"'__should_be_ignored':" not in output
|
|
102
146
|
|
|
103
147
|
# Variables from intermediate_function frame
|