Skip to content

Commit bfafeba

Browse files
authored
feat: Add context helpers to Client (#681)
* fix: Add context helpers to Client * address pr review feedback * keep scoped public api stable * mark client context helpers as minor
1 parent b7dff95 commit bfafeba

5 files changed

Lines changed: 231 additions & 25 deletions

File tree

.changeset/fuzzy-pandas-scope.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'pypi/posthog': minor
3+
---
4+
5+
Add context helper methods to custom PostHog client instances.

posthog/client.py

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@
2424
get_context_device_id,
2525
get_context_distinct_id,
2626
get_context_session_id,
27+
get_tags as _context_get_tags,
28+
identify_context as _context_identify_context,
29+
_scoped as _context_scoped,
2730
new_context,
31+
set_context_device_id as _context_set_context_device_id,
32+
set_context_session as _context_set_context_session,
33+
tag as _context_tag,
2834
)
2935
from posthog.exception_capture import ExceptionCapture
3036
from posthog.exception_utils import (
@@ -486,9 +492,9 @@ def new_context(self, fresh=False, capture_exceptions: Optional[bool] = None):
486492
487493
Examples:
488494
```python
489-
with posthog.new_context():
490-
identify_context('<distinct_id>')
491-
posthog.capture('event_name')
495+
with client.new_context():
496+
client.identify_context('<distinct_id>')
497+
client.capture('event_name')
492498
```
493499
494500
Category:
@@ -498,6 +504,83 @@ def new_context(self, fresh=False, capture_exceptions: Optional[bool] = None):
498504
fresh=fresh, capture_exceptions=capture_exceptions, client=self
499505
)
500506

507+
def scoped(self, fresh=False, capture_exceptions: Optional[bool] = None):
508+
"""
509+
Decorator that creates a new context for the wrapped function using this client.
510+
511+
Args:
512+
fresh: Whether to create a fresh context that doesn't inherit from parent.
513+
capture_exceptions: Whether to automatically capture exceptions in this context. If omitted, defaults to this client's exception autocapture setting.
514+
515+
Category:
516+
Contexts
517+
"""
518+
519+
return _context_scoped(
520+
fresh=fresh, capture_exceptions=capture_exceptions, client=self
521+
)
522+
523+
def tag(self, name: str, value: Any) -> None:
524+
"""
525+
Add a tag to the current context.
526+
527+
Args:
528+
name: The tag key.
529+
value: The tag value.
530+
531+
Category:
532+
Contexts
533+
"""
534+
_context_tag(name, value)
535+
536+
def get_tags(self) -> Dict[str, Any]:
537+
"""
538+
Get all tags from the current context.
539+
540+
Returns:
541+
Dict of all tags in the current context.
542+
543+
Category:
544+
Contexts
545+
"""
546+
return _context_get_tags()
547+
548+
def identify_context(self, distinct_id: str) -> None:
549+
"""
550+
Identify the current context with a distinct ID.
551+
552+
Args:
553+
distinct_id: The distinct ID to associate with the current context and its children.
554+
555+
Category:
556+
Contexts
557+
"""
558+
_context_identify_context(distinct_id)
559+
560+
def set_context_session(self, session_id: str) -> None:
561+
"""
562+
Set the session ID for the current context.
563+
564+
Args:
565+
session_id: The session ID to associate with the current context and its children.
566+
567+
Category:
568+
Contexts
569+
"""
570+
_context_set_context_session(session_id)
571+
572+
def set_context_device_id(self, device_id: str) -> None:
573+
"""
574+
Set the device ID for the current context.
575+
576+
Args:
577+
device_id: The device ID to associate with the current context and its children.
578+
579+
Category:
580+
Contexts
581+
"""
582+
_context_set_context_device_id(device_id)
583+
501584
@property
502585
def feature_flags(self):
503586
"""

posthog/contexts.py

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,38 @@ def get_code_variables_ignore_patterns_context() -> Optional[list]:
393393
F = TypeVar("F", bound=Callable[..., Any])
394394

395395

396+
def _scoped(
397+
fresh: bool = False,
398+
capture_exceptions: Optional[bool] = None,
399+
client: Optional["Client"] = None,
400+
):
401+
def decorator(func: F) -> F:
402+
from functools import wraps
403+
from inspect import iscoroutinefunction
404+
405+
if iscoroutinefunction(func):
406+
407+
@wraps(func)
408+
async def async_wrapper(*args, **kwargs):
409+
with new_context(
410+
fresh=fresh, capture_exceptions=capture_exceptions, client=client
411+
):
412+
return await func(*args, **kwargs)
413+
414+
return cast(F, async_wrapper)
415+
416+
@wraps(func)
417+
def wrapper(*args, **kwargs):
418+
with new_context(
419+
fresh=fresh, capture_exceptions=capture_exceptions, client=client
420+
):
421+
return func(*args, **kwargs)
422+
423+
return cast(F, wrapper)
424+
425+
return decorator
426+
427+
396428
def scoped(fresh: bool = False, capture_exceptions: Optional[bool] = None):
397429
"""
398430
Decorator that creates a new context for the function. Simply wraps
@@ -424,25 +456,4 @@ async def middleware(request, call_next):
424456
Category:
425457
Contexts
426458
"""
427-
428-
def decorator(func: F) -> F:
429-
from functools import wraps
430-
from inspect import iscoroutinefunction
431-
432-
if iscoroutinefunction(func):
433-
434-
@wraps(func)
435-
async def async_wrapper(*args, **kwargs):
436-
with new_context(fresh=fresh, capture_exceptions=capture_exceptions):
437-
return await func(*args, **kwargs)
438-
439-
return cast(F, async_wrapper)
440-
441-
@wraps(func)
442-
def wrapper(*args, **kwargs):
443-
with new_context(fresh=fresh, capture_exceptions=capture_exceptions):
444-
return func(*args, **kwargs)
445-
446-
return cast(F, wrapper)
447-
448-
return decorator
459+
return _scoped(fresh=fresh, capture_exceptions=capture_exceptions)

posthog/test/test_client.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import time
23
import unittest
34
from datetime import datetime
@@ -2361,6 +2362,35 @@ def test_device_id_from_context_is_used_in_flags_request(self, patch_flags):
23612362
flag_keys_to_evaluate=["random_key"],
23622363
)
23632364

2365+
@mock.patch("posthog.client.flags")
2366+
def test_client_set_context_device_id_is_used_in_flags_request(self, patch_flags):
2367+
patch_flags.return_value = {
2368+
"featureFlags": {
2369+
"beta-feature": "random-variant",
2370+
}
2371+
}
2372+
client = Client(
2373+
FAKE_TEST_API_KEY,
2374+
on_error=self.set_fail,
2375+
)
2376+
2377+
with client.new_context():
2378+
client.set_context_device_id("client-context-device-id")
2379+
client.get_feature_flag("random_key", "some_id")
2380+
2381+
patch_flags.assert_called_with(
2382+
"random_key",
2383+
"https://us.i.posthog.com",
2384+
timeout=3,
2385+
distinct_id="some_id",
2386+
groups={},
2387+
person_properties={"distinct_id": "some_id"},
2388+
group_properties={},
2389+
geoip_disable=True,
2390+
device_id="client-context-device-id",
2391+
flag_keys_to_evaluate=["random_key"],
2392+
)
2393+
23642394
@parameterized.expand(
23652395
[
23662396
# name, sys_platform, version_info, expected_runtime, expected_version, expected_os, expected_os_version, expected_os_distro, platform_method, platform_return
@@ -2534,6 +2564,77 @@ def test_set_context_session_with_capture(self):
25342564
msg["properties"]["$session_id"], "context-session-123"
25352565
)
25362566

2567+
@parameterized.expand([("new_context",), ("scoped",)])
2568+
def test_client_context_helpers_apply_to_capture(self, context_helper):
2569+
with mock.patch("posthog.client.batch_post") as mock_post:
2570+
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
2571+
2572+
def capture_in_context():
2573+
client.tag("client_tag", "tag-value")
2574+
client.identify_context("context-user")
2575+
client.set_context_session("context-session-123")
2576+
2577+
self.assertEqual(client.get_tags(), {"client_tag": "tag-value"})
2578+
2579+
return client.capture(
2580+
"test_event",
2581+
properties={"custom_prop": "value"},
2582+
)
2583+
2584+
if context_helper == "new_context":
2585+
with client.new_context(fresh=True):
2586+
msg_uuid = capture_in_context()
2587+
else:
2588+
2589+
@client.scoped(fresh=True)
2590+
def scoped_capture():
2591+
return capture_in_context()
2592+
2593+
msg_uuid = scoped_capture()
2594+
2595+
self.assertIsNotNone(msg_uuid)
2596+
mock_post.assert_called_once()
2597+
batch_data = mock_post.call_args[1]["batch"]
2598+
msg = batch_data[0]
2599+
2600+
self.assertEqual(msg["distinct_id"], "context-user")
2601+
self.assertEqual(msg["properties"]["client_tag"], "tag-value")
2602+
self.assertEqual(msg["properties"]["custom_prop"], "value")
2603+
self.assertEqual(msg["properties"]["$session_id"], "context-session-123")
2604+
self.assertCountEqual(msg["properties"]["$context_tags"], ["client_tag"])
2605+
self.assertEqual(client.get_tags(), {})
2606+
2607+
def test_client_scoped_context_helpers_apply_to_capture_async(self):
2608+
with mock.patch("posthog.client.batch_post") as mock_post:
2609+
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
2610+
2611+
@client.scoped(fresh=True)
2612+
async def scoped_capture():
2613+
client.tag("async_scoped_tag", "async-scoped-value")
2614+
client.identify_context("async-scoped-user")
2615+
client.set_context_session("async-scoped-session-123")
2616+
await asyncio.sleep(0)
2617+
return client.capture("async_scoped_event")
2618+
2619+
msg_uuid = asyncio.run(scoped_capture())
2620+
2621+
self.assertIsNotNone(msg_uuid)
2622+
mock_post.assert_called_once()
2623+
batch_data = mock_post.call_args[1]["batch"]
2624+
msg = batch_data[0]
2625+
2626+
self.assertEqual(msg["distinct_id"], "async-scoped-user")
2627+
self.assertEqual(
2628+
msg["properties"]["async_scoped_tag"], "async-scoped-value"
2629+
)
2630+
self.assertEqual(
2631+
msg["properties"]["$session_id"], "async-scoped-session-123"
2632+
)
2633+
self.assertCountEqual(
2634+
msg["properties"]["$context_tags"], ["async_scoped_tag"]
2635+
)
2636+
self.assertEqual(client.get_tags(), {})
2637+
25372638
def test_set_context_session_with_page_explicit_properties(self):
25382639
with mock.patch("posthog.client.batch_post") as mock_post:
25392640
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)

references/public_api_snapshot.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,13 +1059,19 @@ method posthog.client.Client.get_feature_payloads(distinct_id, groups=None, pers
10591059
method posthog.client.Client.get_feature_variants(distinct_id, groups=None, person_properties=None, group_properties=None, disable_geoip=None, flag_keys_to_evaluate: Optional[list[str]] = None, device_id: Optional[str] = None) -> dict[str, Union[bool, str]]
10601060
method posthog.client.Client.get_flags_decision(distinct_id: Optional[ID_TYPES] = None, groups: Optional[dict] = None, person_properties=None, group_properties=None, disable_geoip=None, flag_keys_to_evaluate: Optional[list[str]] = None, device_id: Optional[str] = None) -> FlagsResponse
10611061
method posthog.client.Client.get_remote_config_payload(key: str)
1062+
method posthog.client.Client.get_tags() -> Dict[str, Any]
10621063
method posthog.client.Client.group_identify(group_type: str, group_key: str, properties: Optional[Dict[str, Any]] = None, timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[Union[str, UUID]] = None, disable_geoip: Optional[bool] = None, distinct_id: Optional[ID_TYPES] = None) -> Optional[str]
1064+
method posthog.client.Client.identify_context(distinct_id: str) -> None
10631065
method posthog.client.Client.join() -> None
10641066
method posthog.client.Client.load_feature_flags()
10651067
method posthog.client.Client.new_context(fresh=False, capture_exceptions: Optional[bool] = None)
1068+
method posthog.client.Client.scoped(fresh=False, capture_exceptions: Optional[bool] = None)
10661069
method posthog.client.Client.set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]
1070+
method posthog.client.Client.set_context_device_id(device_id: str) -> None
1071+
method posthog.client.Client.set_context_session(session_id: str) -> None
10671072
method posthog.client.Client.set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]
10681073
method posthog.client.Client.shutdown() -> None
1074+
method posthog.client.Client.tag(name: str, value: Any) -> None
10691075
method posthog.consumer.Consumer.next()
10701076
method posthog.consumer.Consumer.pause()
10711077
method posthog.consumer.Consumer.request(batch)

0 commit comments

Comments
 (0)