Skip to content
Merged
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
7 changes: 6 additions & 1 deletion src/convert_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Stable public import boundary for the Convert Python SDK."""

from .config import SDKConfig, TransportConfig
from .config import SDKConfig, TrackingConfig, TransportConfig
from .context import Context
from .core import Core
from .domain.results import (
Expand All @@ -9,10 +9,12 @@
ExperienceResult,
FeatureResult,
FeatureStatus,
TrackingFlushResult,
)
from .errors import (
ConfigLoadError,
ConfigValidationError,
ConversionDataError,
GoalNotFoundError,
InitializationError,
TrackingError,
Expand All @@ -22,6 +24,7 @@
__all__ = [
"ConfigLoadError",
"ConfigValidationError",
"ConversionDataError",
"Context",
"Core",
"ConversionEvent",
Expand All @@ -32,6 +35,8 @@
"GoalNotFoundError",
"InitializationError",
"SDKConfig",
"TrackingConfig",
"TrackingFlushResult",
"TrackingError",
"TransportConfig",
"__version__",
Expand Down
46 changes: 43 additions & 3 deletions src/convert_sdk/adapters/transport/httpx_transport.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
"""HTTPX-backed config transport adapter."""
"""HTTPX-backed config and tracking transport adapter."""

from __future__ import annotations

from typing import Any, Mapping, Optional

import httpx

from ...ports.transport import ConfigRequest
from ...ports.transport import ConfigRequest, TrackingRequest


class HttpxTransport:
"""Sync-first transport adapter for Convert config fetches."""
"""Sync-first transport adapter for Convert config fetches and tracking."""

def __init__(self, client: Optional[httpx.Client] = None) -> None:
self._client = client
Expand Down Expand Up @@ -46,3 +46,43 @@ def fetch_config(self, request: ConfigRequest) -> Mapping[str, Any]:
raise TypeError("Config endpoint returned a non-object JSON payload")

return payload

def send_tracking(self, request: TrackingRequest) -> Mapping[str, Any]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
}
headers.update(dict(request.transport.headers))
if request.sdk_key_secret:
headers["Authorization"] = f"Bearer {request.sdk_key_secret}"

if request.sdk_key:
route = request.sdk_key
elif request.account_id and request.project_id:
route = f"{request.account_id}/{request.project_id}"
else:
raise ValueError(
"Tracking delivery requires sdk_key or account_id/project_id"
)

url = f"{request.transport.tracking_endpoint.rstrip('/')}/track/{route}"

owns_client = self._client is None
client = self._client or httpx.Client(
timeout=request.transport.timeout_seconds,
verify=request.transport.verify_tls,
)
try:
response = client.post(url, json=dict(request.payload), headers=headers)
response.raise_for_status()
if not response.content:
return {}
payload = response.json()
finally:
if owns_client:
client.close()

if not isinstance(payload, dict):
raise TypeError("Tracking endpoint returned a non-object JSON payload")

return payload
14 changes: 13 additions & 1 deletion src/convert_sdk/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,29 @@


DEFAULT_CONFIG_ENDPOINT = "https://cdn-4.convertexperiments.com/api/v1"
DEFAULT_TRACKING_ENDPOINT = "https://metrics.convertexperiments.com/v1"


@dataclass(frozen=True)
class TransportConfig:
"""Network configuration for config-fetch transport behavior."""
"""Network configuration for config-fetch and tracking behavior."""

config_endpoint: str = DEFAULT_CONFIG_ENDPOINT
tracking_endpoint: str = DEFAULT_TRACKING_ENDPOINT
headers: Mapping[str, str] = field(default_factory=dict)
timeout_seconds: float = 5.0
verify_tls: bool = True


@dataclass(frozen=True)
class TrackingConfig:
"""Queue configuration for conversion delivery behavior."""

batch_size: int = 10
source: str = "python-sdk"
enrich_data: bool = True


@dataclass(frozen=True)
class SDKConfig:
"""Pythonic SDK initialization config."""
Expand All @@ -28,3 +39,4 @@ class SDKConfig:
sdk_key_secret: Optional[str] = None
config_data: Optional[Mapping[str, Any]] = None
transport: TransportConfig = field(default_factory=TransportConfig)
tracking: TrackingConfig = field(default_factory=TrackingConfig)
9 changes: 9 additions & 0 deletions src/convert_sdk/config_loader/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ def validate_sdk_config(config: SDKConfig) -> None:
"config_endpoint must use HTTPS for sdk_key initialization"
)

if not config.transport.tracking_endpoint.startswith("https://"):
raise ConfigValidationError("tracking_endpoint must use HTTPS")

if config.tracking.batch_size < 1:
raise ConfigValidationError("tracking.batch_size must be greater than zero")

if not config.tracking.source.strip():
raise ConfigValidationError("tracking.source must be a non-empty string")

if has_config_data:
validate_config_data(config.config_data)

Expand Down
66 changes: 61 additions & 5 deletions src/convert_sdk/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,20 @@

from .domain.config_snapshot import ConfigSnapshot
from .domain.context_state import ContextState
from .domain.results import ConversionResult, ExperienceResult, FeatureResult
from .domain.results import (
ConversionResult,
ExperienceResult,
FeatureResult,
TrackingFlushResult,
)
from .evaluation.experiences import evaluate_experience, evaluate_experiences
from .evaluation.features import evaluate_feature, evaluate_features
from .tracking.conversions import track_conversion as create_conversion_result
from .tracking.conversions import (
build_conversion_result,
normalize_conversion_data,
resolve_goal,
)
from .tracking.queue import TrackingQueue


class Context:
Expand All @@ -20,10 +30,12 @@ def __init__(
snapshot: ConfigSnapshot,
state: ContextState,
*,
tracking_queue: TrackingQueue,
default_environment: Optional[str] = None,
) -> None:
self._snapshot = snapshot
self._state = state
self._tracking_queue = tracking_queue
self._default_environment = default_environment

@property
Expand Down Expand Up @@ -128,17 +140,61 @@ def run_features(
type_cast=type_cast,
)

def track_conversion(self, goal_key: str) -> ConversionResult:
"""Create a typed conversion event for the current visitor context."""
def track_conversion(
self,
goal_key: str,
*,
conversion_data: Optional[Mapping[str, Any]] = None,
force_multiple_transactions: bool = False,
visitor_attributes: Optional[Mapping[str, Any]] = None,
location_attributes: Optional[Mapping[str, Any]] = None,
environment: Optional[str] = None,
) -> ConversionResult:
"""Queue typed conversion events for the current visitor context."""

if not isinstance(goal_key, str) or not goal_key.strip():
raise ValueError("goal_key is required")
if not isinstance(force_multiple_transactions, bool):
raise TypeError("force_multiple_transactions must be a boolean")

normalized_conversion_data = normalize_conversion_data(conversion_data)
goal = resolve_goal(self._snapshot, goal_key)
dedupe_key = (self.visitor_id, str(goal.get("id", goal_key)))
decision = self._tracking_queue.plan_conversion(
visitor_id=self.visitor_id,
goal_id=dedupe_key[1],
has_conversion_data=bool(normalized_conversion_data),
allow_repeat_reporting=force_multiple_transactions,
)
if decision.duplicate_prevented:
return ConversionResult(duplicate_prevented=True)

return create_conversion_result(
result = build_conversion_result(
self._snapshot,
visitor_id=self.visitor_id,
goal=goal,
goal_key=goal_key,
normalized_conversion_data=normalized_conversion_data,
visitor_attributes=self._resolve_visitor_attributes(visitor_attributes),
location_attributes=self._resolve_location_attributes(location_attributes),
environment=environment or self._default_environment,
include_base_conversion=decision.should_enqueue_conversion,
include_transaction_event=decision.should_enqueue_transaction,
)
queued_event_count = self._tracking_queue.enqueue(
result.events,
mark_tracked_goal=dedupe_key if decision.should_enqueue_conversion else None,
)
return ConversionResult(
events=result.events,
duplicate_prevented=False,
queued_event_count=queued_event_count,
)

def release_queues(self, reason: Optional[str] = None) -> TrackingFlushResult:
"""Explicitly flush queued tracking events through the transport."""

return self._tracking_queue.release(reason)

def _resolve_location_attributes(
self,
Expand Down
18 changes: 15 additions & 3 deletions src/convert_sdk/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .domain.context_state import ContextState
from .errors import InitializationError
from .ports.transport import Transport
from .tracking.queue import TrackingQueue

class Core:
"""Stable root export for SDK initialization and config access."""
Expand All @@ -23,13 +24,23 @@ def __init__(
) -> None:
self._config = config
self._snapshot: Optional[ConfigSnapshot] = None
self._transport = transport
self._transport = transport or HttpxTransport()
self._tracking_queue: Optional[TrackingQueue] = None
self._initialize()

def _initialize(self) -> None:
self._snapshot = load_config_snapshot(
self._config,
transport=self._transport or HttpxTransport(),
transport=self._transport,
)
self._tracking_queue = TrackingQueue(
transport=self._transport,
transport_config=self._config.transport,
tracking_config=self._config.tracking,
sdk_key=self._config.sdk_key,
sdk_key_secret=self._config.sdk_key_secret,
account_id=self._snapshot.account_id,
project_id=self._snapshot.project_id,
)

@property
Expand Down Expand Up @@ -67,7 +78,7 @@ def create_context(

if not isinstance(visitor_id, str) or not visitor_id.strip():
raise InitializationError("visitor_id is required to create a Context")
if self._snapshot is None:
if self._snapshot is None or self._tracking_queue is None:
raise InitializationError("Core is not ready")

try:
Expand All @@ -81,6 +92,7 @@ def create_context(
return Context(
snapshot=self._snapshot,
state=state,
tracking_queue=self._tracking_queue,
default_environment=self._config.environment,
)

Expand Down
2 changes: 2 additions & 0 deletions src/convert_sdk/domain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
ExperienceResult,
FeatureResult,
FeatureStatus,
TrackingFlushResult,
)

__all__ = [
Expand All @@ -16,4 +17,5 @@
"ExperienceResult",
"FeatureResult",
"FeatureStatus",
"TrackingFlushResult",
]
Loading