posthog 7.3.1__tar.gz → 7.4.1__tar.gz

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.
Files changed (64) hide show
  1. {posthog-7.3.1/posthog.egg-info → posthog-7.4.1}/PKG-INFO +1 -1
  2. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/openai/openai.py +27 -2
  3. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/openai/openai_async.py +27 -2
  4. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/utils.py +2 -2
  5. {posthog-7.3.1 → posthog-7.4.1}/posthog/client.py +11 -11
  6. {posthog-7.3.1 → posthog-7.4.1}/posthog/request.py +55 -6
  7. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_client.py +86 -1
  8. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_feature_flags.py +9 -55
  9. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_request.py +271 -0
  10. {posthog-7.3.1 → posthog-7.4.1}/posthog/version.py +1 -1
  11. {posthog-7.3.1 → posthog-7.4.1/posthog.egg-info}/PKG-INFO +1 -1
  12. {posthog-7.3.1 → posthog-7.4.1}/LICENSE +0 -0
  13. {posthog-7.3.1 → posthog-7.4.1}/MANIFEST.in +0 -0
  14. {posthog-7.3.1 → posthog-7.4.1}/README.md +0 -0
  15. {posthog-7.3.1 → posthog-7.4.1}/posthog/__init__.py +0 -0
  16. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/__init__.py +0 -0
  17. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/anthropic/__init__.py +0 -0
  18. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/anthropic/anthropic.py +0 -0
  19. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/anthropic/anthropic_async.py +0 -0
  20. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/anthropic/anthropic_converter.py +0 -0
  21. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/anthropic/anthropic_providers.py +0 -0
  22. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/gemini/__init__.py +0 -0
  23. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/gemini/gemini.py +0 -0
  24. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/gemini/gemini_async.py +0 -0
  25. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/gemini/gemini_converter.py +0 -0
  26. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/langchain/__init__.py +0 -0
  27. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/langchain/callbacks.py +0 -0
  28. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/openai/__init__.py +0 -0
  29. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/openai/openai_converter.py +0 -0
  30. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/openai/openai_providers.py +0 -0
  31. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/sanitization.py +0 -0
  32. {posthog-7.3.1 → posthog-7.4.1}/posthog/ai/types.py +0 -0
  33. {posthog-7.3.1 → posthog-7.4.1}/posthog/args.py +0 -0
  34. {posthog-7.3.1 → posthog-7.4.1}/posthog/consumer.py +0 -0
  35. {posthog-7.3.1 → posthog-7.4.1}/posthog/contexts.py +0 -0
  36. {posthog-7.3.1 → posthog-7.4.1}/posthog/exception_capture.py +0 -0
  37. {posthog-7.3.1 → posthog-7.4.1}/posthog/exception_utils.py +0 -0
  38. {posthog-7.3.1 → posthog-7.4.1}/posthog/feature_flags.py +0 -0
  39. {posthog-7.3.1 → posthog-7.4.1}/posthog/flag_definition_cache.py +0 -0
  40. {posthog-7.3.1 → posthog-7.4.1}/posthog/integrations/__init__.py +0 -0
  41. {posthog-7.3.1 → posthog-7.4.1}/posthog/integrations/django.py +0 -0
  42. {posthog-7.3.1 → posthog-7.4.1}/posthog/poller.py +0 -0
  43. {posthog-7.3.1 → posthog-7.4.1}/posthog/py.typed +0 -0
  44. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/__init__.py +0 -0
  45. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_before_send.py +0 -0
  46. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_consumer.py +0 -0
  47. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_contexts.py +0 -0
  48. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_exception_capture.py +0 -0
  49. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_feature_flag.py +0 -0
  50. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_feature_flag_result.py +0 -0
  51. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_flag_definition_cache.py +0 -0
  52. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_module.py +0 -0
  53. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_size_limited_dict.py +0 -0
  54. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_types.py +0 -0
  55. {posthog-7.3.1 → posthog-7.4.1}/posthog/test/test_utils.py +0 -0
  56. {posthog-7.3.1 → posthog-7.4.1}/posthog/types.py +0 -0
  57. {posthog-7.3.1 → posthog-7.4.1}/posthog/utils.py +0 -0
  58. {posthog-7.3.1 → posthog-7.4.1}/posthog.egg-info/SOURCES.txt +0 -0
  59. {posthog-7.3.1 → posthog-7.4.1}/posthog.egg-info/dependency_links.txt +0 -0
  60. {posthog-7.3.1 → posthog-7.4.1}/posthog.egg-info/requires.txt +0 -0
  61. {posthog-7.3.1 → posthog-7.4.1}/posthog.egg-info/top_level.txt +0 -0
  62. {posthog-7.3.1 → posthog-7.4.1}/pyproject.toml +0 -0
  63. {posthog-7.3.1 → posthog-7.4.1}/setup.cfg +0 -0
  64. {posthog-7.3.1 → posthog-7.4.1}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthog
3
- Version: 7.3.1
3
+ Version: 7.4.1
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog
@@ -124,14 +124,23 @@ class WrappedResponses:
124
124
  start_time = time.time()
125
125
  usage_stats: TokenUsage = TokenUsage()
126
126
  final_content = []
127
+ model_from_response: Optional[str] = None
127
128
  response = self._original.create(**kwargs)
128
129
 
129
130
  def generator():
130
131
  nonlocal usage_stats
