posthoganalytics 7.4.3__tar.gz → 7.5.0__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 (65) hide show
  1. {posthoganalytics-7.4.3/posthoganalytics.egg-info → posthoganalytics-7.5.0}/PKG-INFO +1 -1
  2. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/__init__.py +16 -0
  3. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/langchain/callbacks.py +46 -9
  4. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/utils.py +202 -173
  5. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/contexts.py +5 -6
  6. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_contexts.py +26 -0
  7. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/version.py +1 -1
  8. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0/posthoganalytics.egg-info}/PKG-INFO +1 -1
  9. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/LICENSE +0 -0
  10. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/MANIFEST.in +0 -0
  11. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/README.md +0 -0
  12. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/__init__.py +0 -0
  13. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/anthropic/__init__.py +0 -0
  14. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/anthropic/anthropic.py +0 -0
  15. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/anthropic/anthropic_async.py +0 -0
  16. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/anthropic/anthropic_converter.py +0 -0
  17. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/anthropic/anthropic_providers.py +0 -0
  18. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/gemini/__init__.py +0 -0
  19. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/gemini/gemini.py +0 -0
  20. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/gemini/gemini_async.py +0 -0
  21. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/gemini/gemini_converter.py +0 -0
  22. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/langchain/__init__.py +0 -0
  23. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/openai/__init__.py +0 -0
  24. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/openai/openai.py +0 -0
  25. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/openai/openai_async.py +0 -0
  26. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/openai/openai_converter.py +0 -0
  27. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/openai/openai_providers.py +0 -0
  28. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/sanitization.py +0 -0
  29. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/ai/types.py +0 -0
  30. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/args.py +0 -0
  31. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/client.py +0 -0
  32. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/consumer.py +0 -0
  33. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/exception_capture.py +0 -0
  34. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/exception_utils.py +0 -0
  35. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/feature_flags.py +0 -0
  36. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/flag_definition_cache.py +0 -0
  37. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/integrations/__init__.py +0 -0
  38. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/integrations/django.py +0 -0
  39. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/poller.py +0 -0
  40. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/py.typed +0 -0
  41. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/request.py +0 -0
  42. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/__init__.py +0 -0
  43. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_before_send.py +0 -0
  44. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_client.py +0 -0
  45. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_consumer.py +0 -0
  46. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_exception_capture.py +0 -0
  47. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_feature_flag.py +0 -0
  48. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_feature_flag_result.py +0 -0
  49. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_feature_flags.py +0 -0
  50. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_flag_definition_cache.py +0 -0
  51. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_module.py +0 -0
  52. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_request.py +0 -0
  53. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_size_limited_dict.py +0 -0
  54. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_types.py +0 -0
  55. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/test/test_utils.py +0 -0
  56. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/types.py +0 -0
  57. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics/utils.py +0 -0
  58. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics.egg-info/SOURCES.txt +0 -0
  59. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics.egg-info/dependency_links.txt +0 -0
  60. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics.egg-info/requires.txt +0 -0
  61. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/posthoganalytics.egg-info/top_level.txt +0 -0
  62. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/pyproject.toml +0 -0
  63. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/setup.cfg +0 -0
  64. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/setup.py +0 -0
  65. {posthoganalytics-7.4.3 → posthoganalytics-7.5.0}/setup_analytics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: posthoganalytics
