Skip to content

epic-2/story-3: Batch, Deduplicate, and Flush Tracking Events - #31

Closed
usmanabbas7 wants to merge 13 commits into
epic-2/story-2-support-revenue-data-and-tracking-payload-constructionfrom
epic-2/story-3-batch-deduplicate-and-flush-tracking-events
Closed

epic-2/story-3: Batch, Deduplicate, and Flush Tracking Events#31
usmanabbas7 wants to merge 13 commits into
epic-2/story-2-support-revenue-data-and-tracking-payload-constructionfrom
epic-2/story-3-batch-deduplicate-and-flush-tracking-events

Conversation

@usmanabbas7

Copy link
Copy Markdown
Collaborator

Summary

Adds the tracking queue + dedup + flush/delivery layer on top of Story 2.2's serializer. Stops before lifecycle-event emission and delivery-outcome reporting (Story 2.4).

  • PL-1SDKConfig gains auto_flush_interval_ms/batch_size/data_store; minimal ports/storage.py DataStore protocol + in-memory default (forward-compatible with Story 3.1); Transport port extended with the tracking-POST method + httpx adapter (/track/{sdkKey}, TLS-only)
  • BE-1tracking/queue.py: thread-safe per-visitor queue, batch-size release, typed ReleaseReason enum (size/explicit/timeout/atexit) (F-031)
  • BE-2tracking/deduplication.py: (visitor_id, goal_id) dedup via the DataStore boundary; truth-table exact; marker persisted unconditionally incl. force_multiple=True calls (F-007)
  • BE-3tracking/tracker.py orchestration seam; force_multiple activated on Context.track_conversion; JS-parity nuance: forced repeat sends only the transaction (goalData) event, never re-fires the bare conversion (F-006); Core.flush() drains via the single shared release path through Story 2.2's payloads.py (no second serializer); empty-queue flush is a no-op
  • BE-4tracking/flush.py with qs-07 frozen names (flush(), setup_periodic_flush(), register_atexit_flush()); daemonic threading.Timer with explicit timer.daemon = True after construction (F-058/F-059); SIGTERM pattern documented as comment only; docs/runtime-integration.md per-runtime guide + README section
  • TEST-1 — qs-06 RESPX integration harness (tests/integration/conftest.py), delivery tests, qs-07 queue-lifecycle matrix (explicit/timer/atexit/no-flush/SIGTERM-pattern)

Audit findings honored: F-006, F-007, F-031, F-058, F-059.

Tests: 268 → 340 (+72), full suite green (uv run pytest).

Traceability

Notes for reviewer

  • Readiness gate: PASS 8.7/10 with 5 auto-delegated questions (sprint mode): minimal DataStore protocol created here (Story 3.1 owns the full boundary); Transport tracking-POST extension; batch_size=10 default; Core-owned shared tracker; test-file locations. See conductor's readiness-assessment.md.
  • Code review: clean after round 1 (one low-severity coupling finding — tracker used private _build_goal_data; fixed via public event_has_goal_data() predicate).
  • ConversionResult gained DEDUPLICATED status + derived tracked/reason properties — Story 2.1 field contract unchanged.
  • Sprint-driver hygiene commit on top: re-untracked the pre-existing empty CLAUDE.md accidentally re-added by the review R1 commit.

🤖 Generated with Claude Code

@usmanabbas7 usmanabbas7 self-assigned this Jun 7, 2026
@usmanabbas7
usmanabbas7 requested a review from clllaur June 7, 2026 13:28

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request implements conversion tracking, batching, and queue flushing for the Convert Python SDK. It introduces a thread-safe in-process TrackingQueue, a Tracker orchestrator, and runtime integration lifecycle hooks (explicit, periodic, and atexit flushes). The feedback focuses on several key improvements: preventing invalid tracking POST requests to /track/None when the SDK key is missing in offline mode, allowing the flush methods and background/shutdown hooks to accept and forward the correct ReleaseReason (such as TIMEOUT or ATEXIT), and validating that the configured data_store implements the DataStore protocol during configuration initialization to fail fast.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +191 to +192
try:
transport.send_tracking(payload, sdk_key=str(self._config.sdk_key))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

When the SDK is initialized with direct config (offline mode), self._config.sdk_key is None. Calling str(self._config.sdk_key) converts None to the string "None", which causes the transport to make invalid tracking POST requests to /track/None. We should check if sdk_key is present, and if not, restore the items to the queue and raise a TrackingDeliveryError.