131
132
  nonlocal final_content # noqa: F824
133
+ nonlocal model_from_response
132
134
 
133
135
  try:
134
136
  for chunk in response:
137
+ # Extract model from response object in chunk (for stored prompts)
138
+ if hasattr(chunk, "response") and chunk.response:
139
+ if model_from_response is None and hasattr(
140
+ chunk.response, "model"
141
+ ):
142
+ model_from_response = chunk.response.model
143
+
135
144
  # Extract usage stats from chunk
136
145
  chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
137
146
 
@@ -161,6 +170,7 @@ class WrappedResponses:
161
170
  latency,
162
171
  output,
163
172
  None, # Responses API doesn't have tools
173
+ model_from_response,
164
174
  )
165
175
 
166
176
  return generator()
@@ -177,6 +187,7 @@ class WrappedResponses:
177
187
  latency: float,
178
188
  output: Any,
179
189
  available_tool_calls: Optional[List[Dict[str, Any]]] = None,
190
+ model_from_response: Optional[str] = None,
180
191
  ):
181
192
  from posthog.ai.types import StreamingEventData
182
193
  from posthog.ai.openai.openai_converter import (
@@ -189,9 +200,12 @@ class WrappedResponses:
189
200
  formatted_input = format_openai_streaming_input(kwargs, "responses")
190
201
  sanitized_input = sanitize_openai_response(formatted_input)
191
202
 
203
+ # Use model from kwargs, fallback to model from response
204
+ model = kwargs.get("model") or model_from_response or "unknown"
205
+
192
206
  event_data = StreamingEventData(
193
207
  provider="openai",
194
- model=kwargs.get("model", "unknown"),
208
+ model=model,
195
209
  base_url=str(self._client.base_url),
196
210
  kwargs=kwargs,
197
211
  formatted_input=sanitized_input,
@@ -320,6 +334,7 @@ class WrappedCompletions:
320
334
  usage_stats: TokenUsage = TokenUsage()
321
335
  accumulated_content = []
322
336
  accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
337
+ model_from_response: Optional[str] = None
323
338
  if "stream_options" not in kwargs:
324
339
  kwargs["stream_options"] = {}
325
340
  kwargs["stream_options"]["include_usage"] = True
@@ -329,9 +344,14 @@ class WrappedCompletions:
329
344
  nonlocal usage_stats
330
345
  nonlocal accumulated_content # noqa: F824
331
346
  nonlocal accumulated_tool_calls
347
+ nonlocal model_from_response
332
348
 
333
349
  try:
334
350
  for chunk in response:
351
+ # Extract model from chunk (Chat Completions chunks have model field)
352
+ if model_from_response is None and hasattr(chunk, "model"):
353
+ model_from_response = chunk.model
354
+
335
355
  # Extract usage stats from chunk
336
356
  chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
337
357
 
@@ -376,6 +396,7 @@ class WrappedCompletions:
376
396
  accumulated_content,
377
397
  tool_calls_list,
378
398
  extract_available_tool_calls("openai", kwargs),
399
+ model_from_response,
379
400
  )
380
401
 
381
402
  return generator()
@@ -393,6 +414,7 @@ class WrappedCompletions:
393
414
  output: Any,
394
415
  tool_calls: Optional[List[Dict[str, Any]]] = None,
395
416
  available_tool_calls: Optional[List[Dict[str, Any]]] = None,
417
+ model_from_response: Optional[str] = None,
396
418
  ):
397
419
  from posthog.ai.types import StreamingEventData
398
420
  from posthog.ai.openai.openai_converter import (
@@ -405,9 +427,12 @@ class WrappedCompletions:
405
427
  formatted_input = format_openai_streaming_input(kwargs, "chat")
406
428
  sanitized_input = sanitize_openai(formatted_input)
407
429
 
430
+ # Use model from kwargs, fallback to model from response
431
+ model = kwargs.get("model") or model_from_response or "unknown"
432
+
408
433
  event_data = StreamingEventData(
409
434
  provider="openai",
410
- model=kwargs.get("model", "unknown"),
435
+ model=model,
411
436
  base_url=str(self._client.base_url),
412
437
  kwargs=kwargs,
413
438
  formatted_input=sanitized_input,
@@ -128,14 +128,23 @@ class WrappedResponses:
128
128
  start_time = time.time()
129
129
  usage_stats: TokenUsage = TokenUsage()
130
130
  final_content = []
131
+ model_from_response: Optional[str] = None
131
132
  response = await self._original.create(**kwargs)
132
133
 
133
134
  async def async_generator():
134
135
  nonlocal usage_stats
135
136
  nonlocal final_content # noqa: F824
137
+ nonlocal model_from_response
136
138
 
137
139
  try:
138
140
  async for chunk in response:
141
+ # Extract model from response object in chunk (for stored prompts)
142
+ if hasattr(chunk, "response") and chunk.response:
143
+ if model_from_response is None and hasattr(
144
+ chunk.response, "model"
145
+ ):
146
+ model_from_response = chunk.response.model
147
+
139
148
  # Extract usage stats from chunk
140
149
  chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
141
150
 
@@ -166,6 +175,7 @@ class WrappedResponses:
166
175
  latency,
167
176
  output,
168
177
  extract_available_tool_calls("openai", kwargs),
178
+ model_from_response,
169
179
  )
170
180
 
171
181
  return async_generator()
@@ -182,13 +192,17 @@ class WrappedResponses:
182
192
  latency: float,
183
193
  output: Any,
184
194
  available_tool_calls: Optional[List[Dict[str, Any]]] = None,
195
+ model_from_response: Optional[str] = None,
185
196
  ):
