-
Notifications
You must be signed in to change notification settings - Fork 0
epic-2/story-4: Expose Tracking Lifecycle Events and Delivery Outcomes #32
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
Closed
usmanabbas7
wants to merge
2
commits into
epic-2/story-3-batch-deduplicate-and-flush-tracking-events
from
epic-2/story-4-expose-tracking-lifecycle-events-and-delivery-outcomes
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| """ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] | ||
|
|
||
|
|
||
| @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. | ||
| """ | ||
| ... | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Using
Callable[..., None]as the type alias forEventHandlerdisables static type checking for the handler's signature. Since the SDK strictly invokes handlers with two arguments (payloadanderror), registering a handler with a different signature (e.g., a single-argument lambda likelambda payload: ...) will pass static analysis but raise aTypeErrorat 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 (likemypyorpyright) and IDEs to enforce and surface the correct signature to integrators.