Suggested change
try:
transport.send_tracking(payload, sdk_key=str(self._config.sdk_key))
sdk_key = self._config.sdk_key
if not sdk_key:
self._queue.restore(items)
from convert_sdk.errors import TrackingDeliveryError
raise TrackingDeliveryError("Tracking delivery failed: SDK key is missing.")
try:
transport.send_tracking(payload, sdk_key=sdk_key)

Comment on lines +167 to +174
def flush(self) -> None:
"""Explicitly release the queue (``ReleaseReason.EXPLICIT``).

Drains, serializes via the Story 2.2 builder, and delivers through the
transport, clearing the queue on success. An empty queue is a safe
no-op (no transport call, no error).
"""
self._release(ReleaseReason.EXPLICIT)

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 Tracker.flush method currently does not accept any arguments and always releases the queue with ReleaseReason.EXPLICIT. This means that ReleaseReason.TIMEOUT and ReleaseReason.ATEXIT are never actually used when periodic or shutdown flushes occur. We should allow flush to accept an optional reason parameter.

Suggested change
def flush(self) -> None:
"""Explicitly release the queue (``ReleaseReason.EXPLICIT``).
Drains, serializes via the Story 2.2 builder, and delivers through the
transport, clearing the queue on success. An empty queue is a safe
no-op (no transport call, no error).
"""
self._release(ReleaseReason.EXPLICIT)
def flush(self, reason: ReleaseReason = ReleaseReason.EXPLICIT) -> None:
"""Explicitly release the queue (``ReleaseReason.EXPLICIT``).
Drains, serializes via the Story 2.2 builder, and delivers through the
transport, clearing the queue on success. An empty queue is a safe
no-op (no transport call, no error).
"""
self._release(reason)

Comment thread src/convert_sdk/core.py
Comment on lines +108 to +121
def flush(self) -> None:
"""Explicitly release the tracking queue and deliver queued events.

Drains the shared queue through the single release path, serializes the
batched per-visitor events via the Story 2.2 serializer, and delivers
through the configured transport, clearing the queue on success. A flush
on an empty queue is a safe no-op (no transport call, no error). This is
the canonical, deterministic control point for queue release (FR39); the
default lifecycle is explicit-flush-only.

Safe to call before :meth:`initialize` (no tracker yet) — it is a no-op.
"""
if self._tracker is not None:
self._tracker.flush()

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 forwarding the correct ReleaseReason (such as TIMEOUT or ATEXIT) from the periodic timer or interpreter shutdown hooks, Core.flush should accept an optional reason parameter and forward it to the underlying tracker.

Suggested change
def flush(self) -> None:
"""Explicitly release the tracking queue and deliver queued events.
Drains the shared queue through the single release path, serializes the
batched per-visitor events via the Story 2.2 serializer, and delivers
through the configured transport, clearing the queue on success. A flush
on an empty queue is a safe no-op (no transport call, no error). This is
the canonical, deterministic control point for queue release (FR39); the
default lifecycle is explicit-flush-only.
Safe to call before :meth:`initialize` (no tracker yet) — it is a no-op.
"""
if self._tracker is not None:
self._tracker.flush()
def flush(self, reason: Optional[Any] = None) -> None:
"""Explicitly release the tracking queue and deliver queued events.
Drains the shared queue through the single release path, serializes the
batched per-visitor events via the Story 2.2 serializer, and delivers
through the configured transport, clearing the queue on success. A flush
on an empty queue is a safe no-op (no transport call, no error). This is
the canonical, deterministic control point for queue release (FR39); the
default lifecycle is explicit-flush-only.
Safe to call before :meth:`initialize` (no tracker yet) — it is a no-op.
"""
if self._tracker is not None:
if reason is not None:
self._tracker.flush(reason)
else:
self._tracker.flush()

Comment on lines +60 to +81
def setup_periodic_flush(
flushable: Flushable,
interval_ms: Optional[int],
) -> Optional["PeriodicFlusher"]:
"""Opt into periodic background flushes via a daemonic ``threading.Timer``.

When ``interval_ms`` is ``None`` (the default lifecycle), periodic flush is
disabled and this returns ``None`` — the queue is released only on explicit
:func:`flush` (or batch-size release). When a positive interval is given,
starts a daemonic, self-rescheduling timer that calls the flushable's
``flush()`` every ``interval_ms`` milliseconds and returns a
:class:`PeriodicFlusher` the caller can :meth:`PeriodicFlusher.cancel`.

The timer thread is daemonic so it never blocks interpreter shutdown; if the
process exits before the timer fires the flush is silently skipped (the
documented, correct behavior for short-lived runtimes).
"""
if interval_ms is None:
return None
flusher = PeriodicFlusher(flushable.flush, interval_ms)
flusher.start()
return flusher

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

