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
4 changes: 4 additions & 0 deletions src/convert_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
TrackingDeliveryError,
TransportError,
)
from convert_sdk.events import LifecycleEvent
from convert_sdk.version import __version__

__all__ = [
Expand All @@ -60,4 +61,7 @@
# Conversion tracking foundation (Story 2.1).
"ConversionResult",
"ConversionStatus",
# Lifecycle events public surface (Story 2.4): consumers need this enum to
# call Core.on(LifecycleEvent.API_QUEUE_RELEASED, ...).
"LifecycleEvent",
]
8 changes: 8 additions & 0 deletions src/convert_sdk/adapters/events/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Event-delivery adapters for the Convert Python SDK (Story 2.4).

Concrete implementations of the ``ports/event_bus.py`` ``EventBus`` port. The
MVP ships a single in-process synchronous adapter
(:class:`~convert_sdk.adapters.events.in_process.InProcessEventBus`) mirroring
the JS ``EventManager`` ``on``/``fire`` model; an async/queued adapter can be
added later without touching the emission call sites.
"""
83 changes: 83 additions & 0 deletions src/convert_sdk/adapters/events/in_process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""In-process synchronous EventBus adapter for the Convert Python SDK (Story 2.4, L3).

Implements the ``ports/event_bus.py`` ``EventBus`` port with a per-event handler
registry, mirroring the JS ``EventManager`` ``on``/``fire`` model
(``javascript-sdk/packages/event/src/event-manager.ts``):

* :meth:`on` pushes a handler onto the per-event listener list.
* :meth:`emit` calls every listener as ``handler(payload, error)`` inside a
per-listener ``try/except`` that logs (privacy-safe) and **swallows** any
handler exception — one bad integrator handler can never break delivery or the
other handlers (AC #2, Critical Warning #6; direct parity with the JS
``fire()`` per-listener try/catch).
* Emitting an event with no registered handlers is a **zero-cost no-op**: an
empty (or absent) handler list returns immediately with no I/O and no
measurable overhead, preserving the NFR5 enqueue budget when no subscriber
exists (AC #5, Task 2.4).

Design note (Task 2.5): ``CONVERSION`` and ``API_QUEUE_RELEASED`` are emitted
**live** (non-deferred) — they reflect real, repeated state transitions, so a
handler only receives events fired after it subscribes. Deferred one-shot firing
(JS parity for ``READY``) is intentionally NOT implemented here: Story 2.4 does
not emit any one-shot event (READY/CONFIG_UPDATED emission belongs to the
init/config layer — Critical Warning #12), so deferred firing would add cost
without exercising any current call site.

Layering: L3 (concrete adapter). It imports the L0 ``events.py`` types, the L1
``ports/event_bus.py`` protocol, and the SDK ``logging`` helper only — never
``tracking/`` (Critical Warning #9).
"""

from __future__ import annotations

from typing import Any, Dict, List, Optional

from convert_sdk.events import LifecycleEvent
from convert_sdk.logging import log_event_handler_error
from convert_sdk.ports.event_bus import EventHandler


class InProcessEventBus:
"""A synchronous, in-process implementation of the ``EventBus`` port.

Handler registration and invocation are synchronous and ordered (handlers
fire in registration order). The bus is intentionally minimal: it notifies
handlers and does nothing else — it is NOT a second orchestration layer
(Critical Warning #10).
"""

def __init__(self) -> None:
# event -> ordered list of handlers (created lazily on first subscribe).
self._handlers: Dict[LifecycleEvent, List[EventHandler]] = {}

def on(self, event: LifecycleEvent, handler: EventHandler) -> None:
"""Register ``handler`` for ``event`` (appended in subscription order)."""
self._handlers.setdefault(event, []).append(handler)

