-
Notifications
You must be signed in to change notification settings - Fork 0
epic-2/story-1: Track Conversions from a Visitor Context #29
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
213f62f
493e9b4
545220a
552b3a6
4c2b98b
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 | ||||
|---|---|---|---|---|---|---|
|
|
@@ -96,3 +96,63 @@ def __post_init__(self) -> None: | |||||
| object.__setattr__( | ||||||
| self, "variables", MappingProxyType(dict(self.variables)) | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| class ConversionStatus(str, enum.Enum): | ||||||
| """The outcome of attempting to track a conversion (Story 2.1). | ||||||
|
|
||||||
| Mirrors the typed-result-with-status-enum precedent set by | ||||||
| :class:`FeatureStatus`. ``QUEUED`` means a conversion event was created and | ||||||
| associated with the visitor + resolved goal. ``GOAL_NOT_FOUND`` is the | ||||||
| diagnosable NON-EXCEPTION outcome (FR50) for a goal key absent from the | ||||||
| loaded config — distinguishable from success without ``try``/``except``. | ||||||
| """ | ||||||
|
|
||||||
| QUEUED = "queued" | ||||||
| GOAL_NOT_FOUND = "goal_not_found" | ||||||
|
|
||||||
|
|
||||||
| @dataclass(frozen=True) | ||||||
| class ConversionEvent: | ||||||
| """An in-process conversion event tied to a visitor and resolved goal. | ||||||
|
|
||||||
| Story 2.1 creates this locally from the current immutable snapshot and | ||||||
| visitor state — it carries the stable goal identity needed for later payload | ||||||
| shaping (Story 2.2 owns ``tracking/payloads.py``). No raw outbound payload | ||||||
| serialization happens here, and no network I/O is performed. | ||||||
|
|
||||||
| Attributes: | ||||||
| visitor_id: The visitor the conversion is attributed to. | ||||||
| goal_id: The resolved goal's id (stable downstream-attribution identity). | ||||||
| goal_key: The resolved goal's key (the public tracking handle / JS parity). | ||||||
| """ | ||||||
|
|
||||||
| visitor_id: str | ||||||
| goal_id: str | ||||||
|
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. In Python, it is idiomatic to represent the absence of an optional value using
Suggested change
References
|
||||||
| goal_key: str | ||||||
|
|
||||||
|
|
||||||
| @dataclass(frozen=True) | ||||||
| class ConversionResult: | ||||||
| """The typed outcome of :meth:`convert_sdk.context.Context.track_conversion`. | ||||||
|
|
||||||
| Always returned (never raised) for both success and the unknown-goal miss so | ||||||
| callers diagnose the outcome via :attr:`status` alone (FR50). On | ||||||
| ``QUEUED`` the :attr:`event` carries the created | ||||||
| :class:`ConversionEvent`; on ``GOAL_NOT_FOUND`` the event is ``None`` and | ||||||
| :attr:`goal_id` is ``None``. | ||||||
|
|
||||||
| Attributes: | ||||||
| status: :class:`ConversionStatus` — ``QUEUED`` or ``GOAL_NOT_FOUND``. | ||||||
| goal_key: The goal key the caller asked to track (always echoed back so | ||||||
| an unknown-goal result remains diagnosable). | ||||||
| goal_id: The resolved goal's id, or ``None`` when the goal was not found. | ||||||
| visitor_id: The visitor the tracking call was made for. | ||||||
| event: The created :class:`ConversionEvent`, or ``None`` on a miss. | ||||||
| """ | ||||||
|
|
||||||
| status: ConversionStatus | ||||||
| goal_key: str | ||||||
| goal_id: Optional[str] | ||||||
| visitor_id: str | ||||||
| event: Optional[ConversionEvent] = None | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| """Conversion tracking package for the Convert Python SDK (Story 2.1). | ||
|
|
||
| This package holds the tracking-domain slice. Story 2.1 introduces ONLY the | ||
| first layer — :mod:`convert_sdk.tracking.conversions`, which creates an | ||
| in-process conversion event from the immutable snapshot and visitor state | ||
| (single responsibility: conversion event creation). | ||
|
|
||
| Deliberate scope-narrowing (audit finding F-055): the architecture tree lists | ||
| ``tracker.py`` as the primary tracking module, but Story 2.1 ships | ||
| ``conversions.py`` instead and defers ``tracker.py`` orchestration plus | ||
| ``queue.py`` / ``deduplication.py`` / ``payloads.py`` / ``flush.py`` to later | ||
| Epic 2 stories. Nothing here performs network I/O, payload serialization, | ||
| batching, deduplication, or flush control. | ||
| """ | ||
|
|
||
| from convert_sdk.tracking.conversions import create_conversion | ||
|
|
||
| __all__ = ["create_conversion"] |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,84 @@ | ||||||||||
| """Conversion event creation from a visitor context (Story 2.1). | ||||||||||
|
|
||||||||||
| :func:`create_conversion` is the first tracking-domain operation: it resolves a | ||||||||||
| goal by key from the current immutable :class:`ConfigSnapshot` and builds an | ||||||||||
| in-process :class:`ConversionEvent` associated with the visitor and the resolved | ||||||||||
| goal identity. It returns a typed :class:`ConversionResult` for **both** | ||||||||||
| outcomes — a successful enqueue (``QUEUED``) and an unknown goal key | ||||||||||
| (``GOAL_NOT_FOUND``). | ||||||||||
|
|
||||||||||
| Audit-corrected behavior (F-052 / FR50): an unknown goal key is a *diagnosable | ||||||||||
| NON-EXCEPTION* outcome, not programmer misuse. The miss is distinguishable from | ||||||||||
| success purely via :attr:`ConversionResult.status` so callers never need | ||||||||||
| ``try``/``except`` to tell them apart. | ||||||||||
|
|
||||||||||
| Story 2.1 guardrails honored here: | ||||||||||
|
|
||||||||||
| * Goal resolution goes through the snapshot's precomputed index | ||||||||||
| (:meth:`ConfigSnapshot.get_goal_by_key`) — never an ad-hoc raw-config scan | ||||||||||
| (Critical Warning #4). | ||||||||||
| * No raw outbound payload assembly (Story 2.2 owns ``tracking/payloads.py``). | ||||||||||
| * No network I/O, batching, deduplication, or flush (later Epic 2 stories). | ||||||||||
| """ | ||||||||||
|
|
||||||||||
| from __future__ import annotations | ||||||||||
|
|
||||||||||
| from typing import TYPE_CHECKING | ||||||||||
|
|
||||||||||
| from convert_sdk.domain.results import ( | ||||||||||
| ConversionEvent, | ||||||||||
| ConversionResult, | ||||||||||
| ConversionStatus, | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| if TYPE_CHECKING: # pragma: no cover - typing only | ||||||||||
| from convert_sdk.domain.config_snapshot import ConfigSnapshot | ||||||||||
|
|
||||||||||
|
|
||||||||||
| def create_conversion( | ||||||||||
| snapshot: "ConfigSnapshot", | ||||||||||
| *, | ||||||||||
| visitor_id: str, | ||||||||||
| goal_key: str, | ||||||||||
| ) -> ConversionResult: | ||||||||||
|
Comment on lines
+38
to
+43
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. To support the forward-compatible signature of def create_conversion(
snapshot: "ConfigSnapshot",
*,
visitor_id: str,
goal_key: str,
**kwargs: Any,
) -> ConversionResult: |
||||||||||
| """Create an in-process conversion event for ``goal_key`` and ``visitor_id``. | ||||||||||
|
|
||||||||||
| Resolves the goal from the immutable ``snapshot``. On a hit, builds a | ||||||||||
| :class:`ConversionEvent` carrying the visitor and the stable goal identity | ||||||||||
| (id + key) and returns a ``QUEUED`` :class:`ConversionResult`. On a miss, | ||||||||||
| returns a ``GOAL_NOT_FOUND`` result with no event (FR50) — never raises. | ||||||||||
|
|
||||||||||
| The result is diagnosable without leaking config secrets or unrelated | ||||||||||
| visitor data: only the requested ``goal_key`` and the visitor's own id are | ||||||||||
| echoed back. | ||||||||||
| """ | ||||||||||
| goal = snapshot.get_goal_by_key(goal_key) | ||||||||||
| if goal is None: | ||||||||||
| # FR50: typed, diagnosable, NON-EXCEPTION miss — distinguishable from | ||||||||||
| # a successful enqueue purely by status. | ||||||||||
| return ConversionResult( | ||||||||||
| status=ConversionStatus.GOAL_NOT_FOUND, | ||||||||||
| goal_key=goal_key, | ||||||||||
| goal_id=None, | ||||||||||
| visitor_id=visitor_id, | ||||||||||
| event=None, | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| # Goals are indexed by key, so a resolved goal is guaranteed to have a key | ||||||||||
| # but may (defensively) lack an id. Preserve the real id as a string; never | ||||||||||
| # coerce a missing id into the literal "None", which would poison | ||||||||||
| # downstream attribution and diagnosability. | ||||||||||
| raw_goal_id = goal.get("id") | ||||||||||
| goal_id = str(raw_goal_id) if raw_goal_id is not None else "" | ||||||||||
|
Comment on lines
+71
to
+72
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. Aligning with the updated
Suggested change
References
|
||||||||||
| event = ConversionEvent( | ||||||||||
| visitor_id=visitor_id, | ||||||||||
| goal_id=goal_id, | ||||||||||
| goal_key=goal_key, | ||||||||||
| ) | ||||||||||
| return ConversionResult( | ||||||||||
| status=ConversionStatus.QUEUED, | ||||||||||
| goal_key=goal_key, | ||||||||||
| goal_id=goal_id, | ||||||||||
| visitor_id=visitor_id, | ||||||||||
| event=event, | ||||||||||
| ) | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docstring and PR description mention that the signature of
track_conversionis designed to be forward-compatible with Story 2.2'sconversion_data=kwargs. However, the current signaturedef track_conversion(self, goal_key: str)does not accept arbitrary keyword arguments. Any caller attempting to pass keyword arguments (e.g.,conversion_data) will encounter aTypeError. To make the signature truly forward-compatible, we should add**kwargs: Anyto bothtrack_conversionandcreate_conversion.