Update setup_periodic_flush to pass ReleaseReason.TIMEOUT when flushing, falling back to zero-arg flush() if the flushable does not support arguments.

def setup_periodic_flush(
    flushable: Flushable,
    interval_ms: Optional[int],
) -> Optional["PeriodicFlusher"]:
    """Opt into periodic background flushes via a daemonic ``threading.Timer``.

    When ``interval_ms`` is ``None`` (the default lifecycle), periodic flush is
    disabled and this returns ``None`` — the queue is released only on explicit
    :func:`flush` (or batch-size release). When a positive interval is given,
    starts a daemonic, self-rescheduling timer that calls the flushable's
    ``flush()`` every ``interval_ms`` milliseconds and returns a
    :class:`PeriodicFlusher` the caller can :meth:`PeriodicFlusher.cancel`.

    The timer thread is daemonic so it never blocks interpreter shutdown; if the
    process exits before the timer fires the flush is silently skipped (the
    documented, correct behavior for short-lived runtimes).
    """
    if interval_ms is None:
        return None
    from convert_sdk.tracking.queue import ReleaseReason
    def _periodic_flush() -> None:
        try:
            flushable.flush(ReleaseReason.TIMEOUT)  # type: ignore[call-arg]
        except TypeError:
            flushable.flush()
    flusher = PeriodicFlusher(_periodic_flush, interval_ms)
    flusher.start()
    return flusher

Comment on lines +84 to +102
def register_atexit_flush(flushable: Flushable) -> Callable[[], None]:
"""Register a best-effort final flush on interpreter shutdown (``atexit``).

Returns the registered callback so a caller can ``atexit.unregister`` it.
Best-effort only: ``atexit`` does NOT fire under ``SIGKILL`` or in some
serverless runtimes, and any exception during the final flush is swallowed
so shutdown never crashes (a never-flushed process must exit cleanly even if
it silently drops events — NFR18).
"""

def _final_flush() -> None:
try:
flushable.flush()
except Exception: # pragma: no cover - shutdown best-effort
# Never let a shutdown-time delivery failure crash the interpreter.
pass

atexit.register(_final_flush)
return _final_flush

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

Update register_atexit_flush to pass ReleaseReason.ATEXIT when flushing, falling back to zero-arg flush() if the flushable does not support arguments.

def register_atexit_flush(flushable: Flushable) -> Callable[[], None]:
    """Register a best-effort final flush on interpreter shutdown (``atexit``).

    Returns the registered callback so a caller can ``atexit.unregister`` it.
    Best-effort only: ``atexit`` does NOT fire under ``SIGKILL`` or in some
    serverless runtimes, and any exception during the final flush is swallowed
    so shutdown never crashes (a never-flushed process must exit cleanly even if
    it silently drops events — NFR18).
    """
    from convert_sdk.tracking.queue import ReleaseReason
    def _final_flush() -> None:
        try:
            try:
                flushable.flush(ReleaseReason.ATEXIT)  # type: ignore[call-arg]
            except TypeError:
                flushable.flush()
        except Exception:  # pragma: no cover - shutdown best-effort
            # Never let a shutdown-time delivery failure crash the interpreter.
            pass

    atexit.register(_final_flush)
    return _final_flush

Comment thread src/convert_sdk/config.py
Comment on lines +140 to +147
if self.auto_flush_interval_ms is not None and (
not isinstance(self.auto_flush_interval_ms, int)
or self.auto_flush_interval_ms < 1
):
raise InvalidConfigError(
"SDKConfig 'auto_flush_interval_ms' must be a positive integer "
f"or None; got {self.auto_flush_interval_ms!r}"
)

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

Validate that the configured data_store implements the DataStore protocol during initialization to fail fast with InvalidConfigError rather than failing with an attribute error later during tracking.

Suggested change
if self.auto_flush_interval_ms is not None and (
not isinstance(self.auto_flush_interval_ms, int)
or self.auto_flush_interval_ms < 1
):
raise InvalidConfigError(
"SDKConfig 'auto_flush_interval_ms' must be a positive integer "
f"or None; got {self.auto_flush_interval_ms!r}"
)
if self.auto_flush_interval_ms is not None and (
not isinstance(self.auto_flush_interval_ms, int)
or self.auto_flush_interval_ms < 1
):
raise InvalidConfigError(
"SDKConfig 'auto_flush_interval_ms' must be a positive integer "
f"or None; got {self.auto_flush_interval_ms!r}"
)
if self.data_store is not None:
from convert_sdk.ports.storage import DataStore
if not isinstance(self.data_store, DataStore):
raise InvalidConfigError(
"SDKConfig 'data_store' must implement the DataStore protocol; "
f"got {self.data_store!r}"
)