def emit(
self,
event: LifecycleEvent,
payload: Any,
error: Optional[BaseException] = None,
) -> None:
"""Invoke every handler for ``event`` with ``(payload, error)``.

No-subscriber emit is a zero-cost no-op. Each handler runs inside a
``try/except`` so a raising handler is logged (privacy-safe — only the
event name + traceback, never the payload) and swallowed; subsequent
handlers still run and the emission never propagates an exception.
"""
handlers = self._handlers.get(event)
if not handlers:
# Zero-cost no-op: no subscribers -> no work, no I/O (NFR5).
return
# Iterate a snapshot so a handler that subscribes during emission does
# not change the in-flight iteration.
for handler in tuple(handlers):
try:
handler(payload, error)
except Exception: # noqa: BLE001 - intentional handler isolation
# Parity with JS EventManager.fire per-listener try/catch:
# isolate, log privacy-safely, swallow. One bad handler must not
# break delivery or the other handlers.
log_event_handler_error(event=event.value)
24 changes: 24 additions & 0 deletions src/convert_sdk/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
from convert_sdk.context import Context
from convert_sdk.domain.config_snapshot import ConfigSnapshot

from convert_sdk.adapters.events.in_process import InProcessEventBus
from convert_sdk.events import LifecycleEvent

if TYPE_CHECKING: # pragma: no cover - typing only
from convert_sdk.ports.event_bus import EventBus, EventHandler
from convert_sdk.ports.transport import Transport


Expand Down Expand Up @@ -48,6 +52,25 @@ def __init__(self, config: SDKConfig, *, transport: Optional["Transport"] = None
self._tracker: Optional[Any] = None
# Opt-in daemonic periodic-flush driver (None unless configured).
self._periodic_flusher: Optional[Any] = None
# Story 2.4: ONE EventBus per Core, created eagerly so Core.on(...) is
# usable before initialize() and the SAME bus is injected into the
# tracker at initialize() (no per-context or per-call bus).
self._event_bus: "EventBus" = InProcessEventBus()

# --- lifecycle events --------------------------------------------------

def on(self, event: LifecycleEvent, handler: "EventHandler") -> None:
"""Register a lifecycle-event ``handler`` for ``event`` (Story 2.4, FR40).

The only public observability surface added by Story 2.4. Delegates to
the single per-Core :class:`~convert_sdk.ports.event_bus.EventBus`; Core
itself stays thin (no event-routing logic here). A handler that raises is
isolated, logged, and swallowed by the bus — it can never break tracking
or delivery (AC #2). Handlers are invoked as ``handler(payload, error)``.

Safe to call before :meth:`initialize`.
"""
self._event_bus.on(event, handler)

# --- readiness & config access ----------------------------------------

Expand Down Expand Up @@ -157,6 +180,7 @@ def _build_tracker(self) -> None:
config=self._config,
transport=self._transport,
transport_provider=self._ensure_transport,
event_bus=self._event_bus,
)
# Opt-in periodic flush (daemonic timer) when configured; default
# (interval None) keeps the lifecycle explicit-flush-only.
Expand Down
92 changes: 92 additions & 0 deletions src/convert_sdk/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Lifecycle event model for the Convert Python SDK (Story 2.4, L0 domain).

