posthoganalytics 6.7.14__py3-none-any.whl → 6.9.0__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.
- posthoganalytics/__init__.py +29 -2
- posthoganalytics/ai/anthropic/anthropic_async.py +30 -67
- posthoganalytics/ai/anthropic/anthropic_converter.py +40 -0
- posthoganalytics/ai/gemini/gemini_converter.py +73 -3
- posthoganalytics/ai/openai/openai_async.py +19 -0
- posthoganalytics/ai/openai/openai_converter.py +124 -0
- posthoganalytics/ai/types.py +1 -0
- posthoganalytics/ai/utils.py +30 -0
- posthoganalytics/client.py +49 -0
- posthoganalytics/contexts.py +81 -0
- posthoganalytics/exception_utils.py +192 -0
- posthoganalytics/test/test_exception_capture.py +300 -0
- posthoganalytics/version.py +1 -1
- {posthoganalytics-6.7.14.dist-info → posthoganalytics-6.9.0.dist-info}/METADATA +1 -1
- {posthoganalytics-6.7.14.dist-info → posthoganalytics-6.9.0.dist-info}/RECORD +18 -18
- {posthoganalytics-6.7.14.dist-info → posthoganalytics-6.9.0.dist-info}/WHEEL +0 -0
- {posthoganalytics-6.7.14.dist-info → posthoganalytics-6.9.0.dist-info}/licenses/LICENSE +0 -0
- {posthoganalytics-6.7.14.dist-info → posthoganalytics-6.9.0.dist-info}/top_level.txt +0 -0
posthoganalytics/__init__.py
CHANGED
|
@@ -10,6 +10,9 @@ from posthoganalytics.contexts import (
|
|
|
10
10
|
tag as inner_tag,
|
|
11
11
|
set_context_session as inner_set_context_session,
|
|
12
12
|
identify_context as inner_identify_context,
|
|
13
|
+
set_capture_exception_code_variables_context as inner_set_capture_exception_code_variables_context,
|
|
14
|
+
set_code_variables_mask_patterns_context as inner_set_code_variables_mask_patterns_context,
|
|
15
|
+
set_code_variables_ignore_patterns_context as inner_set_code_variables_ignore_patterns_context,
|
|
13
16
|
)
|
|
14
17
|
from posthoganalytics.feature_flags import InconclusiveMatchError, RequiresServerEvaluation
|
|
15
18
|
from posthoganalytics.types import FeatureFlag, FlagsAndPayloads, FeatureFlagResult
|
|
@@ -20,13 +23,14 @@ __version__ = VERSION
|
|
|
20
23
|
"""Context management."""
|
|
21
24
|
|
|
22
25
|
|
|
23
|
-
def new_context(fresh=False, capture_exceptions=True):
|
|
26
|
+
def new_context(fresh=False, capture_exceptions=True, client=None):
|
|
24
27
|
"""
|
|
25
28
|
Create a new context scope that will be active for the duration of the with block.
|
|
26
29
|
|
|
27
30
|
Args:
|
|
28
31
|
fresh: Whether to start with a fresh context (default: False)
|
|
29
32
|
capture_exceptions: Whether to capture exceptions raised within the context (default: True)
|
|
33
|
+
client: Optional Posthog client instance to use for this context (default: None)
|
|
30
34
|
|
|
31
35
|
Examples:
|
|
32
36
|
```python
|
|
@@ -39,7 +43,9 @@ def new_context(fresh=False, capture_exceptions=True):
|
|
|
39
43
|
Category:
|
|
40
44
|
Contexts
|
|
41
45
|
"""
|
|
42
|
-
return inner_new_context(
|
|
46
|
+
return inner_new_context(
|
|
47
|
+
fresh=fresh, capture_exceptions=capture_exceptions, client=client
|
|
48
|
+
)
|
|
43
49
|
|
|
44
50
|
|
|
45
51
|
def scoped(fresh=False, capture_exceptions=True):
|
|
@@ -103,6 +109,27 @@ def identify_context(distinct_id: str):
|
|
|
103
109
|
return inner_identify_context(distinct_id)
|
|
104
110
|
|
|
105
111
|
|
|
112
|
+
def set_capture_exception_code_variables_context(enabled: bool):
|
|
113
|
+
"""
|
|
114
|
+
Set whether code variables are captured for the current context.
|
|
115
|
+
"""
|
|
116
|
+
return inner_set_capture_exception_code_variables_context(enabled)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def set_code_variables_mask_patterns_context(mask_patterns: list):
|
|
120
|
+
"""
|
|
121
|
+
Variable names matching these patterns will be masked with *** when capturing code variables.
|
|
122
|
+
"""
|
|
123
|
+
return inner_set_code_variables_mask_patterns_context(mask_patterns)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def set_code_variables_ignore_patterns_context(ignore_patterns: list):
|
|
127
|
+
"""
|
|
128
|
+
Variable names matching these patterns will be ignored completely when capturing code variables.
|
|
129
|
+
"""
|
|
130
|
+
return inner_set_code_variables_ignore_patterns_context(ignore_patterns)
|
|
131
|
+
|
|
132
|
+
|
|
106
133
|
def tag(name: str, value: Any):
|
|
107
134
|
"""
|
|
108
135
|
Add a tag to the current context.
|
|
@@ -14,14 +14,9 @@ from posthoganalytics import setup
|
|
|
14
14
|
from posthoganalytics.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
|
|
15
15
|
from posthoganalytics.ai.utils import (
|
|
16
16
|
call_llm_and_track_usage_async,
|
|
17
|
-
extract_available_tool_calls,
|
|
18
|
-
get_model_params,
|
|
19
|
-
merge_system_prompt,
|
|
20
17
|
merge_usage_stats,
|
|
21
|
-
with_privacy_mode,
|
|
22
18
|
)
|
|
23
19
|
from posthoganalytics.ai.anthropic.anthropic_converter import (
|
|
24
|
-
format_anthropic_streaming_content,
|
|
25
20
|
extract_anthropic_usage_from_event,
|
|
26
21
|
handle_anthropic_content_block_start,
|
|
27
22
|
handle_anthropic_text_delta,
|
|
@@ -220,66 +215,34 @@ class AsyncWrappedMessages(AsyncMessages):
|
|
|
220
215
|
content_blocks: List[StreamingContentBlock],
|
|
221
216
|
accumulated_content: str,
|
|
222
217
|
):
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
"$ai_provider": "anthropic",
|
|
243
|
-
"$ai_model": kwargs.get("model"),
|
|
244
|
-
"$ai_model_parameters": get_model_params(kwargs),
|
|
245
|
-
"$ai_input": with_privacy_mode(
|
|
246
|
-
self._client._ph_client,
|
|
247
|
-
posthog_privacy_mode,
|
|
248
|
-
sanitize_anthropic(merge_system_prompt(kwargs, "anthropic")),
|
|
249
|
-
),
|
|
250
|
-
"$ai_output_choices": with_privacy_mode(
|
|
251
|
-
self._client._ph_client,
|
|
252
|
-
posthog_privacy_mode,
|
|
253
|
-
formatted_output,
|
|
254
|
-
),
|
|
255
|
-
"$ai_http_status": 200,
|
|
256
|
-
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
|
|
257
|
-
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
|
|
258
|
-
"$ai_cache_read_input_tokens": usage_stats.get(
|
|
259
|
-
"cache_read_input_tokens", 0
|
|
260
|
-
),
|
|
261
|
-
"$ai_cache_creation_input_tokens": usage_stats.get(
|
|
262
|
-
"cache_creation_input_tokens", 0
|
|
218
|
+
from posthoganalytics.ai.types import StreamingEventData
|
|
219
|
+
from posthoganalytics.ai.anthropic.anthropic_converter import (
|
|
220
|
+
format_anthropic_streaming_input,
|
|
221
|
+
format_anthropic_streaming_output_complete,
|
|
222
|
+
)
|
|
223
|
+
from posthoganalytics.ai.utils import capture_streaming_event
|
|
224
|
+
|
|
225
|
+
# Prepare standardized event data
|
|
226
|
+
formatted_input = format_anthropic_streaming_input(kwargs)
|
|
227
|
+
sanitized_input = sanitize_anthropic(formatted_input)
|
|
228
|
+
|
|
229
|
+
event_data = StreamingEventData(
|
|
230
|
+
provider="anthropic",
|
|
231
|
+
model=kwargs.get("model", "unknown"),
|
|
232
|
+
base_url=str(self._client.base_url),
|
|
233
|
+
kwargs=kwargs,
|
|
234
|
+
formatted_input=sanitized_input,
|
|
235
|
+
formatted_output=format_anthropic_streaming_output_complete(
|
|
236
|
+
content_blocks, accumulated_content
|
|
263
237
|
),
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if posthog_distinct_id is None:
|
|
277
|
-
event_properties["$process_person_profile"] = False
|
|
278
|
-
|
|
279
|
-
if hasattr(self._client._ph_client, "capture"):
|
|
280
|
-
self._client._ph_client.capture(
|
|
281
|
-
distinct_id=posthog_distinct_id or posthog_trace_id,
|
|
282
|
-
event="$ai_generation",
|
|
283
|
-
properties=event_properties,
|
|
284
|
-
groups=posthog_groups,
|
|
285
|
-
)
|
|
238
|
+
usage_stats=usage_stats,
|
|
239
|
+
latency=latency,
|
|
240
|
+
distinct_id=posthog_distinct_id,
|
|
241
|
+
trace_id=posthog_trace_id,
|
|
242
|
+
properties=posthog_properties,
|
|
243
|
+
privacy_mode=posthog_privacy_mode,
|
|
244
|
+
groups=posthog_groups,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Use the common capture function
|
|
248
|
+
capture_streaming_event(self._client._ph_client, event_data)
|
|
@@ -163,6 +163,32 @@ def format_anthropic_streaming_content(
|
|
|
163
163
|
return formatted
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
def extract_anthropic_web_search_count(response: Any) -> int:
|
|
167
|
+
"""
|
|
168
|
+
Extract web search count from Anthropic response.
|
|
169
|
+
|
|
170
|
+
Anthropic provides exact web search counts via usage.server_tool_use.web_search_requests.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
response: The response from Anthropic API
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Number of web search requests (0 if none)
|
|
177
|
+
"""
|
|
178
|
+
if not hasattr(response, "usage"):
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
if not hasattr(response.usage, "server_tool_use"):
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
server_tool_use = response.usage.server_tool_use
|
|
185
|
+
|
|
186
|
+
if hasattr(server_tool_use, "web_search_requests"):
|
|
187
|
+
return max(0, int(getattr(server_tool_use, "web_search_requests", 0)))
|
|
188
|
+
|
|
189
|
+
return 0
|
|
190
|
+
|
|
191
|
+
|
|
166
192
|
def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
|
|
167
193
|
"""
|
|
168
194
|
Extract usage from a full Anthropic response (non-streaming).
|
|
@@ -191,6 +217,10 @@ def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
|
|
|
191
217
|
if cache_creation and cache_creation > 0:
|
|
192
218
|
result["cache_creation_input_tokens"] = cache_creation
|
|
193
219
|
|
|
220
|
+
web_search_count = extract_anthropic_web_search_count(response)
|
|
221
|
+
if web_search_count > 0:
|
|
222
|
+
result["web_search_count"] = web_search_count
|
|
223
|
+
|
|
194
224
|
return result
|
|
195
225
|
|
|
196
226
|
|
|
@@ -222,6 +252,16 @@ def extract_anthropic_usage_from_event(event: Any) -> TokenUsage:
|
|
|
222
252
|
if hasattr(event, "usage") and event.usage:
|
|
223
253
|
usage["output_tokens"] = getattr(event.usage, "output_tokens", 0)
|
|
224
254
|
|
|
255
|
+
# Extract web search count from usage
|
|
256
|
+
if hasattr(event.usage, "server_tool_use"):
|
|
257
|
+
server_tool_use = event.usage.server_tool_use
|
|
258
|
+
if hasattr(server_tool_use, "web_search_requests"):
|
|
259
|
+
web_search_count = int(
|
|
260
|
+
getattr(server_tool_use, "web_search_requests", 0)
|
|
261
|
+
)
|
|
262
|
+
if web_search_count > 0:
|
|
263
|
+
usage["web_search_count"] = web_search_count
|
|
264
|
+
|
|
225
265
|
return usage
|
|
226
266
|
|
|
227
267
|
|
|
@@ -338,6 +338,61 @@ def format_gemini_input(contents: Any) -> List[FormattedMessage]:
|
|
|
338
338
|
return [_format_object_message(contents)]
|
|
339
339
|
|
|
340
340
|
|
|
341
|
+
def extract_gemini_web_search_count(response: Any) -> int:
|
|
342
|
+
"""
|
|
343
|
+
Extract web search count from Gemini response.
|
|
344
|
+
|
|
345
|
+
Gemini bills per request that uses grounding, not per query.
|
|
346
|
+
Returns 1 if grounding_metadata is present with actual search data, 0 otherwise.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
response: The response from Gemini API
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
1 if web search/grounding was used, 0 otherwise
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
# Check for grounding_metadata in candidates
|
|
356
|
+
if hasattr(response, "candidates"):
|
|
357
|
+
for candidate in response.candidates:
|
|
358
|
+
if (
|
|
359
|
+
hasattr(candidate, "grounding_metadata")
|
|
360
|
+
and candidate.grounding_metadata
|
|
361
|
+
):
|
|
362
|
+
grounding_metadata = candidate.grounding_metadata
|
|
363
|
+
|
|
364
|
+
# Check if web_search_queries exists and is non-empty
|
|
365
|
+
if hasattr(grounding_metadata, "web_search_queries"):
|
|
366
|
+
queries = grounding_metadata.web_search_queries
|
|
367
|
+
|
|
368
|
+
if queries is not None and len(queries) > 0:
|
|
369
|
+
return 1
|
|
370
|
+
|
|
371
|
+
# Check if grounding_chunks exists and is non-empty
|
|
372
|
+
if hasattr(grounding_metadata, "grounding_chunks"):
|
|
373
|
+
chunks = grounding_metadata.grounding_chunks
|
|
374
|
+
|
|
375
|
+
if chunks is not None and len(chunks) > 0:
|
|
376
|
+
return 1
|
|
377
|
+
|
|
378
|
+
# Also check for google_search or grounding in function call names
|
|
379
|
+
if hasattr(candidate, "content") and candidate.content:
|
|
380
|
+
if hasattr(candidate.content, "parts") and candidate.content.parts:
|
|
381
|
+
for part in candidate.content.parts:
|
|
382
|
+
if hasattr(part, "function_call") and part.function_call:
|
|
383
|
+
function_name = getattr(
|
|
384
|
+
part.function_call, "name", ""
|
|
385
|
+
).lower()
|
|
386
|
+
|
|
387
|
+
if (
|
|
388
|
+
"google_search" in function_name
|
|
389
|
+
or "grounding" in function_name
|
|
390
|
+
):
|
|
391
|
+
return 1
|
|
392
|
+
|
|
393
|
+
return 0
|
|
394
|
+
|
|
395
|
+
|
|
341
396
|
def _extract_usage_from_metadata(metadata: Any) -> TokenUsage:
|
|
342
397
|
"""
|
|
343
398
|
Common logic to extract usage from Gemini metadata.
|
|
@@ -382,7 +437,14 @@ def extract_gemini_usage_from_response(response: Any) -> TokenUsage:
|
|
|
382
437
|
if not hasattr(response, "usage_metadata") or not response.usage_metadata:
|
|
383
438
|
return TokenUsage(input_tokens=0, output_tokens=0)
|
|
384
439
|
|
|
385
|
-
|
|
440
|
+
usage = _extract_usage_from_metadata(response.usage_metadata)
|
|
441
|
+
|
|
442
|
+
# Add web search count if present
|
|
443
|
+
web_search_count = extract_gemini_web_search_count(response)
|
|
444
|
+
if web_search_count > 0:
|
|
445
|
+
usage["web_search_count"] = web_search_count
|
|
446
|
+
|
|
447
|
+
return usage
|
|
386
448
|
|
|
387
449
|
|
|
388
450
|
def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
|
|
@@ -398,11 +460,19 @@ def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
|
|
|
398
460
|
|
|
399
461
|
usage: TokenUsage = TokenUsage()
|
|
400
462
|
|
|
463
|
+
# Extract web search count from the chunk before checking for usage_metadata
|
|
464
|
+
# Web search indicators can appear on any chunk, not just those with usage data
|
|
465
|
+
web_search_count = extract_gemini_web_search_count(chunk)
|
|
466
|
+
if web_search_count > 0:
|
|
467
|
+
usage["web_search_count"] = web_search_count
|
|
468
|
+
|
|
401
469
|
if not hasattr(chunk, "usage_metadata") or not chunk.usage_metadata:
|
|
402
470
|
return usage
|
|
403
471
|
|
|
404
|
-
|
|
405
|
-
|
|
472
|
+
usage_from_metadata = _extract_usage_from_metadata(chunk.usage_metadata)
|
|
473
|
+
|
|
474
|
+
# Merge the usage from metadata with any web search count we found
|
|
475
|
+
usage.update(usage_from_metadata)
|
|
406
476
|
|
|
407
477
|
return usage
|
|
408
478
|
|
|
@@ -213,6 +213,15 @@ class WrappedResponses:
|
|
|
213
213
|
**(posthog_properties or {}),
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
# Add web search count if present
|
|
217
|
+
web_search_count = usage_stats.get("web_search_count")
|
|
218
|
+
if (
|
|
219
|
+
web_search_count is not None
|
|
220
|
+
and isinstance(web_search_count, int)
|
|
221
|
+
and web_search_count > 0
|
|
222
|
+
):
|
|
223
|
+
event_properties["$ai_web_search_count"] = web_search_count
|
|
224
|
+
|
|
216
225
|
if available_tool_calls:
|
|
217
226
|
event_properties["$ai_tools"] = available_tool_calls
|
|
218
227
|
|
|
@@ -444,6 +453,16 @@ class WrappedCompletions:
|
|
|
444
453
|
**(posthog_properties or {}),
|
|
445
454
|
}
|
|
446
455
|
|
|
456
|
+
# Add web search count if present
|
|
457
|
+
web_search_count = usage_stats.get("web_search_count")
|
|
458
|
+
|
|
459
|
+
if (
|
|
460
|
+
web_search_count is not None
|
|
461
|
+
and isinstance(web_search_count, int)
|
|
462
|
+
and web_search_count > 0
|
|
463
|
+
):
|
|
464
|
+
event_properties["$ai_web_search_count"] = web_search_count
|
|
465
|
+
|
|
447
466
|
if available_tool_calls:
|
|
448
467
|
event_properties["$ai_tools"] = available_tool_calls
|
|
449
468
|
|
|
@@ -255,6 +255,113 @@ def format_openai_streaming_content(
|
|
|
255
255
|
return formatted
|
|
256
256
|
|
|
257
257
|
|
|
258
|
+
def extract_openai_web_search_count(response: Any) -> int:
|
|
259
|
+
"""
|
|
260
|
+
Extract web search count from OpenAI response.
|
|
261
|
+
|
|
262
|
+
Uses a two-tier detection strategy:
|
|
263
|
+
1. Priority 1 (exact count): Check for output[].type == "web_search_call" (Responses API)
|
|
264
|
+
2. Priority 2 (binary detection): Check for various web search indicators:
|
|
265
|
+
- Root-level citations, search_results, or usage.search_context_size (Perplexity)
|
|
266
|
+
- Annotations with type "url_citation" in choices/output (including delta for streaming)
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
response: The response from OpenAI API
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Number of web search requests (exact count or binary 1/0)
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
# Priority 1: Check for exact count in Responses API output
|
|
276
|
+
if hasattr(response, "output"):
|
|
277
|
+
web_search_count = 0
|
|
278
|
+
|
|
279
|
+
for item in response.output:
|
|
280
|
+
if hasattr(item, "type") and item.type == "web_search_call":
|
|
281
|
+
web_search_count += 1
|
|
282
|
+
|
|
283
|
+
web_search_count = max(0, web_search_count)
|
|
284
|
+
|
|
285
|
+
if web_search_count > 0:
|
|
286
|
+
return web_search_count
|
|
287
|
+
|
|
288
|
+
# Priority 2: Binary detection (returns 1 or 0)
|
|
289
|
+
|
|
290
|
+
# Check root-level indicators (Perplexity)
|
|
291
|
+
if hasattr(response, "citations"):
|
|
292
|
+
citations = getattr(response, "citations")
|
|
293
|
+
|
|
294
|
+
if citations and len(citations) > 0:
|
|
295
|
+
return 1
|
|
296
|
+
|
|
297
|
+
if hasattr(response, "search_results"):
|
|
298
|
+
search_results = getattr(response, "search_results")
|
|
299
|
+
|
|
300
|
+
if search_results and len(search_results) > 0:
|
|
301
|
+
return 1
|
|
302
|
+
|
|
303
|
+
if hasattr(response, "usage") and hasattr(response.usage, "search_context_size"):
|
|
304
|
+
if response.usage.search_context_size:
|
|
305
|
+
return 1
|
|
306
|
+
|
|
307
|
+
# Check for url_citation annotations in choices (Chat Completions)
|
|
308
|
+
if hasattr(response, "choices"):
|
|
309
|
+
for choice in response.choices:
|
|
310
|
+
# Check message.annotations (non-streaming or final chunk)
|
|
311
|
+
if hasattr(choice, "message") and hasattr(choice.message, "annotations"):
|
|
312
|
+
annotations = choice.message.annotations
|
|
313
|
+
|
|
314
|
+
if annotations:
|
|
315
|
+
for annotation in annotations:
|
|
316
|
+
# Support both dict and object formats
|
|
317
|
+
annotation_type = (
|
|
318
|
+
annotation.get("type")
|
|
319
|
+
if isinstance(annotation, dict)
|
|
320
|
+
else getattr(annotation, "type", None)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if annotation_type == "url_citation":
|
|
324
|
+
return 1
|
|
325
|
+
|
|
326
|
+
# Check delta.annotations (streaming chunks)
|
|
327
|
+
if hasattr(choice, "delta") and hasattr(choice.delta, "annotations"):
|
|
328
|
+
annotations = choice.delta.annotations
|
|
329
|
+
|
|
330
|
+
if annotations:
|
|
331
|
+
for annotation in annotations:
|
|
332
|
+
# Support both dict and object formats
|
|
333
|
+
annotation_type = (
|
|
334
|
+
annotation.get("type")
|
|
335
|
+
if isinstance(annotation, dict)
|
|
336
|
+
else getattr(annotation, "type", None)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if annotation_type == "url_citation":
|
|
340
|
+
return 1
|
|
341
|
+
|
|
342
|
+
# Check for url_citation annotations in output (Responses API)
|
|
343
|
+
if hasattr(response, "output"):
|
|
344
|
+
for item in response.output:
|
|
345
|
+
if hasattr(item, "content") and isinstance(item.content, list):
|
|
346
|
+
for content_item in item.content:
|
|
347
|
+
if hasattr(content_item, "annotations"):
|
|
348
|
+
annotations = content_item.annotations
|
|
349
|
+
|
|
350
|
+
if annotations:
|
|
351
|
+
for annotation in annotations:
|
|
352
|
+
# Support both dict and object formats
|
|
353
|
+
annotation_type = (
|
|
354
|
+
annotation.get("type")
|
|
355
|
+
if isinstance(annotation, dict)
|
|
356
|
+
else getattr(annotation, "type", None)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if annotation_type == "url_citation":
|
|
360
|
+
return 1
|
|
361
|
+
|
|
362
|
+
return 0
|
|
363
|
+
|
|
364
|
+
|
|
258
365
|
def extract_openai_usage_from_response(response: Any) -> TokenUsage:
|
|
259
366
|
"""
|
|
260
367
|
Extract usage statistics from a full OpenAI response (non-streaming).
|
|
@@ -312,6 +419,10 @@ def extract_openai_usage_from_response(response: Any) -> TokenUsage:
|
|
|
312
419
|
if reasoning_tokens > 0:
|
|
313
420
|
result["reasoning_tokens"] = reasoning_tokens
|
|
314
421
|
|
|
422
|
+
web_search_count = extract_openai_web_search_count(response)
|
|
423
|
+
if web_search_count > 0:
|
|
424
|
+
result["web_search_count"] = web_search_count
|
|
425
|
+
|
|
315
426
|
return result
|
|
316
427
|
|
|
317
428
|
|
|
@@ -334,6 +445,13 @@ def extract_openai_usage_from_chunk(
|
|
|
334
445
|
usage: TokenUsage = TokenUsage()
|
|
335
446
|
|
|
336
447
|
if provider_type == "chat":
|
|
448
|
+
# Extract web search count from the chunk before checking for usage
|
|
449
|
+
# Web search indicators (citations, annotations) can appear on any chunk,
|
|
450
|
+
# not just those with usage data
|
|
451
|
+
web_search_count = extract_openai_web_search_count(chunk)
|
|
452
|
+
if web_search_count > 0:
|
|
453
|
+
usage["web_search_count"] = web_search_count
|
|
454
|
+
|
|
337
455
|
if not hasattr(chunk, "usage") or not chunk.usage:
|
|
338
456
|
return usage
|
|
339
457
|
|
|
@@ -386,6 +504,12 @@ def extract_openai_usage_from_chunk(
|
|
|
386
504
|
response_usage.output_tokens_details.reasoning_tokens
|
|
387
505
|
)
|
|
388
506
|
|
|
507
|
+
# Extract web search count from the complete response
|
|
508
|
+
if hasattr(chunk, "response"):
|
|
509
|
+
web_search_count = extract_openai_web_search_count(chunk.response)
|
|
510
|
+
if web_search_count > 0:
|
|
511
|
+
usage["web_search_count"] = web_search_count
|
|
512
|
+
|
|
389
513
|
return usage
|
|
390
514
|
|
|
391
515
|
|
posthoganalytics/ai/types.py
CHANGED
posthoganalytics/ai/utils.py
CHANGED
|
@@ -53,6 +53,12 @@ def merge_usage_stats(
|
|
|
53
53
|
if source_reasoning is not None:
|
|
54
54
|
current = target.get("reasoning_tokens") or 0
|
|
55
55
|
target["reasoning_tokens"] = current + source_reasoning
|
|
56
|
+
|
|
57
|
+
source_web_search = source.get("web_search_count")
|
|
58
|
+
if source_web_search is not None:
|
|
59
|
+
current = target.get("web_search_count") or 0
|
|
60
|
+
target["web_search_count"] = max(current, source_web_search)
|
|
61
|
+
|
|
56
62
|
elif mode == "cumulative":
|
|
57
63
|
# Replace with latest values (already cumulative)
|
|
58
64
|
if source.get("input_tokens") is not None:
|
|
@@ -67,6 +73,9 @@ def merge_usage_stats(
|
|
|
67
73
|
]
|
|
68
74
|
if source.get("reasoning_tokens") is not None:
|
|
69
75
|
target["reasoning_tokens"] = source["reasoning_tokens"]
|
|
76
|
+
if source.get("web_search_count") is not None:
|
|
77
|
+
target["web_search_count"] = source["web_search_count"]
|
|
78
|
+
|
|
70
79
|
else:
|
|
71
80
|
raise ValueError(f"Invalid mode: {mode}. Must be 'incremental' or 'cumulative'")
|
|
72
81
|
|
|
@@ -311,6 +320,10 @@ def call_llm_and_track_usage(
|
|
|
311
320
|
if reasoning is not None and reasoning > 0:
|
|
312
321
|
event_properties["$ai_reasoning_tokens"] = reasoning
|
|
313
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
|
+
|
|
314
327
|
if posthog_distinct_id is None:
|
|
315
328
|
event_properties["$process_person_profile"] = False
|
|
316
329
|
|
|
@@ -414,6 +427,14 @@ async def call_llm_and_track_usage_async(
|
|
|
414
427
|
if cache_creation is not None and cache_creation > 0:
|
|
415
428
|
event_properties["$ai_cache_creation_input_tokens"] = cache_creation
|
|
416
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
|
+
|
|
417
438
|
if posthog_distinct_id is None:
|
|
418
439
|
event_properties["$process_person_profile"] = False
|
|
419
440
|
|
|
@@ -535,6 +556,15 @@ def capture_streaming_event(
|
|
|
535
556
|
if value is not None and isinstance(value, int) and value > 0:
|
|
536
557
|
event_properties[f"$ai_{field}"] = value
|
|
537
558
|
|
|
559
|
+
# Add web search count if present (all providers)
|
|
560
|
+
web_search_count = event_data["usage_stats"].get("web_search_count")
|
|
561
|
+
if (
|
|
562
|
+
web_search_count is not None
|
|
563
|
+
and isinstance(web_search_count, int)
|
|
564
|
+
and web_search_count > 0
|
|
565
|
+
):
|
|
566
|
+
event_properties["$ai_web_search_count"] = web_search_count
|
|
567
|
+
|
|
538
568
|
# Handle provider-specific fields
|
|
539
569
|
if (
|
|
540
570
|
event_data["provider"] == "openai"
|
posthoganalytics/client.py
CHANGED
|
@@ -19,6 +19,9 @@ from posthoganalytics.exception_utils import (
|
|
|
19
19
|
handle_in_app,
|
|
20
20
|
exception_is_already_captured,
|
|
21
21
|
mark_exception_as_captured,
|
|
22
|
+
try_attach_code_variables_to_frames,
|
|
23
|
+
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
|
|
24
|
+
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
|
|
22
25
|
)
|
|
23
26
|
from posthoganalytics.feature_flags import (
|
|
24
27
|
InconclusiveMatchError,
|
|
@@ -39,6 +42,9 @@ from posthoganalytics.contexts import (
|
|
|
39
42
|
_get_current_context,
|
|
40
43
|
get_context_distinct_id,
|
|
41
44
|
get_context_session_id,
|
|
45
|
+
get_capture_exception_code_variables_context,
|
|
46
|
+
get_code_variables_mask_patterns_context,
|
|
47
|
+
get_code_variables_ignore_patterns_context,
|
|
42
48
|
new_context,
|
|
43
49
|
)
|
|
44
50
|
from posthoganalytics.types import (
|
|
@@ -178,6 +184,9 @@ class Client(object):
|
|
|
178
184
|
before_send=None,
|
|
179
185
|
flag_fallback_cache_url=None,
|
|
180
186
|
enable_local_evaluation=True,
|
|
187
|
+
capture_exception_code_variables=False,
|
|
188
|
+
code_variables_mask_patterns=None,
|
|
189
|
+
code_variables_ignore_patterns=None,
|
|
181
190
|
):
|
|
182
191
|
"""
|
|
183
192
|
Initialize a new PostHog client instance.
|
|
@@ -233,6 +242,18 @@ class Client(object):
|
|
|
233
242
|
self.privacy_mode = privacy_mode
|
|
234
243
|
self.enable_local_evaluation = enable_local_evaluation
|
|
235
244
|
|
|
245
|
+
self.capture_exception_code_variables = capture_exception_code_variables
|
|
246
|
+
self.code_variables_mask_patterns = (
|
|
247
|
+
code_variables_mask_patterns
|
|
248
|
+
if code_variables_mask_patterns is not None
|
|
249
|
+
else DEFAULT_CODE_VARIABLES_MASK_PATTERNS
|
|
250
|
+
)
|
|
251
|
+
self.code_variables_ignore_patterns = (
|
|
252
|
+
code_variables_ignore_patterns
|
|
253
|
+
if code_variables_ignore_patterns is not None
|
|
254
|
+
else DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS
|
|
255
|
+
)
|
|
256
|
+
|
|
236
257
|
if project_root is None:
|
|
237
258
|
try:
|
|
238
259
|
project_root = os.getcwd()
|
|
@@ -979,6 +1000,34 @@ class Client(object):
|
|
|
979
1000
|
**properties,
|
|
980
1001
|
}
|
|
981
1002
|
|
|
1003
|
+
context_enabled = get_capture_exception_code_variables_context()
|
|
1004
|
+
context_mask = get_code_variables_mask_patterns_context()
|
|
1005
|
+
context_ignore = get_code_variables_ignore_patterns_context()
|
|
1006
|
+
|
|
1007
|
+
enabled = (
|
|
1008
|
+
context_enabled
|
|
1009
|
+
if context_enabled is not None
|
|
1010
|
+
else self.capture_exception_code_variables
|
|
1011
|
+
)
|
|
1012
|
+
mask_patterns = (
|
|
1013
|
+
context_mask
|
|
1014
|
+
if context_mask is not None
|
|
1015
|
+
else self.code_variables_mask_patterns
|
|
1016
|
+
)
|
|
1017
|
+
ignore_patterns = (
|
|
1018
|
+
context_ignore
|
|
1019
|
+
if context_ignore is not None
|
|
1020
|
+
else self.code_variables_ignore_patterns
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
if enabled:
|
|
1024
|
+
try_attach_code_variables_to_frames(
|
|
1025
|
+
all_exceptions_with_trace_and_in_app,
|
|
1026
|
+
exc_info,
|
|
1027
|
+
mask_patterns=mask_patterns,
|
|
1028
|
+
ignore_patterns=ignore_patterns,
|
|
1029
|
+
)
|
|
1030
|
+
|
|
982
1031
|
if self.log_captured_exceptions:
|
|
983
1032
|
self.log.exception(exception, extra=kwargs)
|
|
984
1033
|
|