diff --git a/.sampo/changesets/opt-out-capturing.md b/.sampo/changesets/opt-out-capturing.md new file mode 100644 index 000000000..7dcb3bf5a --- /dev/null +++ b/.sampo/changesets/opt-out-capturing.md @@ -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. diff --git a/posthog/__init__.py b/posthog/__init__.py index d718906f0..5d9dc4afb 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -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. diff --git a/posthog/client.py b/posthog/client.py index d74ea56e3..05df4c0a5 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -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 @@ -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) @@ -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"] @@ -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. diff --git a/posthog/metrics_capture.py b/posthog/metrics_capture.py index 6e454842c..d42262879 100644 --- a/posthog/metrics_capture.py +++ b/posthog/metrics_capture.py @@ -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 = { diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 1c86a0daa..32ec31fb3 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -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) diff --git a/posthog/test/test_metrics.py b/posthog/test/test_metrics.py index 0575c2aa1..e47bf5c45 100644 --- a/posthog/test/test_metrics.py +++ b/posthog/test/test_metrics.py @@ -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): diff --git a/posthog/test/test_module.py b/posthog/test/test_module.py index dc875e674..6699d59c2 100644 --- a/posthog/test/test_module.py +++ b/posthog/test/test_module.py @@ -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() diff --git a/posthog/test/tracing/test_export.py b/posthog/test/tracing/test_export.py index 52cff1515..10c538d90 100644 --- a/posthog/test/tracing/test_export.py +++ b/posthog/test/tracing/test_export.py @@ -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")) diff --git a/posthog/test/tracing/test_pipeline.py b/posthog/test/tracing/test_pipeline.py index 1026f3b96..d2cdb9d4e 100644 --- a/posthog/test/tracing/test_pipeline.py +++ b/posthog/test/tracing/test_pipeline.py @@ -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) @@ -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 diff --git a/posthog/test/tracing/test_transport.py b/posthog/test/tracing/test_transport.py index 1073bbda6..3e7369a17 100644 --- a/posthog/test/tracing/test_transport.py +++ b/posthog/test/tracing/test_transport.py @@ -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" diff --git a/posthog/tracing/_export.py b/posthog/tracing/_export.py index e68385f78..78c55c78d 100644 --- a/posthog/tracing/_export.py +++ b/posthog/tracing/_export.py @@ -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") @@ -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 @@ -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 @@ -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 diff --git a/posthog/tracing/_pipeline.py b/posthog/tracing/_pipeline.py index b09d25dff..2ec324f60 100644 --- a/posthog/tracing/_pipeline.py +++ b/posthog/tracing/_pipeline.py @@ -29,6 +29,7 @@ inert_span, ) from ._traceparent import parse_traceparent, sanitize_tracestate, traceparent_header +from ._transport import capture_blocked_reason log = logging.getLogger("posthog") @@ -137,7 +138,7 @@ def _start_span( tracestate: Any, start_time: Any, ) -> Span: - if self._closed or getattr(self._client, "disabled", False): + if self._closed or capture_blocked_reason(self._client) is not None: return inert_span(parent, tracestate, self._active_var) parent = traceparent_header(parent) @@ -304,9 +305,9 @@ def _on_span_end(self, record: SpanRecord) -> None: # A miss means it was evicted for age, or tracing was shut down. if self._live_spans.pop(record.span_id, None) is None: return - disabled = getattr(self._client, "disabled", False) - if disabled: - self._drops.record(1, "the client is disabled") + blocked_reason = capture_blocked_reason(self._client) + if blocked_reason is not None: + self._drops.record(1, blocked_reason) else: # The hook is application code, so it runs with no lock held. hooked = run_before_span_send(record, self._config, self._drops) diff --git a/posthog/tracing/_transport.py b/posthog/tracing/_transport.py index a261a0e41..da863651b 100644 --- a/posthog/tracing/_transport.py +++ b/posthog/tracing/_transport.py @@ -83,6 +83,15 @@ def parse_retry_after(value: Any, now: Optional[datetime] = None) -> Optional[fl return seconds +def capture_blocked_reason(client: Any) -> Optional[str]: + """Why the client may not send spans right now, or ``None`` when it may.""" + if getattr(client, "disabled", False): + return "the client is disabled" + if getattr(client, "_opted_out", False): + return "capturing is opted out" + return None + + def send_traces_batch(client: Any, payload: dict) -> SendOutcome: """POST one OTLP batch with bearer auth and gzip, classifying the response. @@ -90,7 +99,7 @@ def send_traces_batch(client: Any, payload: dict) -> SendOutcome: retriable; any other status is fatal. A 2xx is not proof of ingestion: an unknown but well-formed key is accepted and the spans dropped downstream. """ - if getattr(client, "disabled", False): + if capture_blocked_reason(client) is not None: return FATAL if not getattr(client, "send", True): return OK diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 956efda25..0dd7f4b76 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1421,6 +1421,7 @@ function posthog.identify_context(distinct_id: str) function posthog.integrations.django.markcoroutinefunction(func) function posthog.integrations.drf.create_exception_handler(handler: Optional[Callable[[Exception, Mapping[str, Any]], Any]] = None, *, client: Optional[Client] = None, capture_exceptions: Optional[bool] = None, capture_4xx: bool = False, exception_filter: Optional[Callable[[Exception, Any, Mapping[str, Any]], bool]] = None) -> Callable[[Exception, Mapping[str, Any]], Any] function posthog.integrations.drf.exception_handler(exc: Exception, context: Mapping[str, Any]) -> Any +function posthog.is_opted_out() -> bool function posthog.join() -> None function posthog.load_feature_flags() function posthog.mcp.asgi.autowire_stateless_mint(server: Any) -> None @@ -1437,6 +1438,8 @@ function posthog.mcp.session_token.encode_session_id(payload: SessionTokenPayloa function posthog.mcp.session_token.read_mcp_session_header(headers: Any) -> Optional[str] function posthog.mcp.tools.get_more_tools_result() -> Dict[str, Any] function posthog.new_context(fresh: bool = False, capture_exceptions: Optional[bool] = None, client: Optional[Client] = None) +function posthog.opt_in_capturing() -> None +function posthog.opt_out_capturing() -> None function posthog.request.batch_post(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, path: str = EVENTS_ENDPOINT, **kwargs) -> requests.Response function posthog.request.determine_server_host(host: Optional[str]) -> str function posthog.request.disable_connection_reuse() -> None @@ -1622,9 +1625,12 @@ method posthog.client.Client.get_remote_config_payload(key: str) method posthog.client.Client.get_tags() -> Dict[str, Any] 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] method posthog.client.Client.identify_context(distinct_id: str) -> None +method posthog.client.Client.is_opted_out() -> bool method posthog.client.Client.join() -> None method posthog.client.Client.load_feature_flags() method posthog.client.Client.new_context(fresh=False, capture_exceptions: Optional[bool] = None) +method posthog.client.Client.opt_in_capturing() -> None +method posthog.client.Client.opt_out_capturing() -> None method posthog.client.Client.scoped(fresh=False, capture_exceptions: Optional[bool] = None) method posthog.client.Client.set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str] method posthog.client.Client.set_context_device_id(device_id: str) -> None