This is a **leaf type module** (architecture layer L0): it defines the
``LifecycleEvent`` enum and the typed event payload structures the higher
layers emit, and it imports **stdlib only**. It must NOT import ``tracking/``,
``ports/``, or ``adapters/`` — the layering is CI-enforced by ``import-linter``
(Critical Warning #9). Higher layers (``tracking/`` L2, ``core.py`` L4) consume
these types; this module never reaches back into them.

Enum identifiers are frozen by the PRD (``prd.md#API-Surface``) and aligned with
the JS ``SystemEvents`` parity subset:

* ``READY`` / ``CONFIG_UPDATED`` / ``BUCKETING`` are defined for completeness and
JS parity but are emitted by the initialization/config and evaluation layers —
Story 2.4 does NOT emit them (Critical Warning #12).
* ``CONVERSION`` is emitted from the tracking enqueue path on a tracked
(non-suppressed) conversion.
* ``API_QUEUE_RELEASED`` is emitted from the single shared release path on every
actual queue release (success or failure).
* ``DATA_STORE_QUEUE_RELEASED`` is defined for JS parity/completeness but is out
of MVP scope here — another layer owns it.

Member values use the dot-separated wire-parity strings (``"config.updated"``,
``"api.queue.released"``, ``"datastore.queue.released"``) matching the current
PRD and the JS ``SystemEvents`` enum.
"""

from __future__ import annotations

import enum
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional

if TYPE_CHECKING: # pragma: no cover - typing only; no runtime cross-layer import
# ``ReleaseReason`` lives in tracking/queue.py (L2). Importing it for real
# would violate the L0 layering rule, so it is referenced under
# TYPE_CHECKING only. ``ReleaseReason`` is a ``str`` enum, so the actual
# value carried on the payload at emission time is also a valid ``str``.
from convert_sdk.tracking.queue import ReleaseReason


class LifecycleEvent(enum.Enum):
"""Known SDK lifecycle events (never raw string literals — FR40).

Member names use ``UPPER_SNAKE_CASE``; values are the stable, documented,
JS-parity wire strings frozen by the PRD.
"""

READY = "ready"
CONFIG_UPDATED = "config.updated"
BUCKETING = "bucketing"
CONVERSION = "conversion"
API_QUEUE_RELEASED = "api.queue.released"
DATA_STORE_QUEUE_RELEASED = "datastore.queue.released"


@dataclass(frozen=True)
class ConversionEventPayload:
"""Domain-relevant context for a ``CONVERSION`` lifecycle event.

Carries ONLY internal snake_case domain identity fields — never raw visitor
attributes, the wire payload, or any transport object (Critical Warning #7,
Task 4.3). Built directly from the in-process conversion event, not from the
Story 2.2 wire serializer, so emission stays off the serialization path.
"""

visitor_id: str
goal_id: str
goal_key: str


@dataclass(frozen=True)
class QueueReleasedPayload:
"""Diagnostic context for an ``API_QUEUE_RELEASED`` lifecycle event.

Emitted once per actual queue release. On success it carries the typed
release ``reason`` (Story 2.3's frozen ``ReleaseReason`` enum — reused, never
redefined: F-062), the ``batch_size`` delivered, and per-visitor/event
counts. On failure it additionally carries privacy-safe error context:
``status_code`` (HTTP status, if any) and ``retry_attempts`` (the transport
adapter's exhausted retry count, or ``0``/``None`` when the adapter performs
no retry). It NEVER carries the SDK key, auth headers, raw transport response
bodies, or raw visitor attributes (NFR23/NFR7, Critical Warning #7).
"""

reason: "ReleaseReason"
batch_size: int
visitor_count: int
event_count: int
# Failure-only diagnostic context (privacy-safe; absent on success).
status_code: Optional[int] = None
retry_attempts: Optional[int] = None
65 changes: 65 additions & 0 deletions src/convert_sdk/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Privacy-safe logging helpers for the Convert Python SDK (Story 2.4).

Provides the SDK's configured stdlib :mod:`logging` logger and small, stable,
event-oriented log call sites for delivery outcomes (architecture
#Logging-Patterns). The phrasing is deliberately stable ("queue release",
"tracking delivery failure") and the operational context is restricted to
non-sensitive fields (release reason, batch size, HTTP status code, retry
count).

Privacy rule (NFR7/NFR23, Critical Warning #7): NO log line at any level may
contain the SDK key, full auth headers, or raw visitor attributes. These helpers
only ever accept and emit the safe fields above; they never receive a secret to
begin with. The centralized redaction primitives land in Story 4.1/4.2 — this
module must not regress that contract.
"""

from __future__ import annotations

import logging
from typing import Optional

#: The SDK's package logger. Applications configure handlers/levels on the
#: ``convert_sdk`` logger (or its parent) per standard stdlib logging.
logger = logging.getLogger("convert_sdk")


def log_queue_release_success(*, reason: str, batch_size: int) -> None:
"""Log a successful queue release at DEBUG (stable event-oriented phrasing).

Carries only the release ``reason`` and ``batch_size`` — no PII, no secrets.
"""
logger.debug("queue release succeeded (reason=%s, batch_size=%d)", reason, batch_size)


def log_tracking_delivery_failure(
*,
reason: str,
batch_size: int,
status_code: Optional[int] = None,
retry_attempts: Optional[int] = None,
) -> None:
"""Log a tracking-delivery failure at ERROR with privacy-safe context.

Includes only ``reason``, ``batch_size``, the HTTP ``status_code`` (if any),
and the ``retry_attempts`` count (NFR23). It NEVER includes the SDK key, auth
headers, or raw visitor attributes (NFR7).
"""
logger.error(
"tracking delivery failure (reason=%s, batch_size=%d, status=%s, retry_attempts=%s)",
reason,
batch_size,
status_code if status_code is not None else "n/a",
retry_attempts if retry_attempts is not None else "n/a",
)


def log_event_handler_error(*, event: str) -> None:
"""Log (at ERROR) that a lifecycle-event handler raised and was swallowed.

Logs only the event name and the traceback (via ``exc_info``); it does not
log the handler's arguments, so no payload PII or secrets leak. Used by the
in-process EventBus to isolate a buggy integrator handler (Critical Warning
#6 — parity with the JS ``EventManager.fire`` per-listener try/catch).
"""
logger.error("lifecycle event handler raised and was swallowed (event=%s)", event, exc_info=True)
55 changes: 55 additions & 0 deletions src/convert_sdk/ports/event_bus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""EventBus port for the Convert Python SDK (Story 2.4, L1).

Defines the capability the SDK depends on to publish lifecycle events and let
applications subscribe to them, decoupled from any concrete event-delivery
implementation. Keeping it behind a :class:`typing.Protocol` lets the in-process
synchronous adapter (``adapters/events/in_process.py``) be swapped for an
async/queued implementation later without touching the emission call sites
(architecture #Async-Readiness).

Naming follows the architecture's capability-noun rule: ``EventBus`` (no
``I``-prefix), like ``Transport`` and ``DataStore`` (#Naming-Patterns).

Layering: L1. This module imports the L0 ``events.py`` types only; it must NOT
import ``tracking/`` or concrete ``adapters/`` (CI-enforced by import-linter,
Critical Warning #9).
"""

from __future__ import annotations

from typing import Any, Callable, Optional, Protocol, runtime_checkable

from convert_sdk.events import LifecycleEvent

#: A lifecycle-event handler. Invoked as ``handler(payload, error)`` — mirroring
#: the JS ``EventManager`` ``fn(args, err)`` contract — so a handler can observe
#: both the success payload and an optional delivery error.
EventHandler = Callable[..., None]

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

Using Callable[..., None] as the type alias for EventHandler disables static type checking for the handler's signature. Since the SDK strictly invokes handlers with two arguments (payload and error), registering a handler with a different signature (e.g., a single-argument lambda like lambda payload: ...) will pass static analysis but raise a TypeError at runtime, which is then silently swallowed and logged as an error by the event bus.

Changing the type definition to Callable[[Any, Optional[BaseException]], None] allows static type checkers (like mypy or pyright) and IDEs to enforce and surface the correct signature to integrators.

Suggested change
EventHandler = Callable[..., None]
EventHandler = Callable[[Any, Optional[BaseException]], None]



@runtime_checkable
class EventBus(Protocol):
"""Publishes lifecycle events to registered handlers.

The bus only notifies handlers; it must NOT become a second orchestration
layer that duplicates queue/flush/dedup control flow (Critical Warning #10,
architecture #Service-Boundaries).
"""

def on(self, event: LifecycleEvent, handler: EventHandler) -> None:
"""Register ``handler`` to be invoked when ``event`` is emitted."""
...

def emit(
self,
event: LifecycleEvent,
payload: Any,
error: Optional[BaseException] = None,
) -> None:
"""Invoke every handler registered for ``event`` with ``(payload, error)``.

A handler that raises must be isolated, logged, and swallowed so one bad
handler cannot break delivery or other handlers. Emitting an event with
no registered handlers is a zero-cost no-op.
"""
...
7 changes: 6 additions & 1 deletion src/convert_sdk/tracking/flush.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ def setup_periodic_flush(
"""
if interval_ms is None:
return None
flusher = PeriodicFlusher(flushable.flush, interval_ms)
# Prefer a trigger-specific timeout release entry point when the flushable
# exposes one (Story 2.4: so the periodic release reports
# ``ReleaseReason.TIMEOUT`` on its ``API_QUEUE_RELEASED`` event). Fall back
# to the generic ``flush()`` for any plain :class:`Flushable`.
callback = getattr(flushable, "flush_timeout", flushable.flush)
flusher = PeriodicFlusher(callback, interval_ms)
flusher.start()
return flusher

Expand Down
Loading