Skip to content

Commit d092fce

Browse files
committed
fix: warn once without the value when replacing an invalid event uuid
An invalid caller uuid is still replaced with a generated UUIDv7, but the SDK now logs one warning that names the rule instead of an error that echoes the value. An empty string counts as unset and gets a generated uuid without a log. Client and AsyncClient share one helper.
1 parent 80b485b commit d092fce

7 files changed

Lines changed: 68 additions & 51 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: major
3+
---
4+
5+
An invalid event `uuid` is still replaced with a generated one, but the SDK now logs one warning instead of an error, and the warning no longer includes the value. An empty string `uuid` counts as unset, so the SDK generates one without logging. `AsyncPosthog` follows the same rules.

‎docs/migration-7.x-to-8.0.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ When the whole request is over the billing limit, capture returns a `402` with n
102102

103103
- Each event needs its own uuid. Capture rejects a whole batch that contains the same uuid twice, so the other events in that batch are lost too.
104104
- The SDK accepts a uuid with hyphens, 32 hex digits, `{...}` braces or a `urn:uuid:` prefix, in any case. It sends the lowercase hyphenated form and returns that form from `capture`.
105-
- Any other value is replaced with a generated uuid, and the SDK logs an error. This applies to `AsyncPosthog` too.
105+
- Any other value is replaced with a generated uuid, and the SDK logs one warning that names the rule, not the value. An empty string counts as unset, so the SDK generates a uuid without a warning. This applies to `AsyncPosthog` too.
106106
- Generated uuids are UUIDv7.
107107

108108
## Session and window IDs

‎posthog/async_client.py‎

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@
3939
)
4040
from .capture_event import (
4141
_build_event_defaults,
42-
_canonical_event_uuid,
4342
_event_options,
4443
_EventDefaults,
4544
_fill_event_defaults,
4645
_merge_groups,
46+
_resolve_event_uuid,
4747
)
4848
from .capture_send import _CAPTURE_AI_V1_PATH, _CAPTURE_V1_PATH
4949
from .client import (
@@ -98,7 +98,6 @@
9898
from .utils import (
9999
SizeLimitedDict,
100100
_normalize_timestamp,
101-
_uuid7,
102101
clean,
103102
system_context,
104103
)
@@ -447,18 +446,7 @@ def enqueue_on_bound_loop() -> None:
447446
return admitted.result()
448447

449448
def _normalize_uuid(self, msg: dict[str, Any]) -> str:
450-
raw_uuid = msg.pop("uuid", None)
451-
if raw_uuid is not None:
452-
normalized = _canonical_event_uuid(raw_uuid)
453-
if normalized is None:
454-
self.log.error(
455-
"Invalid UUID %r. Falling back to a generated UUID.", raw_uuid
456-
)
457-
else:
458-
msg["uuid"] = normalized
459-
return normalized
460-
461-
normalized = str(_uuid7())
449+
normalized = _resolve_event_uuid(msg.pop("uuid", None))
462450
msg["uuid"] = normalized
463451
return normalized
464452

‎posthog/capture_event.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from typing import Any, Optional
2727
from uuid import UUID
2828

29-
from posthog.utils import _normalize_timestamp
29+
from posthog.utils import _normalize_timestamp, _uuid7
3030
from posthog.utils import clean as _clean
3131

3232
log = logging.getLogger("posthog")
@@ -78,6 +78,24 @@ def _canonical_event_uuid(value: Any) -> Optional[str]:
7878
return str(UUID(value.lower()))
7979

8080

81+
def _resolve_event_uuid(value: Any) -> str:
82+
"""Return the canonical form of a caller's event uuid, or a generated one.
83+
84+
A missing or empty uuid is generated silently. An invalid one is replaced
85+
and logs one warning. The warning names the rule and not the value,
86+
because callers can put their own data in the uuid field.
87+
"""
88+
if value is not None and value != "":
89+
canonical = _canonical_event_uuid(value)
90+
if canonical is not None:
91+
return canonical
92+
log.warning(
93+
"Event uuid is not a valid UUID string or uuid.UUID. "
94+
"Sending the event with a generated UUID."
95+
)
96+
return str(_uuid7())
97+
98+
8199
def _json_type_name(value: Any) -> str:
82100
"""Name a value's JSON type, the same names posthog-go and posthog-rs log."""
83101
if isinstance(value, bool):

‎posthog/client.py‎

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@
3636
)
3737
from posthog.capture_event import (
3838
_build_event_defaults,
39-
_canonical_event_uuid,
4039
_event_options,
4140
_fill_event_defaults,
4241
_merge_groups,
42+
_resolve_event_uuid,
4343
)
4444
from posthog.capture_send import (
4545
_CAPTURE_AI_V1_PATH,
@@ -137,7 +137,6 @@
137137
SizeLimitedDict,
138138
clean,
139139
_normalize_timestamp,
140-
_uuid7,
141140
guess_timezone as guess_timezone,
142141
system_context,
143142
)
@@ -274,15 +273,6 @@ def get_identity_state(passed) -> tuple[str, bool]:
274273
return (str(uuid4()), True)
275274

276275

