Compare commits

...
15 Commits
Author SHA1 Message Date
Aleksander BłaszkiewiczandGitHub f75c5efeec feat: use repr in code variables (#372) 2025-11-15 13:43:55 +01:00
Tue HaulundandGitHub 65785b892e fix: avoid overwriting consumer list when using more than one consumer (#370)
fix: avoid overwriting consumer list when using more than one consumer thread
2025-11-12 13:05:05 +01:00
github-actions[bot] 6dde2bf9e5 Update generated references 2025-11-11 18:13:36 +00:00
Carlos MarchalandGitHub 48203364a9 chore(llma): update SDKs (#367) 2025-11-11 18:12:33 +00:00
github-actions[bot] 805c308841 Update generated references 2025-11-11 17:57:26 +00:00
Alessandro PogliaghiandGitHub 898654a174 feat(ph-ai): PostHog properties dict in GenerationMetadata (#366) 2025-11-11 17:56:32 +00:00
github-actions[bot] 499d54570c Update generated references 2025-11-11 08:57:51 +00:00
Carlos MarchalandGitHub 6c815dffc3 fix(llma): Langchain cache token double subtraction for non-Anthropic providers (#369) 2025-11-11 09:56:57 +01:00
Julian BezandGitHub 3a1b8e49cd fix: add ruff check to CI and fix all lint errors (#360)
- Add 'Lint with ruff' step to CI workflow (was only running format check)
- Fix F401: Add explicit re-exports for public API types in __init__.py
- Fix F811: Rename duplicate test_openai_reasoning_tokens to test_openai_reasoning_tokens_o4_mini
- Fix E731: Convert lambda to def function in test_middleware.py
- Fix unused imports in client.py and test files (auto-fixed)

Without ruff check in CI, lint errors were accumulating on master undetected.
2025-11-10 12:45:52 +01:00
github-actions[bot] f3e5d7132f Update generated references 2025-11-07 15:57:55 +00:00
Aleksander BłaszkiewiczandGitHub 88f606994c fix: code variables without client (#368)
* fix: pass variables from init to client

* chore: version+changelog
2025-11-07 16:57:01 +01:00
Luke BeltonandGitHub a155e1dfd6 fix docstring for set (#364) 2025-11-06 16:02:49 +00:00
github-actions[bot] f648a5dfd7 Update generated references 2025-11-06 15:13:17 +00:00
Aleksander BłaszkiewiczandGitHub e309bd7149 feat: add code variables capture (#365)
* feat: add code variables capture

* feat: bump version and add changelog
2025-11-06 16:12:17 +01:00
github-actions[bot] 0cfd678857 Update generated references 2025-11-04 19:44:04 +00:00
23 changed files with 15655 additions and 71 deletions
+5 -1
View File
@@ -36,6 +36,10 @@ jobs:
run: |
ruff format --check .
- name: Lint with ruff
run: |
ruff check .
- name: Check types with mypy
run: |
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
@@ -45,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
python-version: ['3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
+34
View File
@@ -1,3 +1,37 @@
# 7.0.1 - 2025-11-15
Try to use repr() when formatting code variables
# 7.0.0 - 2025-11-11
NB Python 3.9 is no longer supported
- chore(llma): update LLM provider SDKs to latest major versions
- openai: 1.102.0 → 2.7.1
- anthropic: 0.64.0 → 0.72.0
- google-genai: 1.32.0 → 1.49.0
- langchain-core: 0.3.75 → 1.0.3
- langchain-openai: 0.3.32 → 1.0.2
- langchain-anthropic: 0.3.19 → 1.0.1
- langchain-community: 0.3.29 → 0.4.1
- langgraph: 0.6.6 → 1.0.2
# 6.9.3 - 2025-11-10
- feat(ph-ai): PostHog properties dict in GenerationMetadata
# 6.9.2 - 2025-11-10
- fix(llma): fix cache token double subtraction in Langchain for non-Anthropic providers causing negative costs
# 6.9.1 - 2025-11-07
- fix(error-tracking): pass code variables config from init to client
# 6.9.0 - 2025-11-06
- feat(error-tracking): add local variables capture
# 6.8.0 - 2025-11-03
- feat(llma): send web search calls to be used for LLM cost calculations
+2 -2
View File
@@ -30,8 +30,8 @@ We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
## PostHog recommends `uv` so...
```bash
uv python install 3.9.19
uv python pin 3.9.19
uv python install 3.12
uv python pin 3.12
uv venv
source env/bin/activate
uv sync --extra dev --extra test
+49 -4
View File
@@ -10,9 +10,23 @@ from posthog.contexts import (
tag as inner_tag,
set_context_session as inner_set_context_session,
identify_context as inner_identify_context,
set_capture_exception_code_variables_context as inner_set_capture_exception_code_variables_context,
set_code_variables_mask_patterns_context as inner_set_code_variables_mask_patterns_context,
set_code_variables_ignore_patterns_context as inner_set_code_variables_ignore_patterns_context,
)
from posthog.exception_utils import (
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
)
from posthog.feature_flags import (
InconclusiveMatchError as InconclusiveMatchError,
RequiresServerEvaluation as RequiresServerEvaluation,
)
from posthog.types import (
FeatureFlag,
FlagsAndPayloads,
FeatureFlagResult as FeatureFlagResult,
)
from posthog.feature_flags import InconclusiveMatchError, RequiresServerEvaluation
from posthog.types import FeatureFlag, FlagsAndPayloads, FeatureFlagResult
from posthog.version import VERSION
__version__ = VERSION
@@ -20,13 +34,14 @@ __version__ = VERSION
"""Context management."""
def new_context(fresh=False, capture_exceptions=True):
def new_context(fresh=False, capture_exceptions=True, client=None):
"""
Create a new context scope that will be active for the duration of the with block.
Args:
fresh: Whether to start with a fresh context (default: False)
capture_exceptions: Whether to capture exceptions raised within the context (default: True)
client: Optional Posthog client instance to use for this context (default: None)
Examples:
```python
@@ -39,7 +54,9 @@ def new_context(fresh=False, capture_exceptions=True):
Category:
Contexts
"""
return inner_new_context(fresh=fresh, capture_exceptions=capture_exceptions)
return inner_new_context(
fresh=fresh, capture_exceptions=capture_exceptions, client=client
)
def scoped(fresh=False, capture_exceptions=True):
@@ -103,6 +120,27 @@ def identify_context(distinct_id: str):
return inner_identify_context(distinct_id)
def set_capture_exception_code_variables_context(enabled: bool):
"""
Set whether code variables are captured for the current context.
"""
return inner_set_capture_exception_code_variables_context(enabled)
def set_code_variables_mask_patterns_context(mask_patterns: list):
"""
Variable names matching these patterns will be masked with *** when capturing code variables.
"""
return inner_set_code_variables_mask_patterns_context(mask_patterns)
def set_code_variables_ignore_patterns_context(ignore_patterns: list):
"""
Variable names matching these patterns will be ignored completely when capturing code variables.
"""
return inner_set_code_variables_ignore_patterns_context(ignore_patterns)
def tag(name: str, value: Any):
"""
Add a tag to the current context.
@@ -150,6 +188,10 @@ enable_local_evaluation = True # type: bool
default_client = None # type: Optional[Client]
capture_exception_code_variables = False
code_variables_mask_patterns = DEFAULT_CODE_VARIABLES_MASK_PATTERNS
code_variables_ignore_patterns = DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS
# NOTE - this and following functions take unpacked kwargs because we needed to make
# it impossible to write `posthog.capture(distinct-id, event-name)` - basically, to enforce
@@ -744,6 +786,9 @@ def setup() -> Client:
enable_exception_autocapture=enable_exception_autocapture,
log_captured_exceptions=log_captured_exceptions,
enable_local_evaluation=enable_local_evaluation,
capture_exception_code_variables=capture_exception_code_variables,
code_variables_mask_patterns=code_variables_mask_patterns,
code_variables_ignore_patterns=code_variables_ignore_patterns,
)
# always set incase user changes it
+38 -11
View File
@@ -1,8 +1,8 @@
try:
import langchain # noqa: F401
import langchain_core # noqa: F401
except ImportError:
raise ModuleNotFoundError(
"Please install LangChain to use this feature: 'pip install langchain'"
"Please install LangChain to use this feature: 'pip install langchain-core'"
)
import json
@@ -79,6 +79,8 @@ class GenerationMetadata(SpanMetadata):
"""Base URL of the provider's API used in the run."""
tools: Optional[List[Dict[str, Any]]] = None
"""Tools provided to the model."""
posthog_properties: Optional[Dict[str, Any]] = None
"""PostHog properties of the run."""
RunMetadata = Union[SpanMetadata, GenerationMetadata]
@@ -420,6 +422,8 @@ class CallbackHandler(BaseCallbackHandler):
generation.model = model
if provider := metadata.get("ls_provider"):
generation.provider = provider
generation.posthog_properties = metadata.get("posthog_properties")
try:
base_url = serialized["kwargs"]["openai_api_base"]
if base_url is not None:
@@ -566,6 +570,9 @@ class CallbackHandler(BaseCallbackHandler):
"$ai_framework": "langchain",
}
if isinstance(run.posthog_properties, dict):
event_properties.update(run.posthog_properties)
if run.tools:
event_properties["$ai_tools"] = run.tools
@@ -575,7 +582,7 @@ class CallbackHandler(BaseCallbackHandler):
event_properties["$ai_is_error"] = True
else:
# Add usage
usage = _parse_usage(output)
usage = _parse_usage(output, run.provider, run.model)
event_properties["$ai_input_tokens"] = usage.input_tokens
event_properties["$ai_output_tokens"] = usage.output_tokens
event_properties["$ai_cache_creation_input_tokens"] = (
@@ -696,6 +703,8 @@ class ModelUsage:
def _parse_usage_model(
usage: Union[BaseModel, dict],
provider: Optional[str] = None,
model: Optional[str] = None,
) -> ModelUsage:
if isinstance(usage, BaseModel):
usage = usage.__dict__
@@ -764,16 +773,30 @@ def _parse_usage_model(
for mapped_key, dataclass_key in field_mapping.items()
},
)
# In LangChain, input_tokens is the sum of input and cache read tokens.
# Our cost calculation expects them to be separate, for Anthropic.
if normalized_usage.input_tokens and normalized_usage.cache_read_tokens:
# For Anthropic providers, LangChain reports input_tokens as the sum of input and cache read tokens.
# Our cost calculation expects them to be separate for Anthropic, so we subtract cache tokens.
# For other providers (OpenAI, etc.), input_tokens already includes cache tokens as expected.
# Match logic consistent with plugin-server: exact match on provider OR substring match on model
is_anthropic = False
if provider and provider.lower() == "anthropic":
is_anthropic = True
elif model and "anthropic" in model.lower():
is_anthropic = True
if (
is_anthropic
and normalized_usage.input_tokens
and normalized_usage.cache_read_tokens
):
normalized_usage.input_tokens = max(
normalized_usage.input_tokens - normalized_usage.cache_read_tokens, 0
)
return normalized_usage
def _parse_usage(response: LLMResult) -> ModelUsage:
def _parse_usage(
response: LLMResult, provider: Optional[str] = None, model: Optional[str] = None
) -> ModelUsage:
# langchain-anthropic uses the usage field
llm_usage_keys = ["token_usage", "usage"]
llm_usage: ModelUsage = ModelUsage(
@@ -787,13 +810,15 @@ def _parse_usage(response: LLMResult) -> ModelUsage:
if response.llm_output is not None:
for key in llm_usage_keys:
if response.llm_output.get(key):
llm_usage = _parse_usage_model(response.llm_output[key])
llm_usage = _parse_usage_model(
response.llm_output[key], provider, model
)
break
if hasattr(response, "generations"):
for generation in response.generations:
if "usage" in generation:
llm_usage = _parse_usage_model(generation["usage"])
llm_usage = _parse_usage_model(generation["usage"], provider, model)
break
for generation_chunk in generation:
@@ -801,7 +826,9 @@ def _parse_usage(response: LLMResult) -> ModelUsage:
"usage_metadata" in generation_chunk.generation_info
):
llm_usage = _parse_usage_model(
generation_chunk.generation_info["usage_metadata"]
generation_chunk.generation_info["usage_metadata"],
provider,
model,
)
break
@@ -828,7 +855,7 @@ def _parse_usage(response: LLMResult) -> ModelUsage:
bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
)
if chunk_usage:
llm_usage = _parse_usage_model(chunk_usage)
llm_usage = _parse_usage_model(chunk_usage, provider, model)
break
return llm_usage
+53 -17
View File
@@ -19,6 +19,9 @@ from posthog.exception_utils import (
handle_in_app,
exception_is_already_captured,
mark_exception_as_captured,
try_attach_code_variables_to_frames,
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
)
from posthog.feature_flags import (
InconclusiveMatchError,
@@ -39,6 +42,9 @@ from posthog.contexts import (
_get_current_context,
get_context_distinct_id,
get_context_session_id,
get_capture_exception_code_variables_context,
get_code_variables_mask_patterns_context,
get_code_variables_ignore_patterns_context,
new_context,
)
from posthog.types import (
@@ -178,6 +184,9 @@ class Client(object):
before_send=None,
flag_fallback_cache_url=None,
enable_local_evaluation=True,
capture_exception_code_variables=False,
code_variables_mask_patterns=None,
code_variables_ignore_patterns=None,
):
"""
Initialize a new PostHog client instance.
@@ -233,6 +242,18 @@ class Client(object):
self.privacy_mode = privacy_mode
self.enable_local_evaluation = enable_local_evaluation
self.capture_exception_code_variables = capture_exception_code_variables
self.code_variables_mask_patterns = (
code_variables_mask_patterns
if code_variables_mask_patterns is not None
else DEFAULT_CODE_VARIABLES_MASK_PATTERNS
)
self.code_variables_ignore_patterns = (
code_variables_ignore_patterns
if code_variables_ignore_patterns is not None
else DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS
)
if project_root is None:
try:
project_root = os.getcwd()
@@ -274,8 +295,9 @@ class Client(object):
# to call flush().
if send:
atexit.register(self.join)
for n in range(thread):
self.consumers = []
self.consumers = []
for _ in range(thread):
consumer = Consumer(
self.queue,
self.api_key,
@@ -704,21 +726,7 @@ class Client(object):
Examples:
```python
# Set with distinct id
posthog.capture(
'event_name',
distinct_id='user-distinct-id',
properties={
'$set': {'name': 'Max Hedgehog'},
'$set_once': {'initial_url': '/blog'}
}
)
```
```python
# Set using context
from posthog import new_context, identify_context
with new_context():
identify_context('user-distinct-id')
posthog.capture('event_name')
posthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})
```
Category:
@@ -979,6 +987,34 @@ class Client(object):
**properties,
}
context_enabled = get_capture_exception_code_variables_context()
context_mask = get_code_variables_mask_patterns_context()
context_ignore = get_code_variables_ignore_patterns_context()
enabled = (
context_enabled
if context_enabled is not None
else self.capture_exception_code_variables
)
mask_patterns = (
context_mask
if context_mask is not None
else self.code_variables_mask_patterns
)
ignore_patterns = (
context_ignore
if context_ignore is not None
else self.code_variables_ignore_patterns
)
if enabled:
try_attach_code_variables_to_frames(
all_exceptions_with_trace_and_in_app,
exc_info,
mask_patterns=mask_patterns,
ignore_patterns=ignore_patterns,
)
if self.log_captured_exceptions:
self.log.exception(exception, extra=kwargs)
+81
View File
@@ -22,6 +22,9 @@ class ContextScope:
self.session_id: Optional[str] = None
self.distinct_id: Optional[str] = None
self.tags: Dict[str, Any] = {}
self.capture_exception_code_variables: Optional[bool] = None
self.code_variables_mask_patterns: Optional[list] = None
self.code_variables_ignore_patterns: Optional[list] = None
def set_session_id(self, session_id: str):
self.session_id = session_id
@@ -32,6 +35,15 @@ class ContextScope:
def add_tag(self, key: str, value: Any):
self.tags[key] = value
def set_capture_exception_code_variables(self, enabled: bool):
self.capture_exception_code_variables = enabled
def set_code_variables_mask_patterns(self, mask_patterns: list):
self.code_variables_mask_patterns = mask_patterns
def set_code_variables_ignore_patterns(self, ignore_patterns: list):
self.code_variables_ignore_patterns = ignore_patterns
def get_parent(self):
return self.parent
@@ -59,6 +71,27 @@ class ContextScope:
tags.update(new_tags)
return tags
def get_capture_exception_code_variables(self) -> Optional[bool]:
if self.capture_exception_code_variables is not None:
return self.capture_exception_code_variables
if self.parent is not None and not self.fresh:
return self.parent.get_capture_exception_code_variables()
return None
def get_code_variables_mask_patterns(self) -> Optional[list]:
if self.code_variables_mask_patterns is not None:
return self.code_variables_mask_patterns
if self.parent is not None and not self.fresh:
return self.parent.get_code_variables_mask_patterns()
return None
def get_code_variables_ignore_patterns(self) -> Optional[list]:
if self.code_variables_ignore_patterns is not None:
return self.code_variables_ignore_patterns
if self.parent is not None and not self.fresh:
return self.parent.get_code_variables_ignore_patterns()
return None
_context_stack: contextvars.ContextVar[Optional[ContextScope]] = contextvars.ContextVar(
"posthog_context_stack", default=None
@@ -243,6 +276,54 @@ def get_context_distinct_id() -> Optional[str]:
return None
def set_capture_exception_code_variables_context(enabled: bool) -> None:
"""
Set whether code variables are captured for the current context.
"""
current_context = _get_current_context()
if current_context:
current_context.set_capture_exception_code_variables(enabled)
def set_code_variables_mask_patterns_context(mask_patterns: list) -> None:
"""
Variable names matching these patterns will be masked with *** when capturing code variables.
"""
current_context = _get_current_context()
if current_context:
current_context.set_code_variables_mask_patterns(mask_patterns)
def set_code_variables_ignore_patterns_context(ignore_patterns: list) -> None:
"""
Variable names matching these patterns will be ignored completely when capturing code variables.
"""
current_context = _get_current_context()
if current_context:
current_context.set_code_variables_ignore_patterns(ignore_patterns)
def get_capture_exception_code_variables_context() -> Optional[bool]:
current_context = _get_current_context()
if current_context:
return current_context.get_capture_exception_code_variables()
return None
def get_code_variables_mask_patterns_context() -> Optional[list]:
current_context = _get_current_context()
if current_context:
return current_context.get_code_variables_mask_patterns()
return None
def get_code_variables_ignore_patterns_context() -> Optional[list]:
current_context = _get_current_context()
if current_context:
return current_context.get_code_variables_ignore_patterns()
return None
F = TypeVar("F", bound=Callable[..., Any])
+203
View File
@@ -5,6 +5,7 @@
# 💖open source (under MIT License)
# We want to keep payloads as similar to Sentry as possible for easy interoperability
import json
import linecache
import os
import re
@@ -26,6 +27,7 @@ from typing import ( # noqa: F401
Union,
cast,
TYPE_CHECKING,
Pattern,
)
from posthog.args import ExcInfo, ExceptionArg # noqa: F401
@@ -40,6 +42,42 @@ except ImportError:
DEFAULT_MAX_VALUE_LENGTH = 1024
DEFAULT_CODE_VARIABLES_MASK_PATTERNS = [
r"(?i).*password.*",
r"(?i).*secret.*",
r"(?i).*passwd.*",
r"(?i).*pwd.*",
r"(?i).*api_key.*",
r"(?i).*apikey.*",
r"(?i).*auth.*",
r"(?i).*credentials.*",
r"(?i).*privatekey.*",
r"(?i).*private_key.*",
r"(?i).*token.*",
]
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
CODE_VARIABLES_REDACTED_VALUE = "$$_posthog_redacted_based_on_masking_rules_$$"
DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT = 20 * 1024
class VariableSizeLimiter:
def __init__(self, max_size=DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT):
self.max_size = max_size
self.current_size = 0
def can_add(self, size):
return self.current_size + size <= self.max_size
def add(self, size):
self.current_size += size
def get_remaining_space(self):
return self.max_size - self.current_size
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
Event = TypedDict(
@@ -884,3 +922,168 @@ def strip_string(value, max_length=None):
"rem": [["!limit", "x", max_length - 3, max_length]],
},
)
def _compile_patterns(patterns):
compiled = []
for pattern in patterns:
try:
compiled.append(re.compile(pattern))
except Exception:
pass
return compiled
def _pattern_matches(name, patterns):
for pattern in patterns:
if pattern.search(name):
return True
return False
def _serialize_variable_value(value, limiter, max_length=1024):
try:
if value is None:
result = "None"
elif isinstance(value, bool):
result = str(value)
elif isinstance(value, (int, float)):
result_size = len(str(value))
if not limiter.can_add(result_size):
return None
limiter.add(result_size)
return value
elif isinstance(value, str):
result = value
else:
result = json.dumps(value)
if len(result) > max_length:
result = result[: max_length - 3] + "..."
result_size = len(result)
if not limiter.can_add(result_size):
return None
limiter.add(result_size)
return result
except Exception:
try:
result = repr(value)
if len(result) > max_length:
result = result[: max_length - 3] + "..."
result_size = len(result)
if not limiter.can_add(result_size):
return None
limiter.add(result_size)
return result
except Exception:
try:
fallback = f"<{type(value).__name__}>"
fallback_size = len(fallback)
if not limiter.can_add(fallback_size):
return None
limiter.add(fallback_size)
return fallback
except Exception:
fallback = "<unserializable object>"
fallback_size = len(fallback)
if not limiter.can_add(fallback_size):
return None
limiter.add(fallback_size)
return fallback
def _is_simple_type(value):
return isinstance(value, (type(None), bool, int, float, str))
def serialize_code_variables(
frame, limiter, mask_patterns=None, ignore_patterns=None, max_length=1024
):
if mask_patterns is None:
mask_patterns = []
if ignore_patterns is None:
ignore_patterns = []
compiled_mask = _compile_patterns(mask_patterns)
compiled_ignore = _compile_patterns(ignore_patterns)
try:
local_vars = frame.f_locals.copy()
except Exception:
return {}
simple_vars = {}
complex_vars = {}
for name, value in local_vars.items():
if _pattern_matches(name, compiled_ignore):
continue
if _is_simple_type(value):
simple_vars[name] = value
else:
complex_vars[name] = value
result = {}
all_vars = {**simple_vars, **complex_vars}
ordered_names = list(sorted(simple_vars.keys())) + list(sorted(complex_vars.keys()))
for name in ordered_names:
value = all_vars[name]
if _pattern_matches(name, compiled_mask):
redacted_value = CODE_VARIABLES_REDACTED_VALUE
redacted_size = len(redacted_value)
if not limiter.can_add(redacted_size):
break
limiter.add(redacted_size)
result[name] = redacted_value
else:
serialized = _serialize_variable_value(value, limiter, max_length)
if serialized is None:
break
result[name] = serialized
return result
def try_attach_code_variables_to_frames(
all_exceptions, exc_info, mask_patterns, ignore_patterns
):
exc_type, exc_value, traceback = exc_info
if traceback is None:
return
tb_frames = list(iter_stacks(traceback))
if not tb_frames:
return
limiter = VariableSizeLimiter()
for exception in all_exceptions:
stacktrace = exception.get("stacktrace")
if not stacktrace or "frames" not in stacktrace:
continue
serialized_frames = stacktrace["frames"]
for serialized_frame, tb_item in zip(serialized_frames, tb_frames):
if not serialized_frame.get("in_app"):
continue
variables = serialize_code_variables(
tb_item.tb_frame,
limiter,
mask_patterns=mask_patterns,
ignore_patterns=ignore_patterns,
max_length=1024,
)
if variables:
serialized_frame["code_variables"] = variables
+1 -1
View File
@@ -1,5 +1,5 @@
import pytest
pytest.importorskip("langchain")
pytest.importorskip("langchain_core")
pytest.importorskip("langchain_community")
pytest.importorskip("langgraph")
+239 -15
View File
@@ -113,6 +113,7 @@ def test_metadata_capture(mock_client):
base_url="https://us.posthog.com",
name="test",
end_time=None,
posthog_properties=None,
)
assert callbacks._runs[run_id] == expected
with patch("time.time", return_value=1234567891):
@@ -1124,9 +1125,9 @@ def test_anthropic_chain(mock_client):
)
chain = prompt | ChatAnthropic(
api_key=ANTHROPIC_API_KEY,
model="claude-3-opus-20240229",
model="claude-sonnet-4-5-20250929",
temperature=0,
max_tokens=1,
max_tokens=1024,
)
callbacks = CallbackHandler(
mock_client,
@@ -1149,12 +1150,12 @@ def test_anthropic_chain(mock_client):
assert gen_args["event"] == "$ai_generation"
assert gen_props["$ai_trace_id"] == "test-trace-id"
assert gen_props["$ai_provider"] == "anthropic"
assert gen_props["$ai_model"] == "claude-3-opus-20240229"
assert gen_props["$ai_model"] == "claude-sonnet-4-5-20250929"
assert gen_props["foo"] == "bar"
assert gen_props["$ai_model_parameters"] == {
"temperature": 0.0,
"max_tokens": 1,
"max_tokens": 1024,
"streaming": False,
}
assert gen_props["$ai_input"] == [
@@ -1170,7 +1171,7 @@ def test_anthropic_chain(mock_client):
<= approximate_latency
)
assert gen_props["$ai_input_tokens"] == 17
assert gen_props["$ai_output_tokens"] == 1
assert gen_props["$ai_output_tokens"] == 4
assert trace_args["event"] == "$ai_trace"
assert trace_props["$ai_input_state"] == {}
@@ -1187,9 +1188,9 @@ async def test_async_anthropic_streaming(mock_client):
)
chain = prompt | ChatAnthropic(
api_key=ANTHROPIC_API_KEY,
model="claude-3-opus-20240229",
model="claude-sonnet-4-5-20250929",
temperature=0,
max_tokens=1,
max_tokens=1024,
streaming=True,
stream_usage=True,
)
@@ -1269,6 +1270,7 @@ def test_metadata_tools(mock_client):
name="test",
tools=tools,
end_time=None,
posthog_properties=None,
)
assert callbacks._runs[run_id] == expected
with patch("time.time", return_value=1234567891):
@@ -1584,13 +1586,58 @@ def test_anthropic_cache_write_and_read_tokens(mock_client):
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 400
assert (
generation_props["$ai_input_tokens"] == 1200
) # No provider metadata, no subtraction
assert generation_props["$ai_output_tokens"] == 30
assert generation_props["$ai_cache_creation_input_tokens"] == 0
assert generation_props["$ai_cache_read_input_tokens"] == 800
assert generation_props["$ai_reasoning_tokens"] == 0
def test_anthropic_provider_subtracts_cache_tokens(mock_client):
"""Test that Anthropic provider correctly subtracts cache tokens from input tokens."""
from langchain_core.outputs import LLMResult, ChatGeneration
from langchain_core.messages import AIMessage
from uuid import uuid4
cb = CallbackHandler(mock_client)
run_id = uuid4()
# Set up with Anthropic provider
cb._set_llm_metadata(
serialized={},
run_id=run_id,
messages=[{"role": "user", "content": "test"}],
metadata={"ls_provider": "anthropic", "ls_model_name": "claude-3-sonnet"},
)
# Response with cache tokens: 1200 input (includes 800 cached)
response = LLMResult(
generations=[
[
ChatGeneration(
message=AIMessage(content="Response"),
generation_info={
"usage_metadata": {
"input_tokens": 1200,
"output_tokens": 50,
"cache_read_input_tokens": 800,
}
},
)
]
],
llm_output={},
)
cb._pop_run_and_capture_generation(run_id, None, response)
generation_args = mock_client.capture.call_args_list[0][1]
assert generation_args["properties"]["$ai_input_tokens"] == 400 # 1200 - 800
assert generation_args["properties"]["$ai_cache_read_input_tokens"] == 800
def test_openai_cache_read_tokens(mock_client):
"""Test that OpenAI cache read tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages(
@@ -1626,7 +1673,7 @@ def test_openai_cache_read_tokens(mock_client):
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 50
assert generation_props["$ai_input_tokens"] == 150 # No subtraction for OpenAI
assert generation_props["$ai_output_tokens"] == 40
assert generation_props["$ai_cache_read_input_tokens"] == 100
assert generation_props["$ai_cache_creation_input_tokens"] == 0
@@ -1708,7 +1755,7 @@ def test_combined_reasoning_and_cache_tokens(mock_client):
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 200
assert generation_props["$ai_input_tokens"] == 500 # No subtraction for OpenAI
assert generation_props["$ai_output_tokens"] == 100
assert generation_props["$ai_cache_read_input_tokens"] == 300
assert generation_props["$ai_cache_creation_input_tokens"] == 0
@@ -1716,7 +1763,7 @@ def test_combined_reasoning_and_cache_tokens(mock_client):
@pytest.mark.skipif(not OPENAI_API_KEY, reason="OPENAI_API_KEY is not set")
def test_openai_reasoning_tokens(mock_client):
def test_openai_reasoning_tokens_o4_mini(mock_client):
model = ChatOpenAI(
api_key=OPENAI_API_KEY, model="o4-mini", max_completion_tokens=10
)
@@ -1917,8 +1964,8 @@ def test_cache_read_tokens_subtraction_from_input_tokens(mock_client):
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
# Input tokens should be reduced: 150 - 100 = 50
assert generation_props["$ai_input_tokens"] == 50
# Input tokens not reduced without provider metadata
assert generation_props["$ai_input_tokens"] == 150
assert generation_props["$ai_output_tokens"] == 40
assert generation_props["$ai_cache_read_input_tokens"] == 100
@@ -1959,8 +2006,8 @@ def test_cache_read_tokens_subtraction_prevents_negative(mock_client):
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
# Input tokens should be 0, not negative: max(80 - 100, 0) = 0
assert generation_props["$ai_input_tokens"] == 0
# Input tokens not reduced without provider metadata
assert generation_props["$ai_input_tokens"] == 80
assert generation_props["$ai_output_tokens"] == 20
assert generation_props["$ai_cache_read_input_tokens"] == 100
@@ -2126,3 +2173,180 @@ def test_agent_action_and_finish_imports():
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
assert call_args["event"] == "$ai_span"
def test_posthog_properties_field_in_generation_metadata(mock_client):
"""Test that posthog_properties is properly stored in GenerationMetadata."""
callbacks = CallbackHandler(mock_client)
run_id = uuid.uuid4()
# Test with billable=True
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
run_id,
messages=[{"role": "user", "content": "Test message"}],
invocation_params={"temperature": 0.5},
metadata={
"ls_model_name": "gpt-4o",
"ls_provider": "openai",
"posthog_properties": {"$ai_billable": True},
},
name="test",
)
expected = GenerationMetadata(
model="gpt-4o",
input=[{"role": "user", "content": "Test message"}],
start_time=1234567890,
model_params={"temperature": 0.5},
provider="openai",
base_url="https://api.openai.com",
name="test",
posthog_properties={"$ai_billable": True},
end_time=None,
)
assert callbacks._runs[run_id] == expected
assert callbacks._runs[run_id].posthog_properties == {"$ai_billable": True}
callbacks._pop_run_metadata(run_id)
# Test with billable=False (explicit)
run_id2 = uuid.uuid4()
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
run_id2,
messages=[{"role": "user", "content": "Test message"}],
invocation_params={"temperature": 0.5},
metadata={
"ls_model_name": "gpt-4o",
"ls_provider": "openai",
"posthog_properties": {"$ai_billable": False},
},
name="test",
)
assert callbacks._runs[run_id2].posthog_properties == {"$ai_billable": False}
callbacks._pop_run_metadata(run_id2)
# Test when posthog_properties not provided
run_id3 = uuid.uuid4()
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
run_id3,
messages=[{"role": "user", "content": "Test message"}],
invocation_params={"temperature": 0.5},
metadata={"ls_model_name": "gpt-4o", "ls_provider": "openai"},
name="test",
)
assert callbacks._runs[run_id3].posthog_properties is None
def test_billable_property_in_generation_event(mock_client):
"""Test that the billable property is captured in the $ai_generation event."""
callbacks = CallbackHandler(mock_client)
# We need to test the _set_llm_metadata directly since FakeMessagesListChatModel
# doesn't support metadata in the same way as real models
run_id = uuid.uuid4()
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
{},
run_id,
messages=[{"role": "user", "content": "Test"}],
metadata={
"posthog_properties": {"$ai_billable": True},
"ls_model_name": "test-model",
},
invocation_params={},
)
mock_response = MagicMock()
mock_response.generations = [[MagicMock()]]
with patch("time.time", return_value=1234567891):
run = callbacks._pop_run_metadata(run_id)
callbacks._capture_generation(
trace_id=run_id,
run_id=run_id,
run=run,
output=mock_response,
parent_run_id=None,
)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["event"] == "$ai_generation"
assert props["$ai_billable"] is True
def test_billable_defaults_to_false_in_event(mock_client):
"""Test that $ai_billable is not present when not specified."""
prompt = ChatPromptTemplate.from_messages([("user", "Test query")])
model = FakeMessagesListChatModel(
responses=[AIMessage(content="Test response")],
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
chain.invoke({}, config={"callbacks": callbacks})
generation_call = None
for call in mock_client.capture.call_args_list:
if call[1]["event"] == "$ai_generation":
generation_call = call
break
assert generation_call is not None
props = generation_call[1]["properties"]
assert "$ai_billable" not in props
def test_billable_with_real_chain(mock_client):
"""Test billable tracking through a complete chain execution with mocked metadata."""
callbacks = CallbackHandler(mock_client)
run_id = uuid.uuid4()
with patch("time.time", return_value=1000.0):
callbacks._set_llm_metadata(
{},
run_id,
messages=[{"role": "user", "content": "What's the weather?"}],
metadata={
"ls_model_name": "fake-model",
"ls_provider": "fake",
"posthog_properties": {"$ai_billable": True},
},
invocation_params={"temperature": 0.7},
)
assert callbacks._runs[run_id].posthog_properties == {"$ai_billable": True}
mock_response = MagicMock()
mock_response.generations = [[MagicMock()]]
with patch("time.time", return_value=1001.0):
run = callbacks._pop_run_metadata(run_id)
callbacks._capture_generation(
trace_id=run_id,
run_id=run_id,
run=run,
output=mock_response,
parent_run_id=None,
)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["event"] == "$ai_generation"
assert props["$ai_billable"] is True
assert props["$ai_model"] == "fake-model"
assert props["$ai_provider"] == "fake"
+3 -1
View File
@@ -315,7 +315,9 @@ class TestPosthogContextMiddlewareSync(unittest.TestCase):
get_response = Mock(return_value=mock_response)
# Create middleware with request filter that filters all requests
request_filter = lambda req: False
def request_filter(req):
return False
middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware)
middleware.get_response = get_response
middleware._is_coroutine = False
+374
View File
@@ -32,3 +32,377 @@ def test_excepthook(tmpdir):
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
in output
)
def test_code_variables_capture(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
from posthog import Posthog
class UnserializableObject:
pass
posthog = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def trigger_error():
my_string = "hello world"
my_number = 42
my_bool = True
my_dict = {"name": "test", "value": 123}
my_obj = UnserializableObject()
my_password = "secret123" # Should be masked by default
__should_be_ignored = "hidden" # Should be ignored by default
1/0 # Trigger exception
def intermediate_function():
request_id = "abc-123"
user_count = 100
is_active = True
trigger_error()
def process_data():
batch_size = 50
retry_count = 3
intermediate_function()
process_data()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output
assert b"ZeroDivisionError" in output
assert b"code_variables" in output
# Variables from trigger_error frame
assert b"'my_string': 'hello world'" in output
assert b"'my_number': 42" in output
assert b"'my_bool': 'True'" in output
assert b'"my_dict": "{\\"name\\": \\"test\\", \\"value\\": 123}"' in output
assert b"<__main__.UnserializableObject object at" in output
assert b"'my_password': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
assert b"'__should_be_ignored':" not in output
# Variables from intermediate_function frame
assert b"'request_id': 'abc-123'" in output
assert b"'user_count': 100" in output
assert b"'is_active': 'True'" in output
# Variables from process_data frame
assert b"'batch_size': 50" in output
assert b"'retry_count': 3" in output
def test_code_variables_context_override(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
import posthog
from posthog import Posthog
posthog_client = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=False,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def process_data():
bank = "should_be_masked"
__dunder_var = "should_be_visible"
1/0
with posthog.new_context(client=posthog_client):
posthog.set_capture_exception_code_variables_context(True)
posthog.set_code_variables_mask_patterns_context([r"(?i).*bank.*"])
posthog.set_code_variables_ignore_patterns_context([])
process_data()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output
assert b"ZeroDivisionError" in output
assert b"code_variables" in output
assert b"'bank': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
assert b"'__dunder_var': 'should_be_visible'" in output
def test_code_variables_size_limiter(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
from posthog import Posthog
posthog = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def trigger_error():
var_a = "a" * 2000
var_b = "b" * 2000
var_c = "c" * 2000
var_d = "d" * 2000
var_e = "e" * 2000
var_f = "f" * 2000
var_g = "g" * 2000
1/0
def intermediate_function():
var_h = "h" * 2000
var_i = "i" * 2000
var_j = "j" * 2000
var_k = "k" * 2000
var_l = "l" * 2000
var_m = "m" * 2000
var_n = "n" * 2000
trigger_error()
def process_data():
var_o = "o" * 2000
var_p = "p" * 2000
var_q = "q" * 2000
var_r = "r" * 2000
var_s = "s" * 2000
var_t = "t" * 2000
var_u = "u" * 2000
intermediate_function()
process_data()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output.decode("utf-8")
assert "ZeroDivisionError" in output
assert "code_variables" in output
captured_vars = []
for var_name in [
"var_a",
"var_b",
"var_c",
"var_d",
"var_e",
"var_f",
"var_g",
"var_h",
"var_i",
"var_j",
"var_k",
"var_l",
"var_m",
"var_n",
"var_o",
"var_p",
"var_q",
"var_r",
"var_s",
"var_t",
"var_u",
]:
if f"'{var_name}'" in output:
captured_vars.append(var_name)
assert len(captured_vars) > 0
assert len(captured_vars) < 21
def test_code_variables_disabled_capture(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
from posthog import Posthog
posthog = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=False,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def trigger_error():
my_string = "hello world"
my_number = 42
my_bool = True
1/0
trigger_error()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output.decode("utf-8")
assert "ZeroDivisionError" in output
assert "'code_variables':" not in output
assert '"code_variables":' not in output
assert "'my_string'" not in output
assert "'my_number'" not in output
def test_code_variables_enabled_then_disabled_in_context(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
import posthog
from posthog import Posthog
posthog_client = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def process_data():
my_var = "should not be captured"
important_value = 123
1/0
with posthog.new_context(client=posthog_client):
posthog.set_capture_exception_code_variables_context(False)
process_data()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output.decode("utf-8")
assert "ZeroDivisionError" in output
assert "'code_variables':" not in output
assert '"code_variables":' not in output
assert "'my_var'" not in output
assert "'important_value'" not in output
def test_code_variables_repr_fallback(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
import re
from datetime import datetime, timedelta
from decimal import Decimal
from fractions import Fraction
from posthog import Posthog
class CustomReprClass:
def __repr__(self):
return '<CustomReprClass: custom representation>'
posthog = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def trigger_error():
my_regex = re.compile(r'\\d+')
my_datetime = datetime(2024, 1, 15, 10, 30, 45)
my_timedelta = timedelta(days=5, hours=3)
my_decimal = Decimal('123.456')
my_fraction = Fraction(3, 4)
my_set = {1, 2, 3}
my_frozenset = frozenset([4, 5, 6])
my_bytes = b'hello bytes'
my_bytearray = bytearray(b'mutable bytes')
my_memoryview = memoryview(b'memory view')
my_complex = complex(3, 4)
my_range = range(10)
my_custom = CustomReprClass()
my_lambda = lambda x: x * 2
my_function = trigger_error
1/0
trigger_error()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output.decode("utf-8")
assert "ZeroDivisionError" in output
assert "code_variables" in output
assert "re.compile(" in output and "\\\\d+" in output
assert "datetime.datetime(2024, 1, 15, 10, 30, 45)" in output
assert "datetime.timedelta(days=5, seconds=10800)" in output
assert "Decimal('123.456')" in output
assert "Fraction(3, 4)" in output
assert "{1, 2, 3}" in output
assert "frozenset({4, 5, 6})" in output
assert "b'hello bytes'" in output
assert "bytearray(b'mutable bytes')" in output
assert "<memory at" in output
assert "(3+4j)" in output
assert "range(0, 10)" in output
assert "<CustomReprClass: custom representation>" in output
assert "<lambda>" in output
assert "<function trigger_error at" in output
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "6.8.0"
VERSION = "7.0.1"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+8 -9
View File
@@ -10,14 +10,13 @@ authors = [{ name = "PostHog", email = "hey@posthog.com" }]
maintainers = [{ name = "PostHog", email = "hey@posthog.com" }]
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.10"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
@@ -66,13 +65,13 @@ test = [
"pytest-timeout",
"pytest-asyncio",
"django",
"openai",
"anthropic",
"langgraph>=0.4.8",
"langchain-core>=0.3.65",
"langchain-community>=0.3.25",
"langchain-openai>=0.3.22",
"langchain-anthropic>=0.3.15",
"openai>=2.0",
"anthropic>=0.72",
"langgraph>=1.0",
"langchain-core>=1.0",
"langchain-community>=0.4",
"langchain-openai>=1.0",
"langchain-anthropic>=1.0",
"google-genai",
"pydantic",
"parameterized>=0.8.1",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
"id": "posthog-python",
"hogRef": "0.3",
"info": {
"version": "6.7.14",
"version": "7.0.0",
"id": "posthog-python",
"title": "PostHog Python SDK",
"description": "Integrate PostHog into any python application.",
@@ -503,6 +503,24 @@
"description": "",
"isOptional": false,
"type": "bool"
},
{
"name": "capture_exception_code_variables",
"description": "",
"isOptional": false,
"type": "bool"
},
{
"name": "code_variables_mask_patterns",
"description": "",
"isOptional": false,
"type": "any"
},
{
"name": "code_variables_ignore_patterns",
"description": "",
"isOptional": false,
"type": "any"
}
],
"showDocs": true,
@@ -1478,12 +1496,7 @@
{
"id": "example_1",
"name": "Set with distinct id",
"code": "# Set with distinct id\nposthog.capture(\n 'event_name',\n distinct_id='user-distinct-id',\n properties={\n '$set': {'name': 'Max Hedgehog'},\n '$set_once': {'initial_url': '/blog'}\n }\n)"
},
{
"id": "example_2",
"name": "Set using context",
"code": "# Set using context\nfrom posthog import new_context, identify_context\nwith new_context():\n identify_context('user-distinct-id')\n posthog.capture('event_name')"
"code": "# Set with distinct id\nposthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})"
}
]
},
@@ -2138,6 +2151,12 @@
"description": "Whether to capture exceptions raised within the context (default: True)",
"isOptional": false,
"type": "bool"
},
{
"name": "client",
"description": "Optional Posthog client instance to use for this context (default: None)",
"isOptional": false,
"type": "any"
}
],
"showDocs": true,
@@ -2216,6 +2235,69 @@
}
]
},
{
"id": "set_capture_exception_code_variables_context",
"title": "set_capture_exception_code_variables_context",
"description": "Set whether code variables are captured for the current context.",
"details": "",
"category": null,
"params": [
{
"name": "enabled",
"description": "",
"isOptional": true,
"type": "bool"
}
],
"showDocs": true,
"releaseTag": "public",
"returnType": {
"id": "return_type",
"name": "None"
}
},
{
"id": "set_code_variables_ignore_patterns_context",
"title": "set_code_variables_ignore_patterns_context",
"description": "Variable names matching these patterns will be ignored completely when capturing code variables.",
"details": "",
"category": null,
"params": [
{
"name": "ignore_patterns",
"description": "",
"isOptional": true,
"type": "list"
}
],
"showDocs": true,
"releaseTag": "public",
"returnType": {
"id": "return_type",
"name": "None"
}
},
{
"id": "set_code_variables_mask_patterns_context",
"title": "set_code_variables_mask_patterns_context",
"description": "Variable names matching these patterns will be masked with *** when capturing code variables.",
"details": "",
"category": null,
"params": [
{
"name": "mask_patterns",
"description": "",
"isOptional": true,
"type": "list"
}
],
"showDocs": true,
"releaseTag": "public",
"returnType": {
"id": "return_type",
"name": "None"
}
},
{
"id": "set_context_session",
"title": "set_context_session",
+1 -1
View File
@@ -14,7 +14,7 @@ long_description = """
PostHog is developer-friendly, self-hosted product analytics.
posthog-python is the python package.
This package requires Python 3.9 or higher.
This package requires Python 3.10 or higher.
"""
# Minimal setup.py for backward compatibility
+1 -1
View File
@@ -47,7 +47,7 @@ long_description = """
PostHog is developer-friendly, self-hosted product analytics.
posthog-python is the python package.
This package requires Python 3.9 or higher.
This package requires Python 3.10 or higher.
"""
# Minimal setup.py for backward compatibility