usmanabbas7 and others added 13 commits June 14, 2026 21:58
Beads: ai-driven-product-dev-rvx5

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- SDKConfig: batch_size=10 (JS parity), auto_flush_interval_ms=None, data_store
- ports/storage.py: DataStore protocol + InMemoryDataStore + resolve_data_store
- ports/transport.py + httpx adapter: send_tracking POST /track/{sdkKey}
- errors.py: TrackingDeliveryError; redaction covers /track/ keys

Beads: ai-driven-product-dev-rvx5

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-23a9

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
tracking/queue.py: TrackingQueue (per-visitor grouping, threading.Lock,
batch-size release signal, drain/restore) + ReleaseReason enum (F-031).

Beads: ai-driven-product-dev-23a9

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-ahes

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
tracking/deduplication.py: evaluate_dedup + DedupDecision + goal_marker_key
via DataStore boundary. F-006 (transaction-only re-send) + F-007 (uncond marker).

Beads: ai-driven-product-dev-ahes

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Beads: ai-driven-product-dev-pyen

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…REEN)

- tracking/tracker.py: Tracker orchestrates dedup->enqueue->shared release;
  flush serializes via Story 2.2 payloads.py, delivers via Transport, clears
  queue; failed delivery restores queue (no retry). F-006 wiring.
- tracking/flush.py: frozen flush()/setup_periodic_flush()/register_atexit_flush()
  + daemonic PeriodicFlusher (F-058/F-059); SIGTERM pattern as comment only.
- ConversionStatus.DEDUPLICATED + ConversionResult.tracked/reason properties
  (PRD contract without changing Story 2.1 fields).
- Context.track_conversion gains force_multiple, routes through shared tracker.
- Core owns shared Tracker, adds flush(), wires opt-in periodic flusher, cancels
  on close.

Beads: ai-driven-product-dev-pyen

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- tests/test_flush_lifecycle.py(NEW): daemonic timer fires, thread.daemon True,
  errors swallowed, cancel stops, atexit best-effort.
- docs/runtime-integration.md(NEW): per-runtime flush matrix (Lambda, Cloud Run,
  gunicorn, uvicorn, Celery, CLI) + SIGTERM pattern.
- README.md: Conversion tracking + Runtime Integration sections; public API list.

Beads: ai-driven-product-dev-ep61

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…rix (GREEN)

- tests/integration/conftest.py(NEW): qs-06 frozen RESPX harness
  (mock_config_endpoint, mock_tracking_endpoint, sdk_with_mock_transport,
  in_memory_store); tests/fixtures/config/{minimal,full}_config.json(NEW).
- tests/integration/test_tracking_delivery.py(NEW): flush->serialize->POST,
  queue clear, batch-size release, multi-visitor batch, failed-delivery retry.
- tests/integration/test_queue_lifecycle.py(NEW): qs-07 scenario matrix
  (explicit, timer, atexit, no-flush, SIGTERM via subprocess) + NFR21 parity.
- fix tracker._build_batch_payload: merge events per visitor into one
  visitors[] entry (JS VisitorsQueue grouping) — surfaced by TEST-1.

Beads: ai-driven-product-dev-b1jw

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
CLAUDE.md was an untracked working-tree file pulled in by git add -A during
[PL-1]; it is not part of this feature and was never on the base branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Code review: tracker imported the private _build_goal_data from payloads.py.
Replace with a public event_has_goal_data() predicate so the tracker decides
the F-006 transaction-send branch without reaching into a serializer internal.
No behavior change; full suite 340 green.

Beads: ai-driven-product-dev-45m2

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…mmit

Keeps the pre-existing empty working-tree CLAUDE.md untracked, matching the
base branch and sprint convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@usmanabbas7
usmanabbas7 force-pushed the epic-2/story-2-support-revenue-data-and-tracking-payload-construction branch from 021044d to 7cf6b2f Compare June 14, 2026 16:58
@usmanabbas7
usmanabbas7 force-pushed the epic-2/story-3-batch-deduplicate-and-flush-tracking-events branch from fb7d7f5 to 627d3ca Compare June 14, 2026 16:58
@abbaseya

Copy link
Copy Markdown
Collaborator

Superseded — all commits already in main (bc76b64). Closing without merge as part of post-sprint cleanup.

@abbaseya abbaseya closed this Jun 18, 2026
@abbaseya
abbaseya deleted the epic-2/story-3-batch-deduplicate-and-flush-tracking-events branch June 18, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants