From a65ed52bdf80bff5324fe82f3d4f00f7b2cedc63 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Mon, 12 Oct 2026 20:13:25 -0700 Subject: [PATCH 1/2] fix: drop only the invalid event, and size analytics by capture's limits An event with a missing, empty or non-string name, or one that cannot be serialized (for example with NaN), is dropped alone instead of failing the whole batch at capture. A property key JSON cannot represent is dropped from its event. The analytics per-event limit is capture's 10 MiB request limit, since capture reports an event over its per-event limit in the response. A personal API key disables the client like an empty key, and logs no longer include event content or invalid timestamp values. Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/invalid-events-and-sizes.md | 5 ++ docs/migration-7.x-to-8.0.md | 15 ++++- posthog/_async_consumer.py | 4 +- posthog/async_client.py | 20 +++++-- posthog/capture_send.py | 4 +- posthog/client.py | 23 ++++++-- posthog/consumer.py | 12 ++-- posthog/test/test_ai_capture_lane.py | 9 ++- posthog/test/test_async_client.py | 19 +++++- posthog/test/test_before_send.py | 33 ++++++++--- posthog/test/test_client.py | 58 +++++++++++++++---- posthog/test/test_code_variables.py | 18 ++++-- posthog/test/test_utils.py | 18 +++++- posthog/utils.py | 57 +++++++++++++++++- references/public_api_snapshot.txt | 6 +- 15 files changed, 250 insertions(+), 51 deletions(-) create mode 100644 .sampo/changesets/invalid-events-and-sizes.md diff --git a/.sampo/changesets/invalid-events-and-sizes.md b/.sampo/changesets/invalid-events-and-sizes.md new file mode 100644 index 000000000..629fb5318 --- /dev/null +++ b/.sampo/changesets/invalid-events-and-sizes.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: major +--- + +An invalid event no longer costs the rest of its batch. An event with a missing, empty or non-string name is dropped locally, an event that cannot be serialized (for example with `NaN`) is dropped alone, and a property key JSON cannot represent is dropped from its event. The analytics per-event limit is now capture's 10 MiB request limit instead of 900 KiB. A personal API key (`phx_...`) disables the client like an empty key. Logs no longer include event content or invalid timestamp values. `AsyncPosthog` follows the same rules. diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index 0ae737fac..f4c21e5db 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -23,6 +23,8 @@ You need to change code if your app does any of these: - tests the AI integrations with a mock client and asserts on `capture` - passes its own client object to the AI integrations - sets `$lib` or `$lib_version`, or filters on `$lib = "posthog-python-mcp"` +- passes a personal API key (`phx_...`) as the project API key +- reads event content from the SDK's debug log ## Endpoints @@ -97,6 +99,17 @@ When the whole request is over the billing limit, capture returns a `402` with n - If a capture inside `on_error` fails, the SDK logs the failure and does not call `on_error` again. - The SDK retries `408`, `500`, `502`, `503` and `504` responses and network errors. Other error statuses fail the batch at once, including `429` and the other 5xx codes. 7.x also retried `429` and every 5xx. Capture itself does not return `429`; a proxy in front of it can. - Retries start at 100 ms and double each attempt, up to 30 seconds. A `Retry-After` header sets a minimum wait, up to 30 seconds. The `Consumer` default is 3 retries, down from 10. +- Logs never contain event content. The debug line for a queued event names the event and the lane, and the invalid timestamp warning no longer includes the value. To inspect events, use `before_send`. +- A personal API key (`phx_...`) passed as the project API key disables the client and logs one error, as an empty key does. Capture rejects personal API keys, and the SDK no longer sends one with every request. + +## Invalid events + +Capture rejects a whole batch when one event cannot be parsed. +8.0 drops only the invalid event, so the rest of the batch is delivered. + +- An event whose name is missing, `None`, empty or not a string is dropped. The SDK logs one warning per reason per process. 7.x sent it. +- An event that cannot be serialized to JSON is dropped and logged, for example one with `NaN` or `Infinity` in its properties. 7.x sent `NaN` as invalid JSON. +- A property or option key that JSON cannot represent, such as a tuple, is dropped without a log, and the rest of the event is sent. 7.x dropped the event. `int`, `float`, `bool` and `None` keys are still converted to strings. ## Event uuids @@ -194,7 +207,7 @@ These changes follow from this order: ## Batching and size limits -- An analytics event over 900 KiB is dropped and logged. This now applies in `sync_mode` too. +- An analytics event over 10 MiB is dropped and logged, because no capture request can carry it. 7.x dropped events over 900 KiB. Capture drops an event over its own per-event limit and reports it in `CaptureError.drops` as `event_too_big`. - An AI event over `capture_ai_max_event_bytes` is dropped and logged. - A batch stops before an event that would take it past 5 MiB. A larger event is sent alone. - `flush()` and `shutdown()` drain the analytics and AI queues at the same time. diff --git a/posthog/_async_consumer.py b/posthog/_async_consumer.py index 77e1294a4..d2dc5e75d 100644 --- a/posthog/_async_consumer.py +++ b/posthog/_async_consumer.py @@ -78,7 +78,9 @@ async def _report_capture_failure( async def _serialized_event_size(event: dict[str, Any]) -> int: - serialized = await asyncio.to_thread(json.dumps, event, cls=DatetimeSerializer) + serialized = await asyncio.to_thread( + json.dumps, event, cls=DatetimeSerializer, allow_nan=False + ) return len(serialized.encode()) diff --git a/posthog/async_client.py b/posthog/async_client.py index bbe235f94..bd738df60 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -98,8 +98,10 @@ from .types import FlagMetadata, FlagValue, normalize_flags_response from .utils import ( SizeLimitedDict, + _is_personal_api_key, _normalize_timestamp, clean, + _drop_invalid_event_name, system_context, ) from .version import VERSION @@ -189,7 +191,9 @@ def __init__( self.on_error = on_error self.max_retries = max(0, max_retries) self.timeout = timeout - self.disabled = disabled or not self.api_key + self.disabled = ( + disabled or not self.api_key or _is_personal_api_key(self.api_key) + ) self.disable_geoip = disable_geoip self.is_server = is_server self.historical_migration = historical_migration @@ -276,6 +280,11 @@ def __init__( self.log.error( "api_key is empty after trimming whitespace; check your project API key" ) + elif _is_personal_api_key(self.api_key): + self.log.error( + "api_key is a personal API key (phx_...), not a project API key; " + "PostHog is disabled. Use the project API key (phc_...)." + ) self._analytics_lane = _AsyncLane( name="analytics", @@ -472,8 +481,8 @@ def _prepare_event( msg["timestamp"] = _normalize_timestamp(timestamp) except ValueError: self.log.warning( - "Invalid timestamp %r. Falling back to the current UTC time.", - timestamp, + "Invalid timestamp; it must be a datetime or an ISO 8601 string. " + "Falling back to the current UTC time." ) msg["timestamp"] = datetime.now(tz=timezone.utc).isoformat() @@ -498,7 +507,10 @@ async def _process_event( self, msg: dict[str, Any], defaults: Optional[_EventDefaults] ) -> Optional[dict[str, Any]]: _fill_event_defaults(msg, defaults) - return await self._run_before_send(msg) + processed = await self._run_before_send(msg) + if processed is None or _drop_invalid_event_name(self.log, processed): + return None + return processed async def _run_before_send(self, msg: dict[str, Any]) -> Optional[dict[str, Any]]: if self.before_send is None: diff --git a/posthog/capture_send.py b/posthog/capture_send.py index 7734d0534..c9b24feb6 100644 --- a/posthog/capture_send.py +++ b/posthog/capture_send.py @@ -336,7 +336,9 @@ def _post_v1( """ trimmed_host = remove_trailing_slash(normalize_host(host)) url = trimmed_host + path - data = json.dumps(batch_body, cls=DatetimeSerializer) + # Every event was serialized on its own with allow_nan=False before it + # joined the batch, so this never raises for an event the SDK accepted. + data = json.dumps(batch_body, cls=DatetimeSerializer, allow_nan=False) headers = { "Content-Type": "application/json", "User-Agent": sdk_info, diff --git a/posthog/client.py b/posthog/client.py index daed99dc3..f0ffa718c 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -137,6 +137,8 @@ SizeLimitedDict, clean, _normalize_timestamp, + _is_personal_api_key, + _drop_invalid_event_name, guess_timezone as guess_timezone, system_context, ) @@ -997,7 +999,9 @@ def __init__( _BackgroundEventLoopRunner ] = None self._flag_definition_cache_provider_async_runner_lock = threading.Lock() - self.disabled = disabled or not self.api_key + self.disabled = ( + disabled or not self.api_key or _is_personal_api_key(self.api_key) + ) self.disable_geoip = disable_geoip self._metrics_config = metrics self._metrics: Optional[PostHogMetrics] = None @@ -1123,6 +1127,11 @@ def __init__( self.log.error( "api_key is empty after trimming whitespace; check your project API key" ) + elif _is_personal_api_key(self.api_key): + self.log.error( + "api_key is a personal API key (phx_...), not a project API key; " + "PostHog is disabled. Use the project API key (phc_...)." + ) self._set_before_send(before_send) @@ -2517,7 +2526,8 @@ def _enqueue( msg["timestamp"] = _normalize_timestamp(timestamp) except ValueError: self.log.warning( - "Invalid timestamp %r. Falling back to the current UTC time.", timestamp + "Invalid timestamp; it must be a datetime or an ISO 8601 string. " + "Falling back to the current UTC time." ) msg["timestamp"] = datetime.now(tz=timezone.utc).isoformat() @@ -2577,7 +2587,10 @@ def _enqueue( self._normalize_event_uuid(msg) sent_uuid = msg["uuid"] - self.log.debug("queueing: %s", msg) + if _drop_invalid_event_name(self.log, msg): + return None + + self.log.debug("queueing %s on the %s lane", msg["event"], lane.name) # if send is False, return msg as if it was successfully queued, unless # shutdown has already closed this lane's admission. @@ -2595,7 +2608,9 @@ def _enqueue( self.log.debug("enqueued with blocking %s.", msg["event"]) try: - event_size = len(json.dumps(msg, cls=_DatetimeSerializer).encode()) + event_size = len( + json.dumps(msg, cls=_DatetimeSerializer, allow_nan=False).encode() + ) except Exception: self.log.error("Unable to serialize event for sizing, dropping.") return None diff --git a/posthog/consumer.py b/posthog/consumer.py index 46c8c6abb..1f9c9f316 100644 --- a/posthog/consumer.py +++ b/posthog/consumer.py @@ -19,7 +19,10 @@ from queue import Empty -MAX_MSG_SIZE = 900 * 1024 # 900KiB per event +# Capture's request body limit. An analytics event larger than this can never +# be accepted, so the SDK drops it locally; anything smaller is sent, and +# capture reports an event over its own per-event limit in the response. +MAX_MSG_SIZE = 10 * 1024 * 1024 # The AI endpoint's per-event ceiling. The endpoint applies it to the # serialized properties alone. @@ -33,8 +36,7 @@ # A batch closes before it appends an event that would take it past this, so # a request carries at most this much event data, or one larger event alone. -# The maximum request body size is currently 20MiB, let's be conservative -# in case we want to lower it in the future. +# Half of capture's request body limit, which applies before decompression. BATCH_SIZE_LIMIT = 5 * 1024 * 1024 _configure_posthog_logging() @@ -246,7 +248,9 @@ def next(self): pending_items += 1 try: item_size = len( - json.dumps(item, cls=DatetimeSerializer).encode() + json.dumps( + item, cls=DatetimeSerializer, allow_nan=False + ).encode() ) except Exception: # Callback-modified events can still contain invalid mapping diff --git a/posthog/test/test_ai_capture_lane.py b/posthog/test/test_ai_capture_lane.py index badafc86c..dd1843803 100644 --- a/posthog/test/test_ai_capture_lane.py +++ b/posthog/test/test_ai_capture_lane.py @@ -227,10 +227,15 @@ def test_sync_mode_ai_event_at_endpoint_ceiling_is_sent(self): self.assertIsNotNone(result) mock_send.assert_called_once() - def test_analytics_lane_rejects_events_over_900kib(self): + def test_analytics_lane_sends_events_up_to_the_request_limit(self): + # Capture reports an analytics event over its per-event limit in the + # response, so the SDK only drops what no request could carry. client = self._client() consumer = client.consumers[0] - client.queue.put(self._sized_event("big_analytics_event", 2 * 1024 * 1024)) + big = self._sized_event("big_analytics_event", 2 * 1024 * 1024) + client.queue.put(big) + client.queue.put(self._sized_event("too_big_for_a_request", MAX_MSG_SIZE)) + self.assertEqual(consumer.next(), [big]) self.assertEqual(consumer.next(), []) self.assertTrue(client.queue.empty()) diff --git a/posthog/test/test_async_client.py b/posthog/test/test_async_client.py index 5404471b3..f9ae5165b 100644 --- a/posthog/test/test_async_client.py +++ b/posthog/test/test_async_client.py @@ -408,8 +408,23 @@ async def test_ai_capture_handles_a_non_string_event_name_like_capture(event): try: expected = client.capture(event, distinct_id="user-1") is None assert (client.capture_ai(event, distinct_id="user-1") is None) is expected - immediate = await client.capture_ai_immediate(event, distinct_id="user-1") - assert (immediate is None) is expected + # The immediate paths process the event, so they drop the invalid name. + assert await client.capture_immediate(event, distinct_id="user-1") is None + assert await client.capture_ai_immediate(event, distinct_id="user-1") is None + finally: + await client.shutdown() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("api_key", ["", " ", "phx_secret"]) +async def test_missing_or_personal_api_key_disables_the_client(api_key): + with mock.patch.object(AsyncPosthog.log, "error") as mock_error: + client = AsyncPosthog(api_key, send=False) + try: + assert client.disabled + assert client.capture("event", distinct_id="user-1") is None + mock_error.assert_called_once() + assert "secret" not in repr(mock_error.call_args) finally: await client.shutdown() diff --git a/posthog/test/test_before_send.py b/posthog/test/test_before_send.py index 3d7441d97..af0dac383 100644 --- a/posthog/test/test_before_send.py +++ b/posthog/test/test_before_send.py @@ -2,6 +2,8 @@ import unittest from unittest import mock +from parameterized import parameterized + import posthog from posthog.client import Client @@ -243,28 +245,45 @@ def test_before_send_callback_non_dict_output_drops_event(self): "before_send must return a dict or None", mock_log.call_args.args[0] ) - def test_malformed_before_send_event_does_not_stop_consumer_or_shutdown(self): - def add_invalid_mapping_key(event): + @parameterized.expand( + [ + # A key json cannot write costs only that key. + ( + "unwritable_key", + ("private-key",), + "private-value", + ["malformed", "valid"], + ), + # A value json cannot write costs only that event, never the batch. + ("non_finite_value", "private-key", float("nan"), ["valid"]), + ] + ) + def test_malformed_before_send_event_does_not_stop_consumer_or_shutdown( + self, _name, key, value, expected_events + ): + def add_malformed_property(event): if event["event"] == "malformed": - event["properties"][("private-key",)] = "private-value" + event["properties"][key] = value return event client = Client( FAKE_TEST_API_KEY, - before_send=add_invalid_mapping_key, - flush_at=1, + before_send=add_malformed_property, + flush_at=2, flush_interval=0.01, ) with ( patch_capture_send("consumer") as mock_post, - self.assertLogs("posthog", level="ERROR") as logs, + self.assertLogs("posthog", level="DEBUG") as logs, ): client.capture("malformed", distinct_id="user1") client.capture("valid", distinct_id="user1") client.shutdown() mock_post.assert_called_once() - self.assertEqual([event["event"] for event in sent_batch(mock_post)], ["valid"]) + sent = sent_batch(mock_post) + self.assertEqual([event["event"] for event in sent], expected_events) + self.assertTrue(all(key not in event["properties"] for event in sent)) self.assertEqual(client.queue.unfinished_tasks, 0) self.assertTrue(all(not consumer.is_alive() for consumer in client.consumers)) self.assertNotIn("private-key", "\n".join(logs.output)) diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 6d510cd91..25ecab952 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -79,23 +79,32 @@ def test_requires_api_key(self): @parameterized.expand( [ - ("valid_key", " \nphc_validkey\t ", "phc_validkey", False, False), - ("whitespace_only", " \n\t ", "", True, True), - ("empty_string", "", "", True, True), + ("valid_key", " \nphc_validkey\t ", "phc_validkey", False, None), + ("whitespace_only", " \n\t ", "", True, "api_key is empty"), + ("empty_string", "", "", True, "api_key is empty"), + ( + "personal_api_key", + " phx_secret ", + "phx_secret", + True, + "personal API key", + ), ] ) def test_trims_api_key_whitespace( - self, _, raw_api_key, expected_api_key, expected_disabled, expect_error_log + self, _, raw_api_key, expected_api_key, expected_disabled, expected_error ): with mock.patch.object(Client.log, "error") as mock_error: client = Client(raw_api_key, send=False) self.assertEqual(client.api_key, expected_api_key) self.assertEqual(client.disabled, expected_disabled) - if expect_error_log: - mock_error.assert_called_once_with( - "api_key is empty after trimming whitespace; check your project API key" - ) + if expected_error: + mock_error.assert_called_once() + message = " ".join(str(arg) for arg in mock_error.call_args.args) + self.assertIn(expected_error, message) + self.assertNotIn("secret", message) + self.assertIsNone(client.capture("event", distinct_id="user")) else: mock_error.assert_not_called() @@ -1868,9 +1877,36 @@ def test_capture_replaces_invalid_timestamp_with_current_utc_time(self, timestam self.assertIsNotNone(result) msg = sent_batch(mock_post)[0] self.assertEqual(msg["timestamp"], "2026-06-27T12:30:00+00:00") - mock_warning.assert_called_once_with( - "Invalid timestamp %r. Falling back to the current UTC time.", timestamp - ) + mock_warning.assert_called_once() + self.assertNotIn(timestamp, repr(mock_warning.call_args)) + + @parameterized.expand( + [ + ("missing", None, None), + ("non_string", 123, None), + ("empty", "", None), + ("set_by_before_send", "valid", 123), + ] + ) + def test_capture_drops_an_invalid_event_name_and_logs_once( + self, _, event, hook_event + ): + def before_send(msg): + if hook_event is not None: + msg["event"] = hook_event + return msg + + with ( + patch_capture_send("client") as mock_post, + mock.patch("posthog.utils._warned_reasons", set()), + mock.patch.object(Client.log, "warning") as mock_warning, + ): + client = Client(FAKE_TEST_API_KEY, sync_mode=True, before_send=before_send) + results = [client.capture(event, distinct_id="user") for _ in range(2)] + + self.assertEqual(results, [None, None]) + mock_post.assert_not_called() + mock_warning.assert_called_once() def test_capture_does_not_normalize_datetime_properties(self): property_value = datetime( diff --git a/posthog/test/test_code_variables.py b/posthog/test/test_code_variables.py index 21d527469..4e8fab36a 100644 --- a/posthog/test/test_code_variables.py +++ b/posthog/test/test_code_variables.py @@ -119,6 +119,11 @@ def extract( posthog.request._session.post = offline_v1_post +def print_event(event): + print("captured:", event, flush=True) + return event + + def make_client(**options): return Posthog( "phc_x", @@ -126,6 +131,7 @@ def make_client(**options): debug=True, enable_exception_autocapture=True, project_root=os.path.dirname(os.path.abspath(__file__)), + before_send=print_event, **options, ) """ @@ -135,9 +141,9 @@ def run_app(tmpdir, body, *, env=None): """Write and run a tiny PostHog app that raises an uncaught exception. The app already has ``make_client(**options)`` and ``posthog`` imported; the body - creates the client, defines the failing code, and triggers it. In debug mode the - autocaptured payload (including ``code_variables``) is printed, which is what the - end-to-end tests assert against. Returns the combined stdout/stderr. + creates the client, defines the failing code, and triggers it. A ``before_send`` + hook prints the autocaptured payload (including ``code_variables``), which is what + the end-to-end tests assert against. Returns the combined stdout/stderr. """ app = tmpdir.join("app.py") app.write(_APP_HEADER + "\n" + dedent(body)) @@ -1024,8 +1030,8 @@ def outer(note): class TestEndToEnd: """The whole code-variables pipeline through a real subprocess: an uncaught - exception is autocaptured and its serialized payload (with code_variables) is - printed in debug mode.""" + exception is autocaptured and its payload (with code_variables) is printed by a + before_send hook.""" def test_code_variables_are_captured_and_masked(self, tmpdir): # the failing frame's locals: one safe dict, two secrets, one ignored dunder @@ -1050,7 +1056,7 @@ def trigger_error(): ) assert "ZeroDivisionError" in output assert "'code_variables':" in output # the key form, not the temp-path word - # scalars show up in the debug log in their repr form + # scalars show up in the printed payload in their repr form assert "'greeting': 'hello world'" in output assert "'count': 42" in output # a dict variable is double-encoded as a JSON string (locks the wire format) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index c2dd1a0c5..3d32aca4c 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -355,10 +355,22 @@ def clean_or_raise(value): assert utils._clean_dict({"ok": 1, "bad": bad_value}) == {"ok": 1} warning.assert_called_once() - assert warning.call_args.args[0] == ( - 'Dictionary values must be serializeable to JSON "%s" value %s of type %s is unsupported.' + assert warning.call_args.args[1:] == ("bad", type(bad_value)) + assert bad_value not in warning.call_args.args + + def test_clean_drops_only_keys_json_cannot_write(self): + cleaned = utils.clean( + { + "s": 1, + 2: "int", + 1.5: "float", + True: "bool", + None: "none", + ("t",): "tuple", + } ) - assert warning.call_args.args[1:] == ("bad", bad_value, type(bad_value)) + assert cleaned == {"s": 1, 2: "int", 1.5: "float", True: "bool", None: "none"} + json.dumps(cleaned) def test_coerce_unicode(self): assert utils._coerce_unicode("already unicode") == "already unicode" diff --git a/posthog/utils.py b/posthog/utils.py index 840478411..98bcb60b3 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -136,16 +136,69 @@ def _clean_list(list_): return [clean(item) for item in list_] +_warned_reasons: set[str] = set() +_warned_reasons_lock = threading.Lock() + + +def _warn_once(logger: logging.Logger, reason: str, message: str, *args: Any) -> None: + """Log `message` the first time `reason` occurs in this process, then stay silent. + + For drops caused by caller input, which can repeat at event rate. + """ + with _warned_reasons_lock: + if reason in _warned_reasons: + return + _warned_reasons.add(reason) + logger.warning(message, *args) + + +def _is_personal_api_key(api_key: str) -> bool: + """A personal API key (phx_...) is never a project API key; capture rejects it.""" + return api_key.startswith("phx_") + + +def _invalid_event_name_reason(event: Any) -> Optional[str]: + """Why capture would reject `event` as an event name, or None if it is valid.""" + if event is None: + return "missing" + if not isinstance(event, str): + return "non-string" + if not event: + return "empty" + return None + + +def _drop_invalid_event_name(logger: logging.Logger, msg: dict) -> bool: + """True when `msg` has an event name capture would reject; logs once per reason.""" + reason = _invalid_event_name_reason(msg.get("event")) + if reason is None: + return False + _warn_once( + logger, + f"event_name_{reason}", + "Dropping an event with a %s name: event names must be non-empty strings. " + "Later events dropped for the same reason are not logged.", + reason, + ) + return True + + +# The key types json.dumps can write; it converts the non-string ones to strings. +_JSON_KEY_TYPES = (str, int, float, bool, type(None)) + + def _clean_dict(dict_): data = {} for k, v in dict_.items(): + # Drop only a key json cannot write, so one bad key never costs the event. + if not isinstance(k, _JSON_KEY_TYPES): + continue try: data[k] = clean(v) except TypeError: log.warning( - 'Dictionary values must be serializeable to JSON "%s" value %s of type %s is unsupported.', + 'Dictionary value for key "%s" of type %s is not JSON serializable, dropping it.', k, - v, type(v), ) return data diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index ee4e2c583..ac48257d0 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -661,7 +661,7 @@ attribute posthog.async_client.AsyncClient.code_variables_mask_patterns = code_v attribute posthog.async_client.AsyncClient.code_variables_mask_url_credentials = code_variables_mask_url_credentials if code_variables_mask_url_credentials is not None else DEFAULT_CODE_VARIABLES_MASK_URL_CREDENTIALS attribute posthog.async_client.AsyncClient.debug = debug attribute posthog.async_client.AsyncClient.disable_geoip = disable_geoip -attribute posthog.async_client.AsyncClient.disabled = disabled or not self.api_key +attribute posthog.async_client.AsyncClient.disabled = disabled or not self.api_key or _is_personal_api_key(self.api_key) attribute posthog.async_client.AsyncClient.distinct_ids_feature_flags_reported = SizeLimitedDict(_MAX_DICT_SIZE, set) attribute posthog.async_client.AsyncClient.enable_full_ai_capture = enable_full_ai_capture is True attribute posthog.async_client.AsyncClient.feature_flags_request_max_retries = max(0, feature_flags_request_max_retries) @@ -716,7 +716,7 @@ attribute posthog.client.Client.cohorts: Optional[dict[str, Any]] = None attribute posthog.client.Client.consumers: Optional[List[Consumer]] attribute posthog.client.Client.debug = debug attribute posthog.client.Client.disable_geoip = disable_geoip -attribute posthog.client.Client.disabled = disabled or not self.api_key +attribute posthog.client.Client.disabled = disabled or not self.api_key or _is_personal_api_key(self.api_key) attribute posthog.client.Client.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set) attribute posthog.client.Client.enable_exception_autocapture = enable_exception_autocapture attribute posthog.client.Client.enable_exception_autocapture_rate_limiting = enable_exception_autocapture_rate_limiting @@ -780,7 +780,7 @@ attribute posthog.consumer.Consumer.queue = queue attribute posthog.consumer.Consumer.retries = max(0, retries) attribute posthog.consumer.Consumer.running = True attribute posthog.consumer.Consumer.timeout = timeout -attribute posthog.consumer.MAX_MSG_SIZE = 900 * 1024 +attribute posthog.consumer.MAX_MSG_SIZE = 10 * 1024 * 1024 attribute posthog.contexts.ContextScope.capture_exception_code_variables: Optional[bool] = None attribute posthog.contexts.ContextScope.capture_exceptions = capture_exceptions attribute posthog.contexts.ContextScope.client: Optional[Client] = client From d205df87685fd792a4762e48875662a9ad3dd9a6 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Mon, 12 Oct 2026 22:10:36 -0700 Subject: [PATCH 2/2] test: cover the event name, warn-once and personal key helpers in test_utils Co-Authored-By: Claude Opus 5.5 --- posthog/test/test_utils.py | 64 +++++++++++++++++++++++++++++++++++--- posthog/utils.py | 2 +- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/posthog/test/test_utils.py b/posthog/test/test_utils.py index 3d32aca4c..1ae182c5f 100644 --- a/posthog/test/test_utils.py +++ b/posthog/test/test_utils.py @@ -354,19 +354,75 @@ def clean_or_raise(value): ): assert utils._clean_dict({"ok": 1, "bad": bad_value}) == {"ok": 1} - warning.assert_called_once() - assert warning.call_args.args[1:] == ("bad", type(bad_value)) - assert bad_value not in warning.call_args.args + warning.assert_called_once_with( + 'Dictionary value for key "%s" of type %s is not JSON serializable, dropping it.', + "bad", + type(bad_value), + ) + + @parameterized.expand( + [ + ("missing", None, "missing"), + ("non_string", 123, "non-string"), + ("bytes", b"event", "non-string"), + ("empty", "", "empty"), + ("valid", "signed_up", None), + ("whitespace_is_a_name", " ", None), + ] + ) + def test_invalid_event_name_reason(self, _, event, expected): + assert utils._invalid_event_name_reason(event) == expected + + def test_drop_invalid_event_name_logs_each_reason_once(self): + logger = mock.Mock() + with mock.patch.object(utils, "_warned_reasons", set()): + dropped = [ + utils._drop_invalid_event_name(logger, msg) + for msg in ({"event": None}, {}, {"event": ""}, {"event": "ok"}) + ] + + assert dropped == [True, True, True, False] + message = ( + "Dropping an event with a %s name: event names must be non-empty strings. " + "Later events dropped for the same reason are not logged." + ) + assert logger.warning.call_args_list == [ + mock.call(message, "missing"), + mock.call(message, "empty"), + ] + + def test_warn_once_logs_each_reason_once(self): + logger = mock.Mock() + with mock.patch.object(utils, "_warned_reasons", set()): + utils._warn_once(logger, "a", "first %s", 1) + utils._warn_once(logger, "a", "again %s", 2) + utils._warn_once(logger, "b", "second") + + assert logger.warning.call_args_list == [ + mock.call("first %s", 1), + mock.call("second"), + ] + + @parameterized.expand( + [ + ("personal", "phx_abc", True), + ("project", "phc_abc", False), + ("prefix_elsewhere", "abc_phx_", False), + ("empty", "", False), + ] + ) + def test_is_personal_api_key(self, _, api_key, expected): + assert utils._is_personal_api_key(api_key) is expected def test_clean_drops_only_keys_json_cannot_write(self): cleaned = utils.clean( { + ("t",): "tuple", "s": 1, 2: "int", 1.5: "float", True: "bool", None: "none", - ("t",): "tuple", } ) assert cleaned == {"s": 1, 2: "int", 1.5: "float", True: "bool", None: "none"} diff --git a/posthog/utils.py b/posthog/utils.py index 98bcb60b3..9f845a316 100644 --- a/posthog/utils.py +++ b/posthog/utils.py @@ -175,7 +175,7 @@ def _drop_invalid_event_name(logger: logging.Logger, msg: dict) -> bool: return False _warn_once( logger, - f"event_name_{reason}", + f"event_name_{reason}", # pragma: no mutate "Dropping an event with a %s name: event names must be non-empty strings. " "Later events dropped for the same reason are not logged.", reason,