3
- Version: 7.4.3
3
+ Version: 7.5.0
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog
@@ -29,6 +29,9 @@ from posthoganalytics.contexts import (
29
29
  from posthoganalytics.contexts import (
30
30
  tag as inner_tag,
31
31
  )
32
+ from posthoganalytics.contexts import (
33
+ get_tags as inner_get_tags,
34
+ )
32
35
  from posthoganalytics.exception_utils import (
33
36
  DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
34
37
  DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
@@ -190,6 +193,19 @@ def tag(name: str, value: Any):
190
193
  return inner_tag(name, value)
191
194
 
192
195
 
196
+ def get_tags() -> Dict[str, Any]:
197
+ """
198
+ Get all tags from the current context.
199
+
200
+ Returns:
201
+ Dict of all tags in the current context
202
+
203
+ Category:
204
+ Contexts
205
+ """
206
+ return inner_get_tags()
207
+
208
+
193
209
  """Settings."""
194
210
  api_key = None # type: Optional[str]
195
211
  host = None # type: Optional[str]
@@ -22,8 +22,8 @@ from uuid import UUID
22
22
 
23
23
  try:
24
24
  # LangChain 1.0+ and modern 0.x with langchain-core
25
- from langchain_core.callbacks.base import BaseCallbackHandler
26
25
  from langchain_core.agents import AgentAction, AgentFinish
26
+ from langchain_core.callbacks.base import BaseCallbackHandler
27
27
  except (ImportError, ModuleNotFoundError):
28
28
  # Fallback for older LangChain versions
29
29
  from langchain.callbacks.base import BaseCallbackHandler
@@ -35,15 +35,15 @@ from langchain_core.messages import (
35
35
  FunctionMessage,
36
36
  HumanMessage,
37
37
  SystemMessage,
38
- ToolMessage,
39
38
  ToolCall,
39
+ ToolMessage,
40
40
  )
41
41
  from langchain_core.outputs import ChatGeneration, LLMResult
42
42
  from pydantic import BaseModel
43
43
 
44
44
  from posthoganalytics import setup
45
- from posthoganalytics.ai.utils import get_model_params, with_privacy_mode
46
45
  from posthoganalytics.ai.sanitization import sanitize_langchain
46
+ from posthoganalytics.ai.utils import get_model_params, with_privacy_mode
47
47
  from posthoganalytics.client import Client
48
48
 
49
49
  log = logging.getLogger("posthog")
@@ -506,6 +506,14 @@ class CallbackHandler(BaseCallbackHandler):
506
506
  if isinstance(outputs, BaseException):
507
507
  event_properties["$ai_error"] = _stringify_exception(outputs)
508
508
  event_properties["$ai_is_error"] = True
509
+ event_properties = _capture_exception_and_update_properties(
510
+ self._ph_client,
511
+ outputs,
512
+ self._distinct_id,
513
+ self._groups,
514
+ event_properties,
515
+ )
516
+
509
517
  elif outputs is not None:
510
518
  event_properties["$ai_output_state"] = with_privacy_mode(
511
519
  self._ph_client, self._privacy_mode, outputs
@@ -576,10 +584,24 @@ class CallbackHandler(BaseCallbackHandler):
576
584
  if run.tools:
577
585
  event_properties["$ai_tools"] = run.tools
578
586
 
587
+ if self._properties:
588
+ event_properties.update(self._properties)
589
+
590
+ if self._distinct_id is None:
591
+ event_properties["$process_person_profile"] = False
592
+
579
593
  if isinstance(output, BaseException):
580
594
  event_properties["$ai_http_status"] = _get_http_status(output)
581
595
  event_properties["$ai_error"] = _stringify_exception(output)
582
596
  event_properties["$ai_is_error"] = True
597
+
598
+ event_properties = _capture_exception_and_update_properties(
599
+ self._ph_client,
600
+ output,
601
+ self._distinct_id,
602
+ self._groups,
603
+ event_properties,
604
+ )
583
605
  else:
584
606
  # Add usage
585
607
  usage = _parse_usage(output, run.provider, run.model)
@@ -607,12 +629,6 @@ class CallbackHandler(BaseCallbackHandler):
607
629
  self._ph_client, self._privacy_mode, completions
608
630
  )
609
631
 
610
- if self._properties:
611
- event_properties.update(self._properties)
612
-
613
- if self._distinct_id is None:
614
- event_properties["$process_person_profile"] = False
615
-
616
632
  self._ph_client.capture(
617
633
  distinct_id=self._distinct_id or trace_id,
618
634
  event="$ai_generation",
@@ -863,6 +879,27 @@ def _parse_usage(
863
879
  return llm_usage
864
880
 
865
881
 
882
+ def _capture_exception_and_update_properties(
883
+ client: Client,
884
+ exception: BaseException,
885
+ distinct_id: Optional[Union[str, int, UUID]],
886
+ groups: Optional[Dict[str, Any]],
887
+ event_properties: Dict[str, Any],
888
+ ):
889
+ if client.enable_exception_autocapture:
890
+ exception_id = client.capture_exception(
891
+ exception,
892
+ distinct_id=distinct_id,
893
+ groups=groups,
894
+ properties=event_properties,
895
+ )
896
+
897
+ if exception_id:
898
+ event_properties["$exception_event_id"] = exception_id
899
+
900
+ return event_properties
901
+
902
+
866
903
  def _get_http_status(error: BaseException) -> int:
867
904
  # OpenAI: https://github.com/openai/openai-python/blob/main/src/openai/_exceptions.py
868
905
  # Anthropic: https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_exceptions.py
@@ -2,14 +2,15 @@ import time
2
2
  import uuid
3
3
  from typing import Any, Callable, Dict, List, Optional, cast
4
4
 
5
- from posthoganalytics.client import Client as PostHogClient
6
- from posthoganalytics.ai.types import FormattedMessage, StreamingEventData, TokenUsage
5
+ from posthoganalytics import get_tags, identify_context, new_context, tag
7
6
  from posthoganalytics.ai.sanitization import (
8
- sanitize_openai,
9
7
  sanitize_anthropic,
10
8
  sanitize_gemini,
11
9
  sanitize_langchain,
10
+ sanitize_openai,
12
11
  )
12
+ from posthoganalytics.ai.types import FormattedMessage, StreamingEventData, TokenUsage
13
+ from posthoganalytics.client import Client as PostHogClient
13
14
 
14
15
 
15
16
  def merge_usage_stats(
@@ -256,94 +257,108 @@ def call_llm_and_track_usage(
256
257
  usage: TokenUsage = TokenUsage()
257
258
  error_params: Dict[str, Any] = {}
258
259
 
259
- try:
260
- response = call_method(**kwargs)
261
- except Exception as exc:
262
- error = exc
263
- http_status = getattr(
264
- exc, "status_code", 0
265
- ) # default to 0 becuase its likely an SDK error
266
- error_params = {
267
- "$ai_is_error": True,
268
- "$ai_error": exc.__str__(),
269
- }
270
- finally:
271
- end_time = time.time()
272
- latency = end_time - start_time
273
-
274
- if posthog_trace_id is None:
275
- posthog_trace_id = str(uuid.uuid4())
276
-
277
- if response and (
278
- hasattr(response, "usage")
279
- or (provider == "gemini" and hasattr(response, "usage_metadata"))
280
- ):
281
- usage = get_usage(response, provider)
282
-
283
- messages = merge_system_prompt(kwargs, provider)
284
- sanitized_messages = sanitize_messages(messages, provider)
285
-
286
- event_properties = {
287
- "$ai_provider": provider,
288
- "$ai_model": kwargs.get("model") or getattr(response, "model", None),
289
- "$ai_model_parameters": get_model_params(kwargs),
290
- "$ai_input": with_privacy_mode(
291
- ph_client, posthog_privacy_mode, sanitized_messages
292
- ),
293
- "$ai_output_choices": with_privacy_mode(
294
- ph_client, posthog_privacy_mode, format_response(response, provider)
295
- ),
296
- "$ai_http_status": http_status,
297
- "$ai_input_tokens": usage.get("input_tokens", 0),
298
- "$ai_output_tokens": usage.get("output_tokens", 0),
299
- "$ai_latency": latency,
300
- "$ai_trace_id": posthog_trace_id,
301
- "$ai_base_url": str(base_url),
302
- **(posthog_properties or {}),
303
- **(error_params or {}),
304
- }
305
-
306
- available_tool_calls = extract_available_tool_calls(provider, kwargs)
307
-
308
- if available_tool_calls:
309
- event_properties["$ai_tools"] = available_tool_calls
310
-
311
- cache_read = usage.get("cache_read_input_tokens")
312
- if cache_read is not None and cache_read > 0:
313
- event_properties["$ai_cache_read_input_tokens"] = cache_read
314
-
315
- cache_creation = usage.get("cache_creation_input_tokens")
316
- if cache_creation is not None and cache_creation > 0:
317
- event_properties["$ai_cache_creation_input_tokens"] = cache_creation
318
-
319
- reasoning = usage.get("reasoning_tokens")
320
- if reasoning is not None and reasoning > 0:
321
- event_properties["$ai_reasoning_tokens"] = reasoning
322
-
323
- web_search_count = usage.get("web_search_count")
324
- if web_search_count is not None and web_search_count > 0:
325
- event_properties["$ai_web_search_count"] = web_search_count
326
-
327
- if posthog_distinct_id is None:
328
- event_properties["$process_person_profile"] = False
329
-
330
- # Process instructions for Responses API
331
- if provider == "openai" and kwargs.get("instructions") is not None:
332
- event_properties["$ai_instructions"] = with_privacy_mode(
333
- ph_client, posthog_privacy_mode, kwargs.get("instructions")
260
+ with new_context(client=ph_client, capture_exceptions=False):
261
+ if posthog_distinct_id:
262
+ identify_context(posthog_distinct_id)
263
+
264
+ try:
265
+ response = call_method(**kwargs)
266
+ except Exception as exc:
267
+ error = exc
268
+ http_status = getattr(
269
+ exc, "status_code", 0
270
+ ) # default to 0 becuase its likely an SDK error
271
+ error_params = {
272
+ "$ai_is_error": True,
273
+ "$ai_error": exc.__str__(),
274
+ }
275
+ # TODO: Add exception capture for OpenAI/Anthropic/Gemini wrappers when
276
+ # enable_exception_autocapture is True, similar to LangChain callbacks.
277
+ # See _capture_exception_and_update_properties in langchain/callbacks.py
278
+ finally:
279
+ end_time = time.time()
280
+ latency = end_time - start_time
281
+
282
+ if posthog_trace_id is None:
283
+ posthog_trace_id = str(uuid.uuid4())
284
+
285
+ if response and (
286
+ hasattr(response, "usage")
287
+ or (provider == "gemini" and hasattr(response, "usage_metadata"))
288
+ ):
289
+ usage = get_usage(response, provider)
290
+
291
+ messages = merge_system_prompt(kwargs, provider)
292
+ sanitized_messages = sanitize_messages(messages, provider)
293
+
294
+ tag("$ai_provider", provider)
295
+ tag("$ai_model", kwargs.get("model") or getattr(response, "model", None))
296
+ tag("$ai_model_parameters", get_model_params(kwargs))
297
+ tag(
298
+ "$ai_input",
299
+ with_privacy_mode(ph_client, posthog_privacy_mode, sanitized_messages),
334
300
  )
335
-
336
- # send the event to posthog
337
- if hasattr(ph_client, "capture") and callable(ph_client.capture):
338
- ph_client.capture(
339
- distinct_id=posthog_distinct_id or posthog_trace_id,
340
- event="$ai_generation",
341
- properties=event_properties,
342
- groups=posthog_groups,
301
+ tag(
302
+ "$ai_output_choices",
303
+ with_privacy_mode(
304
+ ph_client, posthog_privacy_mode, format_response(response, provider)
305
+ ),
343
306
  )
307
+ tag("$ai_http_status", http_status)
308
+ tag("$ai_input_tokens", usage.get("input_tokens", 0))
309
+ tag("$ai_output_tokens", usage.get("output_tokens", 0))
310
+ tag("$ai_latency", latency)
311
+ tag("$ai_trace_id", posthog_trace_id)
312
+ tag("$ai_base_url", str(base_url))
313
+
314
+ available_tool_calls = extract_available_tool_calls(provider, kwargs)
315
+
316
+ if available_tool_calls:
317
+ tag("$ai_tools", available_tool_calls)
318
+
319
+ cache_read = usage.get("cache_read_input_tokens")
320
+ if cache_read is not None and cache_read > 0:
321
+ tag("$ai_cache_read_input_tokens", cache_read)
322
+
323
+ cache_creation = usage.get("cache_creation_input_tokens")
324
+ if cache_creation is not None and cache_creation > 0:
325
+ tag("$ai_cache_creation_input_tokens", cache_creation)
326
+
327
+ reasoning = usage.get("reasoning_tokens")
328
+ if reasoning is not None and reasoning > 0:
329
+ tag("$ai_reasoning_tokens", reasoning)
330
+
331
+ web_search_count = usage.get("web_search_count")
332
+ if web_search_count is not None and web_search_count > 0:
333
+ tag("$ai_web_search_count", web_search_count)
334
+
335
+ if posthog_distinct_id is None:
336
+ tag("$process_person_profile", False)
337
+
338
+ # Process instructions for Responses API
339
+ if provider == "openai" and kwargs.get("instructions") is not None:
340
+ tag(
341
+ "$ai_instructions",
342
+ with_privacy_mode(
343
+ ph_client, posthog_privacy_mode, kwargs.get("instructions")
344
+ ),
345
+ )
346
+
347
+ # send the event to posthog
348
+ if hasattr(ph_client, "capture") and callable(ph_client.capture):
349
+ ph_client.capture(
350
+ distinct_id=posthog_distinct_id or posthog_trace_id,
351
+ event="$ai_generation",
352
+ properties={
353
+ **get_tags(),
354
+ **(posthog_properties or {}),
355
+ **(error_params or {}),
356
+ },
357
+ groups=posthog_groups,
358
+ )
344
359
 
345
- if error:
346
- raise error
360
+ if error:
361
+ raise error
347
362
 
348
363
  return response
349
364
 
@@ -367,94 +382,108 @@ async def call_llm_and_track_usage_async(
367
382
  usage: TokenUsage = TokenUsage()
368
383
  error_params: Dict[str, Any] = {}
369
384
 
370
- try:
371
- response = await call_async_method(**kwargs)
372
- except Exception as exc:
373
- error = exc
374
- http_status = getattr(
375
- exc, "status_code", 0
376
- ) # default to 0 because its likely an SDK error
377
- error_params = {
378
- "$ai_is_error": True,
379
- "$ai_error": exc.__str__(),
380
- }
381
- finally:
382
- end_time = time.time()
383
- latency = end_time - start_time
384
-
385
- if posthog_trace_id is None:
386
- posthog_trace_id = str(uuid.uuid4())
387
-
388
- if response and (
389
- hasattr(response, "usage")
390
- or (provider == "gemini" and hasattr(response, "usage_metadata"))
391
- ):
392
- usage = get_usage(response, provider)
393
-
394
- messages = merge_system_prompt(kwargs, provider)
395
- sanitized_messages = sanitize_messages(messages, provider)
396
-
397
- event_properties = {
398
- "$ai_provider": provider,
399
- "$ai_model": kwargs.get("model") or getattr(response, "model", None),
400
- "$ai_model_parameters": get_model_params(kwargs),
401
- "$ai_input": with_privacy_mode(
402
- ph_client, posthog_privacy_mode, sanitized_messages
403
- ),
404
- "$ai_output_choices": with_privacy_mode(
405
- ph_client, posthog_privacy_mode, format_response(response, provider)
406
- ),
407
- "$ai_http_status": http_status,
408
- "$ai_input_tokens": usage.get("input_tokens", 0),
409
- "$ai_output_tokens": usage.get("output_tokens", 0),
410
- "$ai_latency": latency,
411
- "$ai_trace_id": posthog_trace_id,
412
- "$ai_base_url": str(base_url),
413
- **(posthog_properties or {}),
414
- **(error_params or {}),
415
- }
416
-
417
- available_tool_calls = extract_available_tool_calls(provider, kwargs)
418
-
419
- if available_tool_calls:
420
- event_properties["$ai_tools"] = available_tool_calls
421
-
422
- cache_read = usage.get("cache_read_input_tokens")
423
- if cache_read is not None and cache_read > 0:
424
- event_properties["$ai_cache_read_input_tokens"] = cache_read
425
-
426
- cache_creation = usage.get("cache_creation_input_tokens")
427
- if cache_creation is not None and cache_creation > 0:
428
- event_properties["$ai_cache_creation_input_tokens"] = cache_creation
429
-
430
- reasoning = usage.get("reasoning_tokens")
431
- if reasoning is not None and reasoning > 0:
432
- event_properties["$ai_reasoning_tokens"] = reasoning
433
-
434
- web_search_count = usage.get("web_search_count")
435
- if web_search_count is not None and web_search_count > 0:
436
- event_properties["$ai_web_search_count"] = web_search_count
437
-
438
- if posthog_distinct_id is None:
439
- event_properties["$process_person_profile"] = False
440
-
441
- # Process instructions for Responses API
442
- if provider == "openai" and kwargs.get("instructions") is not None:
443
- event_properties["$ai_instructions"] = with_privacy_mode(
444
- ph_client, posthog_privacy_mode, kwargs.get("instructions")
385
+ with new_context(client=ph_client, capture_exceptions=False):
386
+ if posthog_distinct_id:
387
+ identify_context(posthog_distinct_id)
388
+
389
+ try:
390
+ response = await call_async_method(**kwargs)
391
+ except Exception as exc:
392
+ error = exc
393
+ http_status = getattr(
394
+ exc, "status_code", 0
395
+ ) # default to 0 because its likely an SDK error
396
+ error_params = {
397
+ "$ai_is_error": True,
398
+ "$ai_error": exc.__str__(),
399
+ }
400
+ # TODO: Add exception capture for OpenAI/Anthropic/Gemini wrappers when
401
+ # enable_exception_autocapture is True, similar to LangChain callbacks.
402
+ # See _capture_exception_and_update_properties in langchain/callbacks.py
403
+ finally:
404
+ end_time = time.time()
405
+ latency = end_time - start_time
406
+
407
+ if posthog_trace_id is None:
408
+ posthog_trace_id = str(uuid.uuid4())
409
+
410
+ if response and (
411
+ hasattr(response, "usage")
412
+ or (provider == "gemini" and hasattr(response, "usage_metadata"))
413
+ ):
414
+ usage = get_usage(response, provider)
415
+
416
+ messages = merge_system_prompt(kwargs, provider)
417
+ sanitized_messages = sanitize_messages(messages, provider)
418
+
419
+ tag("$ai_provider", provider)
420
+ tag("$ai_model", kwargs.get("model") or getattr(response, "model", None))
421
+ tag("$ai_model_parameters", get_model_params(kwargs))
422
+ tag(
423
+ "$ai_input",
424
+ with_privacy_mode(ph_client, posthog_privacy_mode, sanitized_messages),
445
425
  )
446
-
447
- # send the event to posthog
448
- if hasattr(ph_client, "capture") and callable(ph_client.capture):
449
- ph_client.capture(
450
- distinct_id=posthog_distinct_id or posthog_trace_id,
451
- event="$ai_generation",
452
- properties=event_properties,
453
- groups=posthog_groups,
426
+ tag(
427
+ "$ai_output_choices",
428
+ with_privacy_mode(
429
+ ph_client, posthog_privacy_mode, format_response(response, provider)
430
+ ),
454
431
  )
432
+ tag("$ai_http_status", http_status)
433
+ tag("$ai_input_tokens", usage.get("input_tokens", 0))
434
+ tag("$ai_output_tokens", usage.get("output_tokens", 0))
435
+ tag("$ai_latency", latency)
436
+ tag("$ai_trace_id", posthog_trace_id)
437
+ tag("$ai_base_url", str(base_url))
438
+
439
+ available_tool_calls = extract_available_tool_calls(provider, kwargs)
440
+
441
+ if available_tool_calls:
442
+ tag("$ai_tools", available_tool_calls)
443
+
444
+ cache_read = usage.get("cache_read_input_tokens")
445
+ if cache_read is not None and cache_read > 0:
446
+ tag("$ai_cache_read_input_tokens", cache_read)
447
+
448
+ cache_creation = usage.get("cache_creation_input_tokens")
449
+ if cache_creation is not None and cache_creation > 0:
450
+ tag("$ai_cache_creation_input_tokens", cache_creation)
451
+
452
+ reasoning = usage.get("reasoning_tokens")
453
+ if reasoning is not None and reasoning > 0:
454
+ tag("$ai_reasoning_tokens", reasoning)
455
+
456
+ web_search_count = usage.get("web_search_count")
457
+ if web_search_count is not None and web_search_count > 0:
458
+ tag("$ai_web_search_count", web_search_count)
459
+
460
+ if posthog_distinct_id is None:
461
+ tag("$process_person_profile", False)
462
+
463
+ # Process instructions for Responses API
464
+ if provider == "openai" and kwargs.get("instructions") is not None:
465
+ tag(
466
+ "$ai_instructions",
467
+ with_privacy_mode(
468
+ ph_client, posthog_privacy_mode, kwargs.get("instructions")
469
+ ),
470
+ )
471
+
472
+ # send the event to posthog
473
+ if hasattr(ph_client, "capture") and callable(ph_client.capture):
474
+ ph_client.capture(
475
+ distinct_id=posthog_distinct_id or posthog_trace_id,
476
+ event="$ai_generation",
477
+ properties={
478
+ **get_tags(),
479
+ **(posthog_properties or {}),
480
+ **(error_params or {}),
481
+ },
482
+ groups=posthog_groups,
483
+ )
455
484
 
456
- if error:
457
- raise error
485
+ if error:
486
+ raise error
458
487
 
459
488
  return response
460
489
 
@@ -62,14 +62,13 @@ class ContextScope:
62
62
  return None
63
63
 
64
64
  def collect_tags(self) -> Dict[str, Any]:
65
- tags = self.tags.copy()
66
65
  if self.parent and not self.fresh:
67
66
  # We want child tags to take precedence over parent tags,
68
- # so we can't use a simple update here, instead collecting
69
- # the parent tags and then updating with the child tags.
70
- new_tags = self.parent.collect_tags()
71
- tags.update(new_tags)
72
- return tags
67
+ # so collect parent tags first, then update with child tags.
68
+ tags = self.parent.collect_tags()
69
+ tags.update(self.tags)
70
+ return tags
71
+ return self.tags.copy()
73
72
 
74
73
  def get_capture_exception_code_variables(self) -> Optional[bool]:
75
74
  if self.capture_exception_code_variables is not None:
@@ -191,6 +191,32 @@ class TestContexts(unittest.TestCase):
191
191
  assert get_context_distinct_id() == "user123"
192
192
  assert get_context_session_id() == "session456"
193
193
 
194
+ def test_child_tags_override_parent_tags_in_non_fresh_context(self):
195
+ with new_context(fresh=True):
196
+ tag("shared_key", "parent_value")
197
+ tag("parent_only", "parent")
198
+
199
+ with new_context(fresh=False):
200
+ # Child should inherit parent tags
201
+ assert get_tags()["parent_only"] == "parent"
202
+
203
+ # Child sets same key - should override parent
204
+ tag("shared_key", "child_value")
205
+ tag("child_only", "child")
206
+
207
+ tags = get_tags()
208
+ # Child value should win for shared key
209
+ assert tags["shared_key"] == "child_value"
210
+ # Both parent and child tags should be present
211
+ assert tags["parent_only"] == "parent"
212
+ assert tags["child_only"] == "child"
213
+
214
+ # Parent context should be unchanged
215
+ parent_tags = get_tags()
216
+ assert parent_tags["shared_key"] == "parent_value"
217
+ assert parent_tags["parent_only"] == "parent"
218
+ assert "child_only" not in parent_tags
219
+
194
220
  def test_scoped_decorator_with_context_ids(self):
195
221
  @scoped()
196
222
  def function_with_context():
@@ -1,4 +1,4 @@
1
- VERSION = "7.4.3"
1
+ VERSION = "7.5.0"
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: posthoganalytics
3
- Version: 7.4.3
3
+ Version: 7.5.0
4
4
  Summary: Integrate PostHog into any python application.
5
5
  Home-page: https://github.com/posthog/posthog-python
6
6
  Author: Posthog