Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/convert_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@
from convert_sdk.config import SDKConfig, TransportConfig
from convert_sdk.context import Context
from convert_sdk.core import Core
from convert_sdk.domain.results import ExperienceResult, FeatureResult, FeatureStatus
from convert_sdk.domain.results import (
ConversionResult,
ConversionStatus,
ExperienceResult,
FeatureResult,
FeatureStatus,
)
from convert_sdk.errors import (
ConfigError,
ConfigLoadError,
Expand Down Expand Up @@ -48,4 +54,7 @@
# Minimal local feature-resolution foundation (Story 1.6).
"FeatureResult",
"FeatureStatus",
# Conversion tracking foundation (Story 2.1).
"ConversionResult",
"ConversionStatus",
]
33 changes: 32 additions & 1 deletion src/convert_sdk/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@
from typing import TYPE_CHECKING, Any, List, Mapping, Optional

from convert_sdk.domain.context_state import ContextState
from convert_sdk.domain.results import ExperienceResult, FeatureResult
from convert_sdk.domain.results import ConversionResult, ExperienceResult, FeatureResult
from convert_sdk.evaluation.experiences import select_experience
from convert_sdk.evaluation.features import resolve_feature, resolve_features
from convert_sdk.tracking.conversions import create_conversion

if TYPE_CHECKING: # pragma: no cover - typing only
from convert_sdk.domain.config_snapshot import ConfigSnapshot
Expand Down Expand Up @@ -221,3 +222,33 @@ def run_features(
visitor_attributes=visitor_attributes,
location_attributes=location,
)

# --- conversion tracking -----------------------------------------------

def track_conversion(self, goal_key: str) -> ConversionResult:
"""Track a goal conversion for this visitor (Story 2.1).

Resolves ``goal_key`` against the current immutable snapshot and creates
an in-process conversion event associated with this visitor and the
resolved goal identity. Returns a typed
:class:`~convert_sdk.domain.results.ConversionResult`:

* ``status == ConversionStatus.QUEUED`` — the goal resolved and an event
was created (``result.event`` carries the
:class:`~convert_sdk.domain.results.ConversionEvent`).
* ``status == ConversionStatus.GOAL_NOT_FOUND`` — the goal key is absent
from the loaded config. This is a diagnosable NON-EXCEPTION outcome
(FR50), distinguishable from success via ``status`` alone — callers
never need ``try``/``except`` to tell the two apart.

Performs no network I/O and no payload serialization; event delivery,
payload shaping, batching, deduplication, and flush land in later Epic 2
stories. The Story 2.1 surface is goal-key only; richer conversion
attributes (e.g. revenue) arrive in Story 2.2 as additional keyword
arguments, keeping this signature forward-compatible.
"""
return create_conversion(
self._snapshot,
visitor_id=self._state.visitor_id,
goal_key=goal_key,
)
Comment on lines +228 to +254

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The docstring and PR description mention that the signature of track_conversion is designed to be forward-compatible with Story 2.2's conversion_data= kwargs. However, the current signature def 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 a TypeError. To make the signature truly forward-compatible, we should add **kwargs: Any to both track_conversion and create_conversion.

    def track_conversion(self, goal_key: str, **kwargs: Any) -> ConversionResult:
        """Track a goal conversion for this visitor (Story 2.1).

        Resolves ``goal_key`` against the current immutable snapshot and creates
        an in-process conversion event associated with this visitor and the
        resolved goal identity. Returns a typed
        :class:`~convert_sdk.domain.results.ConversionResult`:

        * ``status == ConversionStatus.QUEUED`` — the goal resolved and an event
          was created (``result.event`` carries the
          :class:`~convert_sdk.domain.results.ConversionEvent`).
        * ``status == ConversionStatus.GOAL_NOT_FOUND`` — the goal key is absent
          from the loaded config. This is a diagnosable NON-EXCEPTION outcome
          (FR50), distinguishable from success via ``status`` alone — callers
          never need ``try``/``except`` to tell the two apart.

        Performs no network I/O and no payload serialization; event delivery,
        payload shaping, batching, deduplication, and flush land in later Epic 2
        stories. The Story 2.1 surface is goal-key only; richer conversion
        attributes (e.g. revenue) arrive in Story 2.2 as additional keyword
        arguments, keeping this signature forward-compatible.
        """
        return create_conversion(
            self._snapshot,
            visitor_id=self._state.visitor_id,
            goal_key=goal_key,
            **kwargs,
        )