186
197
  if posthog_trace_id is None:
187
198
  posthog_trace_id = str(uuid.uuid4())
188
199
 
200
+ # Use model from kwargs, fallback to model from response
201
+ model = kwargs.get("model") or model_from_response or "unknown"
202
+
189
203
  event_properties = {
190
204
  "$ai_provider": "openai",
191
- "$ai_model": kwargs.get("model"),
205
+ "$ai_model": model,
192
206
  "$ai_model_parameters": get_model_params(kwargs),
193
207
  "$ai_input": with_privacy_mode(
194
208
  self._client._ph_client,
@@ -350,6 +364,7 @@ class WrappedCompletions:
350
364
  usage_stats: TokenUsage = TokenUsage()
351
365
  accumulated_content = []
352
366
  accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
367
+ model_from_response: Optional[str] = None
353
368
 
354
369
  if "stream_options" not in kwargs:
355
370
  kwargs["stream_options"] = {}
@@ -360,9 +375,14 @@ class WrappedCompletions:
360
375
  nonlocal usage_stats
361
376
  nonlocal accumulated_content # noqa: F824
362
377
  nonlocal accumulated_tool_calls
378
+ nonlocal model_from_response
363
379
 
364
380
  try:
365
381
  async for chunk in response:
382
+ # Extract model from chunk (Chat Completions chunks have model field)
383
+ if model_from_response is None and hasattr(chunk, "model"):
384
+ model_from_response = chunk.model
385
+
366
386
  # Extract usage stats from chunk
367
387
  chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
368
388
  if chunk_usage:
@@ -405,6 +425,7 @@ class WrappedCompletions:
405
425
  accumulated_content,
406
426
  tool_calls_list,
407
427
  extract_available_tool_calls("openai", kwargs),
428
+ model_from_response,
408
429
  )
409
430
 
410
431
  return async_generator()
@@ -422,13 +443,17 @@ class WrappedCompletions:
422
443
  output: Any,
423
444
  tool_calls: Optional[List[Dict[str, Any]]] = None,
424
445
  available_tool_calls: Optional[List[Dict[str, Any]]] = None,
446
+ model_from_response: Optional[str] = None,
425
447
  ):
426
448
  if posthog_trace_id is None:
427
449
  posthog_trace_id = str(uuid.uuid4())
428
450
 
