Repository navigation
feat!: send capture through v1 by default; capture_ai to /i/v1/ai/events #1017
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,10 +13,9 @@ | |
| class CaptureMode(str, Enum): | ||
| """Selects the capture wire protocol used for event ingestion. | ||
|
|
||
| ``V0`` is the legacy ``POST /batch/`` endpoint and the default, so upgrading | ||
| is transparent to existing callers. ``V1`` opts into | ||
| ``POST /i/v1/analytics/events`` (Bearer auth, per-event results, partial | ||
| retry). Inheriting from ``str`` keeps the members directly comparable to and | ||
| ``V1`` is ``POST /i/v1/analytics/events`` (Bearer auth, per-event results, | ||
| partial retry) and the default. ``V0`` opts back into the legacy | ||
| ``POST /batch/`` endpoint. Inheriting from ``str`` keeps the members directly comparable to and | ||
| serializable as their ``"v0"`` / ``"v1"`` values. | ||
| """ | ||
|
|
||
|
|
@@ -61,24 +60,24 @@ def _resolve_capture_mode( | |
| """Resolve the effective capture mode. | ||
|
|
||
| Precedence: explicit ``capture_mode`` argument > ``POSTHOG_CAPTURE_MODE`` env | ||
| var > ``CaptureMode.V0``. An unrecognized env value logs a warning and falls | ||
| back to ``V0`` so a typo never silently flips the wire protocol. | ||
| var > ``CaptureMode.V1``. An unrecognized env value logs a warning and falls | ||
| back to ``V1`` so a typo never silently flips the wire protocol. | ||
| """ | ||
| if capture_mode is not None: | ||
| return _coerce_explicit(capture_mode) | ||
|
|
||
| raw = os.environ.get(CAPTURE_MODE_ENV_VAR) | ||
| if raw is None or raw.strip() == "": | ||
| return CaptureMode.V0 | ||
| return CaptureMode.V1 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With v1 becoming the default, replaying an event with its original UUID and timestamp before flushing can reject unrelated events too. If both copies share a request, the backend rejects the entire batch with HTTP 400, and the SDK drops every entry. #1030 already documents this behavior. Consider discarding identical replay entries before submission, preserving their UUID/timestamp, so they cannot take unrelated events down with them. Regression sketch: enqueue A, unrelated B, then an identical replay of A. Verify the outgoing batch contains A once and B is delivered. This sketch was not executed. AI-assisted review.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looked into this, thanks for the callout @dustinbyrne. After some re-examination, I think I'm comfortable leaving this as-is. Definitely ping me back about this and we can discuss if you have concerns with my rationale: The only scenario where this can happen is:
For the former, I think end users should be leaning on the SDK-managed retry functionality, and if they're not, I believe overlaps/conflicts should still be rare. Submissions of same-UUID events by the same team is very rare in the data, and checking the time intervals between these submissions, they fall into 1-10 second buckets so I suspect this kind of overlap will be very rare during normal operation. For the latter, we do see a very small number of teams submitting the same UUID purposely across related but not identical events in the live data. This is an off-label use of event.UUID to group events, and we'd like to discourage it, since event.UUID was explicitly added as a deduping mechanism, and this practice thwarts current and more robust future event deduping efforts on Let me know what you think 🙇 |
||
|
|
||
| resolved = _ALIASES.get(raw.strip().lower()) | ||
| if resolved is None: | ||
| log.warning( | ||
| "Unrecognized %s=%r; falling back to %s. Expected one of %s.", | ||
| CAPTURE_MODE_ENV_VAR, | ||
| raw, | ||
| CaptureMode.V0.value, | ||
| CaptureMode.V1.value, | ||
| sorted(_ALIASES), | ||
| ) | ||
| return CaptureMode.V0 | ||
| return CaptureMode.V1 | ||
| return resolved | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| """Intercept capture uploads at the batch submitter for client-level tests. | ||
|
|
||
| Patching the submitter (not the HTTP layer) lets tests assert on the event | ||
| dicts the SDK built, before the wire encoding in ``capture_v1``. Wire shape is | ||
| covered by ``test_capture_v1``. | ||
| """ | ||
|
|
||
| import json | ||
| from unittest import mock | ||
|
|
||
| from requests import Response | ||
|
|
||
| _SUBMITTER = "_send_v1_batch" | ||
|
|
||
|
|
||
| def offline_v1_post(url: str, data=None, **kwargs) -> Response: | ||
| """Stand-in for ``requests.Session.post`` that accepts every v1 event. | ||
|
|
||
| For subprocess tests with no server. Prints the uncompressed request body, | ||
| because the SDK never logs payloads, and answers ``ok`` for each event. | ||
| """ | ||
| print(f"capture request body: {data}", flush=True) # noqa: T201 | ||
| events = json.loads(data)["batch"] | ||
| response = Response() | ||
| response.status_code = 200 | ||
| response._content = json.dumps( | ||
| {"results": {event["uuid"]: {"result": "ok"} for event in events}} | ||
| ).encode() | ||
| return response | ||
|
|
||
|
|
||
| def patch_capture_send(site: str = "client", **kwargs) -> "mock._patch": | ||
| """Patch the submitter where ``posthog.<site>`` imported it. | ||
|
|
||
| ``site="client"`` sees ``sync_mode`` uploads; ``site="consumer"`` sees | ||
| background consumer uploads. | ||
| """ | ||
| return mock.patch(f"posthog.{site}.{_SUBMITTER}", **kwargs) | ||
|
|
||
|
|
||
| def patch_async_capture_send(**kwargs) -> "mock._patch": | ||
| """Patch the submitter the ``AsyncPosthog`` consumer awaits.""" | ||
| return mock.patch("posthog._async_consumer.async_send_v1_batch", **kwargs) | ||
|
|
||
|
|
||
| def sent_batch(send_mock: mock.Mock, call_index: int = -1) -> list[dict]: | ||
| """Return the event batch from one recorded upload (default: the last).""" | ||
| call = send_mock.call_args_list[call_index] | ||
| return call.args[2] if len(call.args) > 2 else call.kwargs["batch"] | ||
|
|
||
|
|
||
| def sent_events(send_mock: mock.Mock) -> list[dict]: | ||
| """Return every event uploaded through ``send_mock``, in send order.""" | ||
| return [ | ||
| event | ||
| for index in range(len(send_mock.call_args_list)) | ||
| for event in sent_batch(send_mock, index) | ||
| ] |
Uh oh!
There was an error while loading. Please reload this page.