18 changes: 18 additions & 0 deletions src/convert_sdk/domain/config_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ class ConfigSnapshot:
_audiences_by_key: Mapping[str, Any] = field(
default_factory=dict, repr=False, compare=False
)
_goals_by_key: Mapping[str, Any] = field(
default_factory=dict, repr=False, compare=False
)

def __post_init__(self) -> None:
object.__setattr__(
Expand All @@ -86,6 +89,12 @@ def __post_init__(self) -> None:
object.__setattr__(
self, "_audiences_by_key", MappingProxyType(_index_by(self.audiences, "key"))
)
# Story 2.1 (SDK-1): goals indexed by key so conversion tracking can
# resolve goal identity in O(1) from the immutable snapshot rather than
# scanning raw config (Critical Warning #4 / FR35).
object.__setattr__(
self, "_goals_by_key", MappingProxyType(_index_by(self.goals, "key"))
)

@classmethod
def from_normalized(cls, normalized: Mapping[str, Any]) -> "ConfigSnapshot":
Expand Down Expand Up @@ -131,3 +140,12 @@ def get_audience_by_id(self, audience_id: str) -> Optional[Mapping[str, Any]]:

def get_audience_by_key(self, key: str) -> Optional[Mapping[str, Any]]:
return self._audiences_by_key.get(key)

def get_goal_by_key(self, key: str) -> Optional[Mapping[str, Any]]:
"""Resolve a goal definition by its key, or ``None`` if absent.

Read-only accessor (never raises) used by conversion tracking to resolve
goal identity from the immutable snapshot. An unknown key returning
``None`` is the normal diagnosable miss path (FR50), not an error.
"""
return self._goals_by_key.get(key)
60 changes: 60 additions & 0 deletions src/convert_sdk/domain/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Python, it is idiomatic to represent the absence of an optional value using None rather than an empty string "". Since ConversionResult.goal_id is already typed as Optional[str], we should also type ConversionEvent.goal_id as Optional[str] to maintain consistency and avoid using empty strings as a sentinel for missing IDs.

Suggested change
goal_id: str
goal_id: Optional[str]
References
  1. Representing missing optional values as None is standard Pythonic practice (PEP 8 / idiomatic Python), rather than using empty strings as a sentinel. (link)

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
18 changes: 18 additions & 0 deletions src/convert_sdk/tracking/__init__.py
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"]
84 changes: 84 additions & 0 deletions src/convert_sdk/tracking/conversions.py
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To support the forward-compatible signature of Context.track_conversion, create_conversion should also accept arbitrary keyword arguments (**kwargs: Any). This ensures that when additional attributes (like revenue) are introduced in Story 2.2, the underlying creation service can receive them without breaking the current signature. Note: You will need to import Any from typing in this file.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Aligning with the updated Optional[str] type for ConversionEvent.goal_id, we should use None instead of "" when raw_goal_id is missing. This avoids using empty strings as a sentinel for missing IDs and keeps the representation of missing IDs consistent across both ConversionEvent and ConversionResult.

Suggested change
raw_goal_id = goal.get("id")
goal_id = str(raw_goal_id) if raw_goal_id is not None else ""
raw_goal_id = goal.get("id")
goal_id = str(raw_goal_id) if raw_goal_id is not None else None
References
  1. Representing missing optional values as None is standard Pythonic practice (PEP 8 / idiomatic Python), rather than using empty strings as a sentinel. (link)

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,
)
12 changes: 12 additions & 0 deletions tests/test_config_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ def test_snapshot_precomputes_entity_key_indexes():
assert snap.get_experience_by_key("missing") is None


def test_snapshot_precomputes_goal_key_index():
"""Story 2.1 (SDK-1): goals are indexed by key for O(1) resolution at
track-conversion time without scanning raw config (Critical Warning #4)."""
snap = load_snapshot(MINIMAL_CONFIG)
goal = snap.get_goal_by_key("goal-one")
assert goal is not None
assert goal["id"] == "g1"
assert goal["key"] == "goal-one"
# Unknown goal key returns None (read accessor, never raises).
assert snap.get_goal_by_key("does-not-exist") is None


def test_snapshot_is_immutable():
snap = load_snapshot(MINIMAL_CONFIG)
# Frozen dataclass — assigning an attribute must fail.
Expand Down
Loading