451
+ # Use model from kwargs, fallback to model from response
452
+ model = kwargs.get("model") or model_from_response or "unknown"
453
+
429
454
  event_properties = {
430
455
  "$ai_provider": "openai",
431
- "$ai_model": kwargs.get("model"),
456
+ "$ai_model": model,
432
457
  "$ai_model_parameters": get_model_params(kwargs),
433
458
  "$ai_input": with_privacy_mode(
434
459
  self._client._ph_client,
@@ -285,7 +285,7 @@ def call_llm_and_track_usage(
285
285
 
286
286
  event_properties = {
287
287
  "$ai_provider": provider,
288
- "$ai_model": kwargs.get("model"),
288
+ "$ai_model": kwargs.get("model") or getattr(response, "model", None),
289
289
  "$ai_model_parameters": get_model_params(kwargs),
290
290
  "$ai_input": with_privacy_mode(
291
291
  ph_client, posthog_privacy_mode, sanitized_messages
@@ -396,7 +396,7 @@ async def call_llm_and_track_usage_async(
396
396
 
397
397
  event_properties = {
398
398
  "$ai_provider": provider,
399
- "$ai_model": kwargs.get("model"),
399
+ "$ai_model": kwargs.get("model") or getattr(response, "model", None),
400
400
  "$ai_model_parameters": get_model_params(kwargs),
401
401
  "$ai_input": with_privacy_mode(
402
402
  ph_client, posthog_privacy_mode, sanitized_messages
@@ -2,6 +2,7 @@ import atexit
2
2
  import logging
3
3
  import os
4
4
  import sys
5
+ import warnings
5
6
  from datetime import datetime, timedelta
6
7
  from typing import Any, Dict, Optional, Union
7
8
  from typing_extensions import Unpack
@@ -679,15 +680,6 @@ class Client(object):
679
680
  f"[FEATURE FLAGS] Unable to get feature variants: {e}"
680
681
  )
681
682
 
682
- elif self.feature_flags and event != "$feature_flag_called":
683
- # Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server
684
- feature_variants = self.get_all_flags(
685
- distinct_id,
686
- groups=(groups or {}),
687
- disable_geoip=disable_geoip,
688
- only_evaluate_locally=True,
689
- )
690
-
691
683
  for feature, variant in (feature_variants or {}).items():
692
684
  extra_properties[f"$feature/{feature}"] = variant
693
685
 
@@ -1797,7 +1789,7 @@ class Client(object):
1797
1789
  person_properties=None,
1798
1790
  group_properties=None,
1799
1791
  only_evaluate_locally=False,
1800
- send_feature_flag_events=True,
1792
+ send_feature_flag_events=False,
1801
1793
  disable_geoip=None,
1802
1794
  ):
1803
1795
  """
@@ -1811,7 +1803,7 @@ class Client(object):
1811
1803
  person_properties: A dictionary of person properties.
1812
1804
  group_properties: A dictionary of group properties.
1813
1805
  only_evaluate_locally: Whether to only evaluate locally.
1814
- send_feature_flag_events: Whether to send feature flag events.
1806
+ send_feature_flag_events: Deprecated. Use get_feature_flag() instead if you need events.
1815
1807
  disable_geoip: Whether to disable GeoIP for this request.
1816
1808
 
1817
1809
  Examples:
@@ -1827,6 +1819,14 @@ class Client(object):
1827
1819
  Category:
1828
1820
  Feature flags
1829
1821
  """
1822
+ if send_feature_flag_events:
1823
+ warnings.warn(
1824
+ "send_feature_flag_events is deprecated in get_feature_flag_payload() and will be removed "
1825
+ "in a future version. Use get_feature_flag() if you want to send $feature_flag_called events.",
1826
+ DeprecationWarning,
1827
+ stacklevel=2,
1828
+ )
1829
+
1830
1830
  feature_flag_result = self._get_feature_flag_result(
1831
1831
  key,
1832
1832
  distinct_id,
@@ -8,7 +8,6 @@ from gzip import GzipFile
8
8
  from io import BytesIO
9
9
  from typing import Any, List, Optional, Tuple, Union
10
10
 
11
-
12
11
  import requests
13
12
  from dateutil.tz import tzutc
14
13
  from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
@@ -42,6 +41,9 @@ for attr, value in [
42
41
  if hasattr(socket, attr):
43
42
  KEEP_ALIVE_SOCKET_OPTIONS.append((socket.SOL_TCP, getattr(socket, attr), value))
44
43
 
44
+ # Status codes that indicate transient server errors worth retrying
45
+ RETRY_STATUS_FORCELIST = [408, 500, 502, 503, 504]
46
+
45
47
 
46
48
  def _mask_tokens_in_url(url: str) -> str:
47
49
  """Mask token values in URLs for safe logging, keeping first 10 chars visible."""
@@ -71,20 +73,49 @@ class HTTPAdapterWithSocketOptions(HTTPAdapter):
71
73
 
72
74
 
73
75
  def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.Session:
76
+ """Build a session for general requests (batch, decide, etc.)."""
77
+ adapter = HTTPAdapterWithSocketOptions(
78
+ max_retries=Retry(
79
+ total=2,
80
+ connect=2,
81
+ read=2,
82
+ ),
83
+ socket_options=socket_options,
84
+ )
85
+ session = requests.Session()
86
+ session.mount("https://", adapter)
87
+ return session
88
+
89
+
90
+ def _build_flags_session(
91
+ socket_options: Optional[SocketOptions] = None,
92
+ ) -> requests.Session:
93
+ """
94
+ Build a session for feature flag requests with POST retries.
95
+
96
+ Feature flag requests are idempotent (read-only), so retrying POST
97
+ requests is safe. This session retries on transient server errors
98
+ (408, 5xx) and network failures with exponential backoff
99
+ (0.5s, 1s delays between retries).
100
+ """
74
101
  adapter = HTTPAdapterWithSocketOptions(
75
102
  max_retries=Retry(
76
103
  total=2,
77
104
  connect=2,
78
105
  read=2,
106
+ backoff_factor=0.5,
107
+ status_forcelist=RETRY_STATUS_FORCELIST,
108
+ allowed_methods=["POST"],
79
109
  ),
80
110
  socket_options=socket_options,
81
111
  )
82
- session = requests.sessions.Session()
112
+ session = requests.Session()
83
113
  session.mount("https://", adapter)
84
114
  return session
85
115
 
86
116
 
87
117
  _session = _build_session()
118
+ _flags_session = _build_flags_session()
88
119
  _socket_options: Optional[SocketOptions] = None
89
120
  _pooling_enabled = True
90
121
 
@@ -95,6 +126,12 @@ def _get_session() -> requests.Session:
95
126
  return _build_session(_socket_options)
96
127
 
97
128
 
129
+ def _get_flags_session() -> requests.Session:
130
+ if _pooling_enabled:
131
+ return _flags_session
132
+ return _build_flags_session(_socket_options)
133
+
134
+
98
135
  def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
99
136
  """
100
137
  Configure socket options for all HTTP connections.
@@ -103,11 +140,12 @@ def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
103
140
  from posthog import set_socket_options
104
141
  set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
105
142
  """
106
- global _session, _socket_options
143
+ global _session, _flags_session, _socket_options
107
144
  if socket_options == _socket_options:
108
145
  return
109
146
  _socket_options = socket_options
110
147
  _session = _build_session(socket_options)
148
+ _flags_session = _build_flags_session(socket_options)
111
149
 
112
150
 
113
151
  def enable_keep_alive() -> None:
@@ -145,6 +183,7 @@ def post(
145
183
  path=None,
146
184
  gzip: bool = False,
147
185
  timeout: int = 15,
186
+ session: Optional[requests.Session] = None,
148
187
  **kwargs,
149
188
  ) -> requests.Response:
150
189
  """Post the `kwargs` to the API"""
@@ -165,7 +204,9 @@ def post(
165
204
  gz.write(data.encode("utf-8"))
166
205
  data = buf.getvalue()
167
206
 
168
- res = _get_session().post(url, data=data, headers=headers, timeout=timeout)
207
+ res = (session or _get_session()).post(
208
+ url, data=data, headers=headers, timeout=timeout
209
+ )
169
210
 
170
211
  if res.status_code == 200:
171
212
  log.debug("data uploaded successfully")
@@ -221,8 +262,16 @@ def flags(
221
262
  timeout: int = 15,
222
263
  **kwargs,
223
264
  ) -> Any:
224
- """Post the `kwargs to the flags API endpoint"""
225
- res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
265
+ """Post the kwargs to the flags API endpoint with automatic retries."""
266
+ res = post(
267
+ api_key,
268
+ host,
269
+ "/flags/?v=2",
270
+ gzip,
271
+ timeout,
272
+ session=_get_flags_session(),
273
+ **kwargs,
274
+ )
226
275
  return _process_response(
227
276
  res, success_message="Feature flags evaluated successfully"
228
277
  )
@@ -409,7 +409,9 @@ class TestClient(unittest.TestCase):
409
409
  )
410
410
  client.feature_flags = [multivariate_flag, basic_flag, false_flag]
411
411
 
412
- msg_uuid = client.capture("python test event", distinct_id="distinct_id")
412
+ msg_uuid = client.capture(
413
+ "python test event", distinct_id="distinct_id", send_feature_flags=True
414
+ )
413
415
  self.assertIsNotNone(msg_uuid)
414
416
  self.assertFalse(self.failed)
415
417
 
@@ -565,6 +567,7 @@ class TestClient(unittest.TestCase):
565
567
  "python test event",
566
568
  distinct_id="distinct_id",
567
569
  properties={"$feature/beta-feature-local": "my-custom-variant"},
570
+ send_feature_flags=True,
568
571
  )
569
572
  self.assertIsNotNone(msg_uuid)
570
573
  self.assertFalse(self.failed)
@@ -746,6 +749,88 @@ class TestClient(unittest.TestCase):
746
749
 
747
750
  self.assertEqual(patch_flags.call_count, 0)
748
751
 
752
+ @mock.patch("posthog.client.flags")
753
+ def test_capture_with_send_feature_flags_false_and_local_evaluation_doesnt_send_flags(
754
+ self, patch_flags
755
+ ):
756
+ """Test that send_feature_flags=False with local evaluation enabled does NOT send flags"""
757
+ patch_flags.return_value = {"featureFlags": {"beta-feature": "remote-variant"}}
758
+
759
+ multivariate_flag = {
760
+ "id": 1,
761
+ "name": "Beta Feature",
762
+ "key": "beta-feature-local",
763
+ "active": True,
764
+ "rollout_percentage": 100,
765
+ "filters": {
766
+ "groups": [
767
+ {
768
+ "rollout_percentage": 100,
769
+ },
770
+ ],
771
+ "multivariate": {
772
+ "variants": [
773
+ {
774
+ "key": "first-variant",
775
+ "name": "First Variant",
776
+ "rollout_percentage": 50,
777
+ },
778
+ {
779
+ "key": "second-variant",
780
+ "name": "Second Variant",
781
+ "rollout_percentage": 50,
782
+ },
783
+ ]
784
+ },
785
+ },
786
+ }
787
+ simple_flag = {
788
+ "id": 2,
789
+ "name": "Simple Flag",
790
+ "key": "simple-flag",
791
+ "active": True,
792
+ "filters": {
793
+ "groups": [
794
+ {
795
+ "rollout_percentage": 100,
796
+ }
797
+ ],
798
+ },
799
+ }
800
+
801
+ with mock.patch("posthog.client.batch_post") as mock_post:
802
+ client = Client(
803
+ FAKE_TEST_API_KEY,
804
+ on_error=self.set_fail,
805
+ personal_api_key=FAKE_TEST_API_KEY,
806
+ sync_mode=True,
807
+ )
808
+ client.feature_flags = [multivariate_flag, simple_flag]
809
+
810
+ msg_uuid = client.capture(
811
+ "python test event",
812
+ distinct_id="distinct_id",
813
+ send_feature_flags=False,
814
+ )
815
+ self.assertIsNotNone(msg_uuid)
816
+ self.assertFalse(self.failed)
817
+
818
+ # Get the enqueued message from the mock
819
+ mock_post.assert_called_once()
820
+ batch_data = mock_post.call_args[1]["batch"]
821
+ msg = batch_data[0]
822
+
823
+ self.assertEqual(msg["event"], "python test event")
824
+ self.assertEqual(msg["distinct_id"], "distinct_id")
825
+
826
+ # CRITICAL: Verify local flags are NOT included in the event
827
+ self.assertNotIn("$feature/beta-feature-local", msg["properties"])
828
+ self.assertNotIn("$feature/simple-flag", msg["properties"])
829
+ self.assertNotIn("$active_feature_flags", msg["properties"])
830
+
831
+ # CRITICAL: Verify the /flags API was NOT called
832
+ self.assertEqual(patch_flags.call_count, 0)
833
+
749
834
  @mock.patch("posthog.client.flags")
750
835
  def test_capture_with_send_feature_flags_true_and_local_evaluation_uses_local_flags(
751
836
  self, patch_flags
@@ -3062,6 +3062,7 @@ class TestLocalEvaluation(unittest.TestCase):
3062
3062
  "some-distinct-id",
3063
3063
  match_value=True,
3064
3064
  person_properties={"region": "USA"},
3065
+ send_feature_flag_events=True,
3065
3066
  ),
3066
3067
  300,
3067
3068
  )
@@ -4051,7 +4052,9 @@ class TestCaptureCalls(unittest.TestCase):
4051
4052
 
4052
4053
  self.assertEqual(
4053
4054
  client.get_feature_flag_payload(
4054
- "decide-flag-with-payload", "some-distinct-id"
4055
+ "decide-flag-with-payload",
4056
+ "some-distinct-id",
4057
+ send_feature_flag_events=True,
4055
4058
  ),
4056
4059
  {"foo": "bar"},
4057
4060
  )
@@ -4127,9 +4130,10 @@ class TestCaptureCalls(unittest.TestCase):
4127
4130
 
4128
4131
  @mock.patch.object(Client, "capture")
4129
4132
  @mock.patch("posthog.client.flags")
4130
- def test_capture_is_called_in_get_feature_flag_payload(
4133
+ def test_get_feature_flag_payload_does_not_send_feature_flag_called_events(
4131
4134
  self, patch_flags, patch_capture
4132
4135
  ):
4136
+ """Test that get_feature_flag_payload does NOT send $feature_flag_called events"""
4133
4137
  patch_flags.return_value = {
4134
4138
  "featureFlags": {"person-flag": True},
4135
4139
  "featureFlagPayloads": {"person-flag": 300},
@@ -4151,68 +4155,18 @@ class TestCaptureCalls(unittest.TestCase):
4151
4155
  "rollout_percentage": 100,
4152
4156
  }
4153
4157
  ],
4158
+ "payloads": {"true": '"payload"'},
4154
4159
  },
4155
4160
  }
4156
4161
  ]
4157
4162
 
4158
- # Call get_feature_flag_payload with match_value=None to trigger get_feature_flag
4159
- client.get_feature_flag_payload(
4160
- key="person-flag",
4161
- distinct_id="some-distinct-id",
4162
- person_properties={"region": "USA", "name": "Aloha"},
4163
- )
4164
-
4165
- # Assert that capture was called once, with the correct parameters
4166
- self.assertEqual(patch_capture.call_count, 1)
4167
- patch_capture.assert_called_with(
4168
- "$feature_flag_called",
4169
- distinct_id="some-distinct-id",
4170
- properties={
4171
- "$feature_flag": "person-flag",
4172
- "$feature_flag_response": True,
4173
- "locally_evaluated": True,
4174
- "$feature/person-flag": True,
4175
- },
4176
- groups={},
4177
- disable_geoip=None,
4178
- )
4179
-
4180
- # Reset mocks for further tests
4181
- patch_capture.reset_mock()
4182
- patch_flags.reset_mock()
4183
-
4184
- # Call get_feature_flag_payload again for the same user; capture should not be called again because we've already reported an event for this distinct_id + flag
4185
- client.get_feature_flag_payload(
4163
+ payload = client.get_feature_flag_payload(
4186
4164
  key="person-flag",
4187
4165
  distinct_id="some-distinct-id",
4188
4166
  person_properties={"region": "USA", "name": "Aloha"},
4189
4167
  )
4190
-
4168
+ self.assertIsNotNone(payload)
4191
4169
  self.assertEqual(patch_capture.call_count, 0)
4192
- patch_capture.reset_mock()
4193
-
4194
- # Call get_feature_flag_payload for a different user; capture should be called
4195
- client.get_feature_flag_payload(
4196
- key="person-flag",
4197
- distinct_id="some-distinct-id2",
4198
- person_properties={"region": "USA", "name": "Aloha"},
4199
- )
4200
-
4201
- self.assertEqual(patch_capture.call_count, 1)
4202
- patch_capture.assert_called_with(
4203
- "$feature_flag_called",
4204
- distinct_id="some-distinct-id2",
4205
- properties={
4206
- "$feature_flag": "person-flag",
4207
- "$feature_flag_response": True,
4208
- "locally_evaluated": True,
4209
- "$feature/person-flag": True,
4210
- },
4211
- groups={},
4212
- disable_geoip=None,
4213
- )
4214
-
4215
- patch_capture.reset_mock()
4216
4170
 
4217
4171
  @mock.patch("posthog.client.flags")
4218
4172
  def test_fallback_to_api_in_get_feature_flag_payload_when_flag_has_static_cohort(
@@ -19,6 +19,7 @@ from posthog.request import (
19
19
  determine_server_host,
20
20
  disable_connection_reuse,
21
21
  enable_keep_alive,
22
+ flags,
22
23
  get,
23
24
  set_socket_options,
24
25
  )
@@ -393,3 +394,273 @@ def test_set_socket_options_is_idempotent():
393
394
  assert session1 is session2
394
395
  finally:
395
396
  set_socket_options(None)
397
+
398
+
399
+ class TestFlagsSession(unittest.TestCase):
400
+ """Tests for flags session configuration."""
401
+
402
+ def test_retry_status_forcelist_excludes_rate_limits(self):
403
+ """Verify 429 (rate limit) is NOT retried - need to wait, not hammer."""
404
+ from posthog.request import RETRY_STATUS_FORCELIST
405
+
406
+ self.assertNotIn(429, RETRY_STATUS_FORCELIST)
407
+
408
+ def test_retry_status_forcelist_excludes_quota_errors(self):
409
+ """Verify 402 (payment required/quota) is NOT retried - won't resolve."""
410
+ from posthog.request import RETRY_STATUS_FORCELIST
411
+
412
+ self.assertNotIn(402, RETRY_STATUS_FORCELIST)
413
+
414
+ @mock.patch("posthog.request._get_flags_session")
415
+ def test_flags_uses_flags_session(self, mock_get_flags_session):
416
+ """flags() uses the dedicated flags session, not the general session."""
417
+ mock_response = requests.Response()
418
+ mock_response.status_code = 200
419
+ mock_response._content = json.dumps(
420
+ {
421
+ "featureFlags": {"test-flag": True},
422
+ "featureFlagPayloads": {},
423
+ "errorsWhileComputingFlags": False,
424
+ }
425
+ ).encode("utf-8")
426
+
427
+ mock_session = mock.MagicMock()
428
+ mock_session.post.return_value = mock_response
429
+ mock_get_flags_session.return_value = mock_session
430
+
431
+ result = flags("test-key", "https://test.posthog.com", distinct_id="user123")
432
+
433
+ self.assertEqual(result["featureFlags"]["test-flag"], True)
434
+ mock_get_flags_session.assert_called_once()
435
+ mock_session.post.assert_called_once()
436
+
437
+ @mock.patch("posthog.request._get_flags_session")
438
+ def test_flags_no_retry_on_quota_limit(self, mock_get_flags_session):
439
+ """flags() raises QuotaLimitError without retrying (at application level)."""
440
+ mock_response = requests.Response()
441
+ mock_response.status_code = 200
442
+ mock_response._content = json.dumps(
443
+ {
444
+ "quotaLimited": ["feature_flags"],
445
+ "featureFlags": {},
446
+ "featureFlagPayloads": {},
447
+ "errorsWhileComputingFlags": False,
448
+ }
449
+ ).encode("utf-8")
450
+
451
+ mock_session = mock.MagicMock()
452
+ mock_session.post.return_value = mock_response
453
+ mock_get_flags_session.return_value = mock_session
454
+
455
+ with self.assertRaises(QuotaLimitError):
456
+ flags("test-key", "https://test.posthog.com", distinct_id="user123")
457
+
458
+ # QuotaLimitError is raised after response is received, not retried
459
+ self.assertEqual(mock_session.post.call_count, 1)
460
+
461
+
462
+ class TestFlagsSessionNetworkRetries(unittest.TestCase):
463
+ """Tests for network failure retries in the flags session."""
464
+
465
+ def test_flags_session_retry_config_includes_connection_errors(self):
466
+ """
467
+ Verify that the flags session is configured to retry on connection errors.
468
+
469
+ The urllib3 Retry adapter with connect=2 and read=2 automatically
470
+ retries on network-level failures (DNS failures, connection refused,
471
+ connection reset, etc.) up to 2 times each.
472
+ """
473
+ from posthog.request import _build_flags_session
474
+
475
+ session = _build_flags_session()
476
+
477
+ # Get the adapter for https://
478
+ adapter = session.get_adapter("https://test.posthog.com")
479
+
480
+ # Verify retry configuration
481
+ retry = adapter.max_retries
482
+ self.assertEqual(retry.total, 2, "Should have 2 total retries")
483
+ self.assertEqual(retry.connect, 2, "Should retry connection errors twice")
484
+ self.assertEqual(retry.read, 2, "Should retry read errors twice")
485
+ self.assertIn("POST", retry.allowed_methods, "Should allow POST retries")
486
+
487
+ def test_flags_session_retries_on_server_errors(self):
488
+ """
489
+ Verify that transient server errors (5xx) trigger retries.
490
+
491
+ This tests the status_forcelist configuration which specifies
492
+ which HTTP status codes should trigger a retry.
493
+ """
494
+ from posthog.request import _build_flags_session, RETRY_STATUS_FORCELIST
495
+
496
+ session = _build_flags_session()
497
+ adapter = session.get_adapter("https://test.posthog.com")
498
+ retry = adapter.max_retries
499
+
500
+ # Verify the status codes that trigger retries
501
+ self.assertEqual(
502
+ set(retry.status_forcelist),
503
+ set(RETRY_STATUS_FORCELIST),
504
+ "Should retry on transient server errors",
505
+ )
506
+
507
+ # Verify specific codes are included
508
+ self.assertIn(500, retry.status_forcelist)
509
+ self.assertIn(502, retry.status_forcelist)
510
+ self.assertIn(503, retry.status_forcelist)
511
+ self.assertIn(504, retry.status_forcelist)
512
+
513
+ # Verify rate limits and quota errors are NOT retried
514
+ self.assertNotIn(429, retry.status_forcelist)
515
+ self.assertNotIn(402, retry.status_forcelist)
516
+
517
+ def test_flags_session_has_backoff(self):
518
+ """
519
+ Verify that retries use exponential backoff to avoid thundering herd.
520
+ """
521
+ from posthog.request import _build_flags_session
522
+
523
+ session = _build_flags_session()
524
+ adapter = session.get_adapter("https://test.posthog.com")
525
+ retry = adapter.max_retries
526
+
527
+ self.assertEqual(
528
+ retry.backoff_factor,
529
+ 0.5,
530
+ "Should use 0.5s backoff factor (0.5s, 1s delays)",
531
+ )
532
+
533
+
534
+ class TestFlagsSessionRetryIntegration(unittest.TestCase):
535
+ """Integration tests that verify actual retry behavior with a local server."""
536
+
537
+ def test_retries_on_503_then_succeeds(self):
538
+ """
539
+ Verify that 503 errors trigger retries and eventually succeed.
540
+
541
+ Uses a local HTTP server that fails twice with 503, then succeeds.
542
+ This tests the full retry flow including backoff timing.
543
+ """
544
+ import threading
545
+ from http.server import HTTPServer, BaseHTTPRequestHandler
546
+ from socketserver import ThreadingMixIn
547
+ from urllib3.util.retry import Retry
548
+ from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
549
+
550
+ request_count = 0
551
+
552
+ class RetryTestHandler(BaseHTTPRequestHandler):
553
+ protocol_version = "HTTP/1.1"
554
+
555
+ def do_POST(self):
556
+ nonlocal request_count
557
+ request_count += 1
558
+
559
+ # Read and discard request body to prevent connection issues
560
+ content_length = int(self.headers.get("Content-Length", 0))
561
+ if content_length > 0:
562
+ self.rfile.read(content_length)
563
+
564
+ if request_count <= 2:
565
+ self.send_response(503)
566
+ self.send_header("Content-Type", "application/json")
567
+ body = b'{"error": "Service unavailable"}'
568
+ self.send_header("Content-Length", str(len(body)))
569
+ self.end_headers()
570
+ self.wfile.write(body)
571
+ else:
572
+ self.send_response(200)
573
+ self.send_header("Content-Type", "application/json")
574
+ body = (
575
+ b'{"featureFlags": {"test": true}, "featureFlagPayloads": {}}'
576
+ )
577
+ self.send_header("Content-Length", str(len(body)))
578
+ self.end_headers()
579
+ self.wfile.write(body)
580
+
581
+ def log_message(self, format, *args):
582
+ pass # Suppress logging
583
+
584
+ # Use ThreadingMixIn for cleaner shutdown
585
+ class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
586
+ daemon_threads = True
587
+
588
+ # Start server on a random available port
589
+ server = ThreadedHTTPServer(("127.0.0.1", 0), RetryTestHandler)
590
+ port = server.server_address[1]
591
+ server_thread = threading.Thread(target=server.serve_forever)
592
+ server_thread.daemon = True
593
+ server_thread.start()
594
+
595
+ try:
596
+ # Build session with same retry config as _build_flags_session
597
+ # but mounted on http:// for local testing
598
+ adapter = HTTPAdapterWithSocketOptions(
599
+ max_retries=Retry(
600
+ total=2,
601
+ connect=2,
602
+ read=2,
603
+ backoff_factor=0.01, # Fast backoff for testing
604
+ status_forcelist=RETRY_STATUS_FORCELIST,
605
+ allowed_methods=["POST"],
606
+ ),
607
+ )
608
+ session = requests.Session()
609
+ session.mount("http://", adapter)
610
+
611
+ response = session.post(
612
+ f"http://127.0.0.1:{port}/flags/?v=2",
613
+ json={"distinct_id": "user123"},
614
+ timeout=5,
615
+ )
616
+
617
+ # Should succeed on 3rd attempt
618
+ self.assertEqual(response.status_code, 200)
619
+ self.assertEqual(request_count, 3) # 1 initial + 2 retries
620
+ finally:
621
+ server.shutdown()
622
+ server.server_close()
623
+
624
+ def test_connection_errors_are_retried(self):
625
+ """
626
+ Verify that connection errors (no server) trigger retries.
627
+
628
+ Binds a socket to get a guaranteed available port, then closes it
629
+ so connection attempts fail with ConnectionError.
630
+ """
631
+ import socket
632
+ import time
633
+ from urllib3.util.retry import Retry
634
+ from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
635
+
636
+ # Get an available port by binding then closing a socket
637
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
638
+ sock.bind(("127.0.0.1", 0))
639
+ port = sock.getsockname()[1]
640
+ sock.close() # Port is now available but nothing is listening
641
+
642
+ adapter = HTTPAdapterWithSocketOptions(
643
+ max_retries=Retry(
644
+ total=2,
645
+ connect=2,
646
+ read=2,
647
+ backoff_factor=0.05, # Very fast for testing
648
+ status_forcelist=RETRY_STATUS_FORCELIST,
649
+ allowed_methods=["POST"],
650
+ ),
651
+ )
652
+ session = requests.Session()
653
+ session.mount("http://", adapter)
654
+
655
+ start = time.time()
656
+ with self.assertRaises(requests.exceptions.ConnectionError):
657
+ session.post(
658
+ f"http://127.0.0.1:{port}/flags/?v=2",
659
+ json={"distinct_id": "user123"},
660
+ timeout=1,
661
+ )
662
+ elapsed = time.time() - start
663
+
664
+ # With 3 attempts and backoff, should take more than instant
665
+ # but less than timeout (confirms retries happened)
666
+ self.assertGreater(elapsed, 0.05, "Should have some delay from retries")
@@ -1,4 +1,4 @@
1
- VERSION = "7.3.1"
1
+ VERSION = "7.4.1"
2
2
 
3
3
  if __name__ == "__main__":
4
4
  print(VERSION, end="") # noqa: T201
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthog
3
- Version: 7.3.1
3
+ Version: 7.4.1
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes