Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/opt-out-capturing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

Add `opt_out_capturing()`, `opt_in_capturing()` and `is_opted_out()` to `Client` and the module-level API so capture can be suppressed at runtime. While opted out, events are dropped silently, metrics and spans are no longer recorded or exported, and none of them make a network request; feature flag evaluation is unaffected. Opt-out is separate from `disabled`, and opting in never re-enables a disabled client. `AsyncClient` does not support opt-out yet.
59 changes: 59 additions & 0 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1224,6 +1224,65 @@ def load_feature_flags():
return _proxy("load_feature_flags")


def opt_out_capturing() -> None:
"""
Stop capturing events on the global client until ``opt_in_capturing()`` is called.

While opted out, ``capture()`` and every other event-producing API drop
their event silently, metrics and spans are no longer recorded or exported,
and none of them make a network request. Feature flag evaluation is
unaffected. The state is held in memory, so it does not survive a process
restart; persist the user's choice yourself and re-apply it on startup. This
is separate from ``disabled``: opting in again never re-enables a disabled
client. ``AsyncClient`` does not support opt-out yet.

Examples:
```python
from posthog import opt_out_capturing
opt_out_capturing()
```

Category:
Client management
"""
_proxy("opt_out_capturing")


def opt_in_capturing() -> None:
"""
Resume capturing events on the global client after ``opt_out_capturing()``.

Events, metrics and spans dropped while opted out are not recovered.

Examples:
```python
from posthog import opt_in_capturing
opt_in_capturing()
```

Category:
Client management
"""
_proxy("opt_in_capturing")


def is_opted_out() -> bool:
"""
Whether the global client is currently opted out of capturing.

Examples:
```python
from posthog import is_opted_out
if not is_opted_out():
...
```

Category:
Client management
"""
return _proxy("is_opted_out")


def flush(timeout_seconds: Optional[float] = 10) -> None:
"""
Tell the client to flush all queued events.
Expand Down
71 changes: 70 additions & 1 deletion posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,9 @@ def __init__(
] = None
self._flag_definition_cache_provider_async_runner_lock = threading.Lock()
self.disabled = disabled or not self.api_key
# Runtime consent gate, separate from the constructor-time `disabled`
# kill switch, toggled by opt_out_capturing()/opt_in_capturing().
self._opted_out = False
self.disable_geoip = disable_geoip
self._metrics_config = metrics
self._metrics: Optional[PostHogMetrics] = None
Expand Down Expand Up @@ -1663,6 +1666,10 @@ def _capture(
self, event: str, lane: _Lane, **kwargs: Unpack[OptionalCaptureArgs]
) -> Optional[str]:
"""Shared message-building body of `capture()` and `capture_ai()`; `lane` picks the wire destination."""
# Bail before the `send_feature_flags` prefetch, which would otherwise hit /flags.
if self._opted_out:
return None

distinct_id = kwargs.get("distinct_id", None)
properties = kwargs.get("properties", None)
timestamp = kwargs.get("timestamp", None)
Expand Down Expand Up @@ -2401,7 +2408,7 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None):
if lane is None:
lane = self._analytics_lane

if self.disabled:
if self.disabled or self._opted_out:
return None

timestamp = msg["timestamp"]
Expand Down Expand Up @@ -2713,6 +2720,68 @@ def get_active_span(self) -> Optional[Span]:
"""
return self._active_span_var.get()

def opt_out_capturing(self) -> None:
"""
Stop capturing events until ``opt_in_capturing()`` is called.

Details:
While opted out, ``capture()`` and every other event-producing API
drop their event silently, metrics and spans are no longer recorded
or exported, and none of them make a network request. Feature flag
evaluation is unaffected. The state is held in memory on this client
only, so it does not survive a process restart; persist the user's
choice yourself and re-apply it on startup. This is separate from
``disabled``: opting in again never re-enables a disabled client.
``AsyncClient`` does not support opt-out yet.

Examples:
```python
posthog.opt_out_capturing()
```

Category:
Client management
"""
self._opted_out = True

def opt_in_capturing(self) -> None:
"""
Resume capturing events after ``opt_out_capturing()``.

Details:
Events, metrics and spans dropped while opted out are not recovered.
A disabled client stays disabled.

Examples:
```python
posthog.opt_in_capturing()
```

Category:
Client management
"""
self._opted_out = False

def is_opted_out(self) -> bool:
"""
Whether capture is currently suppressed by ``opt_out_capturing()``.

Returns:
``True`` when the client is opted out. This reports the opt-out
state only; a disabled client returns ``False`` unless it was also
opted out.

Examples:
```python
if not posthog.is_opted_out():
posthog.capture("page_viewed", distinct_id="user_123")
```

Category:
Client management
"""
return self._opted_out

def flush(self, timeout_seconds: Optional[float] = 10) -> None:
"""
Force a flush from the internal queue to the server. Do not use directly, call `shutdown()` instead.
Expand Down
4 changes: 3 additions & 1 deletion posthog/metrics_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,9 @@ def _capture(
unit: Optional[str],
attributes: Optional[dict],
) -> None:
if getattr(self._client, "disabled", False):
if getattr(self._client, "disabled", False) or getattr(
self._client, "_opted_out", False
):
return

sample = {
Expand Down
68 changes: 68 additions & 0 deletions posthog/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3587,6 +3587,74 @@ def test_disabled(self):
self.assertIsNone(msg_uuid)
self.assertFalse(self.failed)

def test_opt_out_capturing_drops_events(self):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
self.assertFalse(client.is_opted_out())

client.opt_out_capturing()
self.assertTrue(client.is_opted_out())
with mock.patch("posthog.client.batch_post") as patch_post:
self.assertIsNone(
client.capture("ignored event", distinct_id="distinct_id")
)
self.assertIsNone(
client.set(distinct_id="distinct_id", properties={"plan": "pro"})
)
self.assertIsNone(client.alias("distinct_id", "other_id"))
self.assertIsNone(
client.group_identify("company", "id:5", {"name": "PostHog"})
)
patch_post.assert_not_called()
self.assertFalse(self.failed)

def test_opt_in_capturing_resumes_events(self):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
client.opt_out_capturing()
self.assertIsNone(client.capture("ignored event", distinct_id="distinct_id"))

client.opt_in_capturing()
self.assertFalse(client.is_opted_out())
msg_uuid = client.capture("python test event", distinct_id="distinct_id")
self.assertIsNotNone(msg_uuid)
client.shutdown()
self.assertFalse(self.failed)

def test_opt_in_capturing_does_not_re_enable_a_disabled_client(self):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disabled=True)
client.opt_in_capturing()
self.assertIsNone(
client.capture("python test event", distinct_id="distinct_id")
)
self.assertFalse(self.failed)

@mock.patch("posthog.client.flags")
def test_opt_out_capturing_leaves_feature_flags_working(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": True}}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
client.opt_out_capturing()

self.assertTrue(client.feature_enabled("beta-feature", "12345"))
patch_flags.assert_called()
self.assertFalse(self.failed)

@mock.patch("posthog.client.batch_post")
@mock.patch("posthog.client.flags")
def test_opt_out_capturing_skips_send_feature_flags_request(
self, patch_flags, patch_post
):
patch_flags.return_value = {"featureFlags": {"beta-feature": True}}
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
client.opt_out_capturing()

self.assertIsNone(
client.capture(
"ignored event", distinct_id="distinct_id", send_feature_flags=True
)
)
patch_flags.assert_not_called()
patch_post.assert_not_called()
self.assertFalse(self.failed)

@mock.patch("posthog.client.flags")
def test_disabled_with_feature_flags(self, patch_flags):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disabled=True)
Expand Down
9 changes: 9 additions & 0 deletions posthog/test/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,15 @@ def test_disabled_client_records_nothing(self):

assert payload is None

def test_opted_out_client_records_nothing(self):
c = Client(FAKE_API_KEY, host="https://us.example.com")
c.opt_out_capturing()
c.metrics.count("m", 1)

payload, _, _ = flush_and_capture(c)

assert payload is None


class TestMetricsDelivery:
def test_resource_attributes_layer_sdk_keys_over_user_keys(self):
Expand Down
14 changes: 14 additions & 0 deletions posthog/test/test_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ def test_alias(self):
self.assertEqual(event["properties"]["alias"], "distinct_id")
self.assertEqual(event["uuid"], res)

def test_opt_out_capturing_drops_module_level_capture(self):
with mock.patch.object(posthog, "default_client", self.posthog):
posthog.opt_out_capturing()
self.assertTrue(posthog.is_opted_out())
self.assertIsNone(
posthog.capture("ignored event", distinct_id="distinct_id")
)

posthog.opt_in_capturing()
self.assertFalse(posthog.is_opted_out())
self._assert_enqueue_result(
posthog.capture("python module event", distinct_id="distinct_id")
)

def test_flush(self):
self.posthog.flush()

Expand Down
9 changes: 9 additions & 0 deletions posthog/test/tracing/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ def test_a_disabled_client_discards_the_queue_instead_of_exporting(self):
assert sender.payloads == []
assert queued(pipeline) == []

def test_an_opted_out_client_discards_the_queue_instead_of_exporting(self):
client = SimpleNamespace(disabled=False, _opted_out=False, send=True)
pipeline, sender, _ = make_traces(client=client)
pipeline.start_span("a").end()
client._opted_out = True
pipeline.flush()
assert sender.payloads == []
assert queued(pipeline) == []

def test_a_disabled_discard_takes_the_failed_batchs_budget_with_it(self, clock):
client = SimpleNamespace(disabled=False, send=True)
sender = FakeSender(SendOutcome("retry-later"), SendOutcome("ok"))
Expand Down
17 changes: 17 additions & 0 deletions posthog/test/tracing/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ def test_returns_an_inert_handle_when_the_client_is_disabled(self):
span.end()
assert queued(pipeline) == []

def test_returns_an_inert_handle_when_the_client_is_opted_out(self):
client = SimpleNamespace(disabled=False, _opted_out=True, send=True)
pipeline, _, _ = make(client=client)
span = pipeline.start_span("a")
assert span is NOOP_SPAN
span.end()
assert queued(pipeline) == []

def test_makes_a_child_of_an_inert_handle_inert_rather_than_an_orphan(self):
pipeline, _, _ = make()
child = pipeline.start_span("child", parent=NOOP_SPAN)
Expand Down Expand Up @@ -447,6 +455,15 @@ def test_drops_a_span_whose_client_was_disabled_mid_trace_without_raising(self):
assert queued(pipeline) == []
assert pipeline._live_spans == {}

def test_drops_a_span_whose_client_opted_out_mid_trace(self):
client = SimpleNamespace(disabled=False, _opted_out=False, send=True)
pipeline, _, _ = make(client=client)
span = pipeline.start_span("a")
client._opted_out = True
span.end()
assert queued(pipeline) == []
assert pipeline._live_spans == {}

def test_a_close_that_lands_mid_start_makes_the_span_inert(self):
pipeline, _, _ = make()
resolve = pipeline._resolve_parent
Expand Down
5 changes: 5 additions & 0 deletions posthog/test/tracing/test_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ def test_disabled_client_is_fatal_without_a_request(self):
assert outcome.kind == "fatal"
assert not session.post.called

def test_opted_out_client_is_fatal_without_a_request(self):
outcome, session = send(fake_client(_opted_out=True))
assert outcome.kind == "fatal"
assert not session.post.called

def test_send_false_is_ok_without_a_request(self):
outcome, session = send(fake_client(send=False))
assert outcome.kind == "ok"
Expand Down
17 changes: 9 additions & 8 deletions posthog/tracing/_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
build_traces_payload,
to_resource_key_value_list,
)
from ._transport import SendOutcome, send_traces_batch
from ._transport import SendOutcome, capture_blocked_reason, send_traces_batch

log = logging.getLogger("posthog")

Expand Down Expand Up @@ -278,7 +278,7 @@ def _drain(self, deadline: Optional[float]) -> Tuple[int, bool]:
Returns ``(removed, stop)``; ``stop`` means no follow-up pass now.
"""
with self._lock:
if self._is_closed() or self._discard_if_disabled_locked():
if self._is_closed() or self._discard_if_blocked_locked():
return 0, True
remaining = len(self._queue)
removed = 0
Expand All @@ -288,7 +288,7 @@ def _drain(self, deadline: Optional[float]) -> Tuple[int, bool]:
local_cap: Optional[int] = None
while remaining > 0:
with self._lock:
if self._is_closed() or self._discard_if_disabled_locked():
if self._is_closed() or self._discard_if_blocked_locked():
return removed, True
if not self._queue:
break
Expand Down Expand Up @@ -445,13 +445,14 @@ def _encode(self, batch: List[SpanRecord]) -> Tuple[List[dict], List[int]]:
failed.append(index)
return encoded, failed

def _discard_if_disabled_locked(self) -> bool:
if not getattr(self._client, "disabled", False):
def _discard_if_blocked_locked(self) -> bool:
blocked_reason = capture_blocked_reason(self._client)
if blocked_reason is None:
return False
# Spans carry person and session ids: once the client is disabled,
# nothing queued may export.
# Spans carry person and session ids: once the client is disabled or
# opted out, nothing queued may export.
if self._queue:
self._drops.record(len(self._queue), "the client is disabled")
self._drops.record(len(self._queue), blocked_reason)
self._queue = []
self._end_failure_sequence_locked()
return True
Expand Down
Loading
Loading