Skip to content

Commit ab273ec

Browse files
authored
Merge pull request #51 from convertcom/fix/bucketing-activation-events-parity
fix: emit experience-activation (bucketing) tracking events with per-call enable_tracking control
2 parents 80440da + ca1aba4 commit ab273ec

11 files changed

Lines changed: 1086 additions & 23 deletions

File tree

‎src/convert_sdk/context.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ def run_experience(
416416
*,
417417
attributes: Optional[Mapping[str, Any]] = None,
418418
location_attributes: Optional[Mapping[str, Any]] = None,
419+
enable_tracking: bool = True,
419420
) -> Optional[ExperienceResult]:
420421
"""Evaluate a single experience by key for this visitor.
421422
@@ -425,6 +426,19 @@ def run_experience(
425426
qualifies and buckets into a variation, or ``None`` for any normal miss
426427
(missing experience, unqualified visitor, no active variation). Never
427428
raises for normal evaluation outcomes and performs no network I/O.
429+
430+
Args:
431+
experience_key: The experience key to evaluate.
432+
attributes: Optional per-call visitor attribute overlay (ephemeral).
433+
location_attributes: Optional per-call location attribute overlay
434+
(ephemeral).
435+
enable_tracking: When ``True`` (the default), a successfully-bucketed
436+
result enqueues a bucketing activation event via the tracker
437+
(Story 2.5). When ``False``, no bucketing event is enqueued and
438+
no ``LifecycleEvent.BUCKETING`` is emitted. The returned
439+
:class:`~convert_sdk.domain.results.ExperienceResult` is
440+
identical regardless of this flag — it gates tracking only, not
441+
evaluation. Must be passed as a keyword argument.
428442
"""
429443
visitor_attributes = self._state.with_overlay(attributes)
430444
location = self._merge(self._location_attributes, location_attributes)
@@ -436,20 +450,36 @@ def run_experience(
436450
location_attributes=location,
437451
)
438452
self._log_bucketing(result)
453+
if result is not None and enable_tracking and self._tracker is not None:
454+
self._tracker.track_bucketing(
455+
visitor_id=self._state.visitor_id,
456+
experience_id=result.experience_id,
457+
variation_id=result.variation_id,
458+
)
439459
return result
440460