277-
def _stringify_event_uuid(value) -> str:
278-
canonical = _canonical_event_uuid(value)
279-
if canonical is None:
280-
raise ValueError(
281-
f"Invalid event uuid {value!r}. Expected a valid UUID string or uuid.UUID instance."
282-
)
283-
return canonical
284-
285-
286276
def _personless_options(personless: bool) -> dict[str, Any]:
287277
"""The lowest option layer: no person profile for a generated distinct ID."""
288278
return {"process_person_profile": False} if personless else {}
@@ -2475,17 +2465,8 @@ def _reinit_after_fork(self):
24752465
def _normalize_event_uuid(self, msg):
24762466
# type: (...) -> None
24772467
"""Ensure `msg["uuid"]` is a valid uuid string, generating one if missing or invalid."""
2478-
if "uuid" in msg:
2479-
uuid = msg.pop("uuid")
2480-
if uuid is not None:
2481-
try:
2482-
msg["uuid"] = _stringify_event_uuid(uuid)
2483-
except ValueError as e:
2484-
self.log.error("%s Falling back to a generated UUID.", e)
2485-
2486-
if "uuid" not in msg:
2487-
# Always send a uuid, so we can always return one
2488-
msg["uuid"] = str(_uuid7())
2468+
# Always send a uuid, so we can always return one
2469+
msg["uuid"] = _resolve_event_uuid(msg.pop("uuid", None))
24892470

24902471
def _report_capture_failure(
24912472
self, error: Exception, batch: list[dict], endpoint: str

‎posthog/test/test_async_client.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,26 @@ async def send_batch(api_key, host, batch, **kwargs):
7272
assert event["uuid"] == event_uuid
7373

7474

75+
@pytest.mark.asyncio
76+
@pytest.mark.parametrize(
77+
"supplied, warnings",
78+
[("not-a-uuid-secret-1", 1), (123, 1), ("", 0), (None, 0)],
79+
)
80+
async def test_capture_replaces_unusable_uuid_and_warns_only_when_invalid(
81+
caplog, supplied, warnings
82+
):
83+
client = AsyncPosthog("test-key", send=False)
84+
with caplog.at_level(logging.WARNING, logger="posthog"):
85+
event_uuid = client.capture("async event", distinct_id="user-1", uuid=supplied)
86+
await client.shutdown()
87+
88+
assert UUID(event_uuid).version == 7
89+
logged = [r for r in caplog.records if r.levelno >= logging.WARNING]
90+
assert len(logged) == warnings
91+
assert all(r.levelname == "WARNING" for r in logged)
92+
assert all(str(supplied) not in r.getMessage() for r in logged)
93+
94+
7595
def test_capture_from_worker_thread_wakes_loop_bound_queue():
7696
script = r"""
7797
import asyncio

‎posthog/test/test_client.py‎

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -476,18 +476,17 @@ def test_capture_sends_and_returns_canonical_uuid(self, _name, supplied):
476476

477477
@parameterized.expand(
478478
[
479-
("empty string", ""),
480-
("invalid string", "not-a-uuid"),
479+
("invalid string", "not-a-uuid-secret-1"),
481480
("short string", "1234"),
482481
("integer", 123),
483482
]
484483
)
485-
def test_capture_with_invalid_uuid_logs_and_falls_back_to_generated_uuid(
484+
def test_capture_with_invalid_uuid_warns_and_falls_back_to_generated_uuid(
486485
self, _name, invalid_uuid
487486
):
488487
with patch_capture_send("client") as mock_post:
489488
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
490-
with self.assertLogs("posthog", level="ERROR") as logs:
489+
with self.assertLogs("posthog", level="WARNING") as logs:
491490
msg_uuid = client.capture(
492491
"python test event", distinct_id="distinct_id", uuid=invalid_uuid
493492
)
@@ -498,18 +497,24 @@ def test_capture_with_invalid_uuid_logs_and_falls_back_to_generated_uuid(
498497
msg = sent_batch(mock_post)[0]
499498
self.assertEqual(msg["uuid"], msg_uuid)
500499
self.assertNotEqual(msg["uuid"], str(invalid_uuid))
501-
self.assertTrue(
502-
any(
503-
f"Invalid event uuid {invalid_uuid!r}" in message
504-
and "Expected a valid UUID string or uuid.UUID instance" in message
505-
and "Falling back to a generated UUID" in message
506-
for message in logs.output
500+
uuid_warnings = [r for r in logs.records if "Event uuid" in r.getMessage()]
501+
self.assertEqual(len(uuid_warnings), 1)
502+
self.assertEqual(uuid_warnings[0].levelname, "WARNING")
503+
self.assertNotIn(str(invalid_uuid), uuid_warnings[0].getMessage())
504+
505+
def test_capture_with_empty_uuid_generates_one_without_logging(self):
506+
with patch_capture_send("client") as mock_post:
507+
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
508+
with self.assertNoLogs("posthog", level="WARNING"):
509+
msg_uuid = client.capture(
510+
"python test event", distinct_id="distinct_id", uuid=""
507511
)
508-
)
512+
513+
self.assertEqual(UUID(msg_uuid).version, 7)
514+
self.assertEqual(sent_batch(mock_post)[0]["uuid"], msg_uuid)
509515

510516
@parameterized.expand(
511517
[
512-
("empty string", ""),
513518
("invalid string", "not-a-uuid"),
514519
("short string", "1234"),
515520
("integer", 123),
@@ -518,7 +523,7 @@ def test_capture_with_invalid_uuid_logs_and_falls_back_to_generated_uuid(
518523
def test_capture_with_invalid_uuid_falls_back_in_debug(self, _name, invalid_uuid):
519524
with patch_capture_send("client") as mock_post:
520525
client = Client(FAKE_TEST_API_KEY, debug=True, sync_mode=True)
521-
with self.assertLogs("posthog", level="ERROR"):
526+
with self.assertLogs("posthog", level="WARNING"):
522527
msg_uuid = client.capture(
523528
"python test event", distinct_id="distinct_id", uuid=invalid_uuid
524529
)

0 commit comments

Comments
 (0)