441461
def run_experiences(
442462
self,
443463
*,
444464
attributes: Optional[Mapping[str, Any]] = None,
445465
location_attributes: Optional[Mapping[str, Any]] = None,
466+
enable_tracking: bool = True,
446467
) -> List[ExperienceResult]:
447468
"""Evaluate all applicable experiences for this visitor.
448469
449470
Returns the list of typed results for the experiences the visitor
450471
qualifies for and buckets into; experiences that do not resolve are
451472
omitted (no ``None`` entries). Evaluation stays local to the snapshot —
452473
no network I/O.
474+
475+
Args:
476+
attributes: Optional per-call visitor attribute overlay (ephemeral).
477+
location_attributes: Optional per-call location attribute overlay
478+
(ephemeral).
479+
enable_tracking: When ``True`` (the default), each successfully-bucketed
480+
result enqueues a bucketing activation event via the tracker
481+
(Story 2.5). When ``False``, no bucketing events are enqueued
482+
for any resolved experience. Must be passed as a keyword argument.
453483
"""
454484
visitor_attributes = self._state.with_overlay(attributes)
455485
location = self._merge(self._location_attributes, location_attributes)
@@ -468,6 +498,12 @@ def run_experiences(
468498
if result is not None:
469499
results.append(result)
470500
self._log_bucketing(result)
501+
if enable_tracking and self._tracker is not None:
502+
self._tracker.track_bucketing(
503+
visitor_id=self._state.visitor_id,
504+
experience_id=result.experience_id,
505+
variation_id=result.variation_id,
506+
)
471507
return results
472508

473509
# --- feature resolution ------------------------------------------------

‎src/convert_sdk/domain/results.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,28 @@ class EntityDiagnostic(_Diagnostic):
308308
"""
309309

310310

311+
@dataclass(frozen=True)
312+
class BucketingEvent:
313+
"""An in-process bucketing activation event tied to a visitor and experience.
314+
315+
Story 2.5 creates this locally when a visitor is bucketed into a variation —
316+
it carries the stable identity fields needed for later payload shaping. Parallel
317+
to :class:`ConversionEvent` but simpler: no revenue, no goalData, no segments
318+
here (those are conversion-only concerns). All fields stay **snake_case internal**
319+
— no wire-name mapping happens on this value object (that is the serializer's
320+
sole job, per the architecture's data boundary).
321+
322+
Attributes:
323+
visitor_id: The visitor the bucketing is attributed to.
324+
experience_id: The resolved experience's id (stable downstream identity).
325+
variation_id: The selected variation's id.
326+
"""
327+
328+
visitor_id: str
329+
experience_id: str
330+
variation_id: str
331+
332+
311333
@dataclass(frozen=True)
312334
class CustomSegmentsResult:
313335
"""The typed outcome of :meth:`convert_sdk.context.Context.run_custom_segments`.

‎src/convert_sdk/events.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
Enum identifiers are frozen by the PRD (``prd.md#API-Surface``) and aligned with
1111
the JS ``SystemEvents`` parity subset:
1212
13-
* ``READY`` / ``CONFIG_UPDATED`` / ``BUCKETING`` are defined for completeness and
14-
JS parity but are emitted by the initialization/config and evaluation layers —
15-
Story 2.4 does NOT emit them (Critical Warning #12).
13+
* ``READY`` / ``CONFIG_UPDATED`` are defined for completeness and JS parity but
14+
are emitted by the initialization/config layers.
15+
* ``BUCKETING`` is emitted from the tracking enqueue path (Story 2.5) after a
16+
visitor is bucketed into a variation and the bucketing event is enqueued.
1617
* ``CONVERSION`` is emitted from the tracking enqueue path on a tracked
1718
(non-suppressed) conversion.
1819
* ``API_QUEUE_RELEASED`` is emitted from the single shared release path on every
@@ -74,6 +75,21 @@ class ConversionEventPayload:
7475
goal_key: str
7576

7677

78+
@dataclass(frozen=True)
79+
class BucketingEventPayload:
80+
"""Domain-relevant context for a ``BUCKETING`` lifecycle event (Story 2.5).
81+
82+
Carries ONLY internal snake_case domain identity fields — never raw visitor
83+
attributes, the wire payload, or any transport object (NFR6). Built directly
84+
from the in-process bucketing event, not from the wire serializer, so emission
85+
stays off the serialization path. Parallel to :class:`ConversionEventPayload`.
86+
"""
87+
88+
visitor_id: str
89+
experience_id: str
90+
variation_id: str
91+
92+
7793
@dataclass(frozen=True)
7894
class QueueReleasedPayload:
7995
"""Diagnostic context for an ``API_QUEUE_RELEASED`` lifecycle event.

‎src/convert_sdk/tracking/deduplication.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,48 @@ class DedupDecision:
8383
already_tracked: bool
8484

8585

86+
def bucketing_marker_key(visitor_id: str, experience_id: str) -> str:
87+
"""Build the DataStore key for the ``(visitor_id, experience_id)`` bucketing marker.
88+
89+
Uses a collision-safe namespaced key
90+
``f"bucketing:{json.dumps([visitor_id, experience_id])}"``. JSON serialization of a
91+
two-element list guarantees no collision regardless of separator characters in the
92+
values — e.g. ``("a:b", "c")`` → ``'bucketing:["a:b", "c"]'`` and
93+
``("a", "b:c")`` → ``'bucketing:["a", "b:c"]'`` are guaranteed distinct, where a
94+
naive ``f"{visitor_id}:{experience_id}"`` composite would collide. Parallel to
95+
:func:`goal_marker_key` but uses the ``bucketing:`` prefix to keep concerns separate.
96+
"""
97+
return f"bucketing:{json.dumps([visitor_id, experience_id])}"
98+
99+
100+
def evaluate_bucketing_dedup(
101+
store: DataStore,
102+
*,
103+
visitor_id: str,
104+
experience_id: str,
105+
) -> bool:
106+
"""Check and persist a bucketing deduplication marker for ``(visitor_id, experience_id)``.
107+
108+
Returns ``True`` (should enqueue) when the marker is absent (first time for this
109+
visitor+experience pair) and persists the marker via ``store.set(key, True)``.
110+
Returns ``False`` (suppress) when the marker is already present.
111+
112+
Bucketing dedup has no ``force_multiple`` / ``goalData`` analog — it is a plain
113+
"first time?" check scoped to ``(visitor_id, experience_id)``. This satisfies
114+
Story 2.5 AC#3: client-side dedup ensures each visitor-experience pair produces
115+
at most one bucketing event per DataStore scope. Note that the JS SDK enqueues
116+
bucketing events unconditionally and relies on server-side ``enrichData`` — the
117+
Python SDK honors the story contract with an explicit client-side dedup marker
118+
instead, consistent with the Python SDK's ``DataStore``-backed dedup approach
119+
for conversions.
120+
"""
121+
key = bucketing_marker_key(visitor_id, experience_id)
122+
if store.has(key):
123+
return False
124+
store.set(key, True)
125+
return True
126+
127+
86128
def evaluate_dedup(
87129
store: DataStore,
88130
*,

‎src/convert_sdk/tracking/payloads.py‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939
from typing import TYPE_CHECKING, Any, Dict, List, Optional
4040

41-
from convert_sdk.domain.results import ConversionEvent
41+
from convert_sdk.domain.results import BucketingEvent, ConversionEvent
4242

4343
if TYPE_CHECKING: # pragma: no cover - typing only
4444
from convert_sdk.domain.config_snapshot import ConfigSnapshot
@@ -188,6 +188,61 @@ def _build_visitor_entry(event: ConversionEvent) -> Dict[str, Any]:
188188
return visitor
189189

190190

191+
def build_bucketing_payload(
192+
snapshot: "ConfigSnapshot",
193+
event: BucketingEvent,
194+
*,
195+
data_store: Optional[Any] = None,
196+
) -> Dict[str, Any]:
197+
"""Assemble the JS-SDK outbound bucketing-event payload for one bucketing activation.
198+
199+
Wire contract anchored against ``types.gen.ts:2467-2473`` (VisitorTrackingEvents
200+
wrapper) and ``types.gen.ts:2486-2497`` (BucketingEvent body) and
201+
``architecture.md §"Bucketing Event Tracking Format"``:
202+
203+
{eventType: "bucketing", data: {experienceId: str, variationId: str}}
204+
205+
No ``timestamp``, no extra keys beyond ``eventType`` and ``data``, no ``segments``
206+
on the visitor entry (bucketing events carry no segment attribution). The stable
207+
envelope fields (``accountId`` / ``projectId`` / ``source`` / ``enrichData``) are
208+
computed identically to :func:`build_tracking_payload` so a mixed batch serializes
209+
coherently through :meth:`~convert_sdk.tracking.tracker.Tracker._build_batch_payload`.
210+
211+
Args:
212+
snapshot: The immutable config snapshot supplying ``accountId`` /
213+
``projectId``.
214+
event: The internal snake_case :class:`~convert_sdk.domain.results.BucketingEvent`
215+
to serialize.
216+
data_store: The configured DataStore, if any. ``enrichData`` is computed as
217+
``data_store is None`` (F-002 parity with :func:`build_tracking_payload`).
218+
219+
Returns:
220+
A JSON-serializable ``dict`` matching the JS-SDK ``SendTrackingEventsRequestData``
221+
envelope with a single bucketing event entry. In-memory only — no batching,
222+
queue, or network I/O happens here.
223+
"""
224+
return {
225+
"accountId": snapshot.account_id,
226+
"projectId": snapshot.project_id,
227+
"enrichData": data_store is None,
228+
"source": TRACKING_SOURCE,
229+
"visitors": [
230+
{
231+
"visitorId": event.visitor_id,
232+
"events": [
233+
{
234+
"eventType": "bucketing",
235+
"data": {
236+
"experienceId": str(event.experience_id),
237+
"variationId": str(event.variation_id),
238+
},
239+
}
240+
],
241+
}
242+
],
243+
}
244+
245+
191246
def build_tracking_payload(
192247
snapshot: "ConfigSnapshot",
193248
event: ConversionEvent,

‎src/convert_sdk/tracking/queue.py‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,15 @@
3030
import enum
3131
import threading
3232
from dataclasses import dataclass, field
33-
from typing import Any, List, Mapping, Optional
33+
from typing import Any, List, Mapping, Optional, Union
3434

35-
from convert_sdk.domain.results import ConversionEvent
35+
from convert_sdk.domain.results import BucketingEvent, ConversionEvent
36+
37+
# Union type for all events the queue can hold (Story 2.5). Both ConversionEvent
38+
# and BucketingEvent carry a ``visitor_id`` field so per-visitor grouping works
39+
# unchanged. The queue holds snake_case domain objects only — wire serialization
40+
# is dispatched per-type at flush time in ``tracking/tracker.py``.
41+
TrackedEvent = Union[ConversionEvent, BucketingEvent]
3642

3743

3844
class ReleaseReason(str, enum.Enum):
@@ -64,7 +70,7 @@ class VisitorQueueItem:
6470
"""
6571

6672
visitor_id: str
67-
events: List[ConversionEvent] = field(default_factory=list)
73+
events: List[TrackedEvent] = field(default_factory=list)
6874
segments: Optional[Mapping[str, Any]] = None
6975

7076

@@ -125,18 +131,23 @@ def update_snapshot_metadata(
125131

126132
def enqueue(
127133
self,
128-
event: ConversionEvent,
134+
event: TrackedEvent,
129135
*,
130136
segments: Optional[Mapping[str, Any]] = None,
131137
) -> bool:
132-
"""Append a conversion event for its visitor; lightweight and synchronous.
138+
"""Append a tracked event (conversion or bucketing) for its visitor; lightweight and sync.
133139
134140
Groups the event under its ``visitor_id`` and records the visitor's
135141
active ``segments`` (latest write wins). Performs no network I/O and no
136142
wire serialization (NFR5). Returns ``True`` when this enqueue brings the
137143
total event count to the configured ``batch_size`` — the signal that the
138144
caller should release the queue via the shared release path with
139145
:attr:`ReleaseReason.SIZE`. Returns ``False`` otherwise.
146+
147+
Both :class:`~convert_sdk.domain.results.ConversionEvent` and
148+
:class:`~convert_sdk.domain.results.BucketingEvent` are accepted; they
149+
coexist in the same per-visitor batch and are dispatched per-type at flush
150+
time in :meth:`~convert_sdk.tracking.tracker.Tracker._build_batch_payload`.
140151
"""
141152
with self._lock:
142153
item = self._items.get(event.visitor_id)

0 commit comments

Comments
 (0)