Skip to content

feat(workflows): wire workflow step lifecycle events in PaywallViewModel#6868

Merged
facumenzella merged 13 commits into
mainfrom
port/3487
Jun 9, 2026
Merged

feat(workflows): wire workflow step lifecycle events in PaywallViewModel#6868
facumenzella merged 13 commits into
mainfrom
port/3487

Conversation

@facumenzella

@facumenzella facumenzella commented May 29, 2026

Copy link
Copy Markdown
Member

Port of RevenueCat/purchases-android#3487

Builds on #6858 (the WorkflowEvent model + wire format, the iOS port of android #3486), which is now merged. This branch is also up to date with main.

Summary

Emits WorkflowEvent.stepStarted / stepCompleted during multi-step workflow paywalls. Key pieces:

  • Purchases.track(workflowEvent:) — tracking entry point.
  • WorkflowStepEventTracker — builds the events (isFirstStep/isLastStep, terminal-step detection, and start/forward/back entry reasons).
  • WorkflowStepEventCoordinator — owns the per-impression traceId and the fire-once / "only if a page rendered" gating, and drives the tracker. Unit-tested.
  • WorkflowPaywallView — forwards its lifecycle and navigation signals (initial appear, forward, back, dismiss) to the coordinator. Terminal stepCompleted is anchored to onDisappear.

iOS-specific decisions (differ from Android)

iOS splits navigation (WorkflowNavigator) and rendering (WorkflowPaywallView), and a workflow paywall can be presented in a swipe-dismissable .sheet. A callback-only choke point would miss swipe/programmatic dismissals, so terminal stepCompleted is anchored to onDisappear, the same lifecycle hook paywall_close already uses.

Locale. Android's #3487 wiring sets no locale, so its context.locale defaults to null. This wiring likewise sets no locale. iOS's WorkflowEvent.Data (added in #6858) defaults localeIdentifier to the device locale and serializes it, so iOS will emit the device locale rather than null. Reversing that would mean undoing #6858's intentional, separately-tested field, which is out of scope for this port.

Validation against the contract

The wire format here was double-checked against ground truth, not just the Android source:

  • Live Khepri contract matches exactly. The backend pins the shape with Literal types (khepri/services/workflows/domain/workflows_events.py): type: "workflows", event_name: "workflows_step_started" / "workflows_step_completed", version: 1. iOS sends exactly that, so ingestion is safe.
  • Backend is authoritative for some fields. Khepri recomputes is_first_step/is_last_step and derives step_type/screen_type from its own DB step model, so the SDK correctly omits step_type/screen_type (and workflow_type, which the backend doesn't read yet). Android omits the same three (asserted by its serialization tests).
  • The Monetization Event Naming Spec is a future, cross-repo migration. The spec proposes singular rc_workflow_*_event, but that is unshipped everywhere (SDKs and Khepri still use plural workflows). Renaming the iOS strings in isolation would break ingestion, so this PR intentionally matches today's contract.
AI session context

AI Context

Metadata

  • PR: 6868
  • Branch: port/3487
  • Author / human owner: facumenzella
  • Agent(s): Claude Code (Opus 4.8), with Explore subagents for cross-repo validation
  • Session source: current conversation
  • Generated: 2026-06-04
  • Context document version: 1

Goal

Emit stepStarted / stepCompleted workflow lifecycle events during multi-step workflow paywalls (port of android #3487). This session brought the branch up to date with main, resolved the merge conflict, and validated the event contract against the Monetization Event Naming Spec, the live Khepri backend, and the Android source.

Initial Prompt

Review PR #6868, then bring main into the branch and fix conflicts (verbatim opener: "Check #6868", followed by "Fix conflicts, and bring main to this branch").

Important Follow-up Prompts

  • Validate the PR against the Monetization Event Naming Spec (Notion).
  • Validate through Khepri and/or Android for ground truth beyond the spec.

Agent Contribution

  • Merged origin/main into port/3487 (branch was 15 behind); resolved the single conflict in WorkflowPaywallView.swift init (both-add: kept main's seenPages/transitionState plus this branch's stepEventCoordinator).
  • Verified swift build clean, committed the merge, pushed; PR went from CONFLICTING to MERGEABLE.
  • Validated the wire format against the Notion spec, the live Khepri models, and the Android serialization tests + PaywallViewModel emission logic.

Human Decisions

  • Decision: merge main in (merge commit) rather than rebase + force-push the open PR.

Key Implementation Decisions

  • Decision: anchor terminal stepCompleted to WorkflowPaywallView.onDisappear.
    • Rationale: iOS splits navigation/rendering and supports swipe-dismissable sheets; onDisappear is the one signal that catches every dismissal path. Faithful to Android, which fires terminal completion off currentStepId on dismiss/purchase/restore.
  • Decision: keep plural workflows event names / type, no _event suffix.
    • Rationale: matches the live Khepri Literal contract and Android. Spec's singular rename is unshipped everywhere.
    • Rejected: renaming to spec's rc_workflow_*_event now (would break ingestion until backend updates).
  • Decision: do not send workflow_type/step_type/screen_type.
    • Rationale: backend derives step/screen type itself; workflow_type isn't read yet and PublishedWorkflow has no type field.

Files / Symbols Touched

  • RevenueCatUI/Templates/V2/WorkflowPaywallView.swift
    • Why: merge conflict resolution in init (this session); feature lifecycle hooks (PR).
    • Symbols: init, trackInitialStep, trackTerminalCompletion, trackTransition
    • Review relevance: confirm hasRenderedPage now reads main's transitionState.currentPage.
  • Feature files (PR): WorkflowStepEventCoordinator.swift, WorkflowStepEventTracker.swift, PurchaseHandler.swift, PaywallEventTracker.swift, PaywallPurchasesType.swift, MockPurchases.swift, Purchases.swift, plus tests under Tests/RevenueCatUITests/.

Dependencies / Config / Migrations

  • None. Cross-repo follow-ups (not this PR): Khepri/SDK singular-naming migration, workflow_type data-model field, checkout event family.

Validation

  • Commands run:
    • swift build: clean (build complete).
    • git merge origin/main: one conflict, resolved; merge commit 632b853.
  • Cross-repo source inspection:
    • Khepri Literal types match iOS wire format exactly. Proof: khepri/services/workflows/domain/workflows_events.py:32-53, khepri/events/event_types.py:46-48.
    • Android parity on type/names, omitted fields (serialization tests), terminal completion, entry reasons, trace id, fire-once gating.
  • CI:
    • Most checks green; full-test workflow is on hold pending approval; Emerge size-analysis reported fail (size check, not correctness).

Validation Gaps

  • Unit tests were not run locally this session (relied on CI + swift build). Cheap local check: WorkflowStepEventCoordinatorTests + WorkflowPaywallViewTests via the Tuist UnitTests scheme.
  • Not device-verified (event emission during a live workflow paywall).

Review Focus

  • Terminal stepCompleted fires on any dismissal of the last step, including plain abandonment. Confirmed intentional Android parity; the spec's "advanced past a step" wording is a web-Funnels framing. Worth a conscious sign-off.
  • Locale divergence from Android (device locale vs null) — intentional, inherited from feat(workflows): add WorkflowEvent model and wire format serialization #6858.
  • Merge resolution in WorkflowPaywallView.init.

Risks / Reviewer Notes

  • Risk: incorrect gating could skew funnel metrics (double-fire or missing events).
    • Evidence: WorkflowStepEventCoordinator enforces fire-once + page-rendered gating.
    • Mitigation: WorkflowStepEventCoordinatorTests covers the sequence/gating.

Non-goals / Out of Scope

Omitted Context

  • Raw transcript, unrelated exploration, repetitive attempts, and chain-of-thought content were omitted. Original feature-authoring prompts predate this session and are Not captured.

Note

Medium Risk
Changes funnel/analytics emission during workflow navigation and dismissal; incorrect gating could skew metrics, though coordinator tests cover the main sequences.

Overview
Wires workflow step lifecycle analytics (stepStarted / stepCompleted) through multi-step workflow paywalls, aligned with the Android port.

Adds WorkflowStepEventTracker (event payloads, start/forward/back, terminal-step detection) and WorkflowStepEventCoordinator (per-impression traceId, fire-once rules, and “only if a page rendered” gating). WorkflowPaywallView forwards appear, forward/back navigation, and onDisappear (terminal completion for all dismiss paths) into the coordinator, which sinks into PurchaseHandler.track.

Extends the existing paywall event pipeline: PaywallEventTracker, PaywallPurchasesType, MockPurchases (including map wrappers), Purchases.track(workflowEvent:)eventsManager, with workflow events kept separate from paywall events. Unit tests cover the coordinator, tracker, and forwarding behavior.

Reviewed by Cursor Bugbot for commit c47998e. Bugbot is set up for automated code reviews on this repo. Configure here.

Port of RevenueCat/purchases-android#3487

Emits WorkflowEvent.stepStarted / stepCompleted during multi-step workflow
paywalls, building on the model and wire format added in #6858.

- Adds Purchases.track(workflowEvent:) and routes it through the existing
  FeatureEvent path, exposed via PaywallPurchasesType and PurchaseHandler
- Adds WorkflowStepEventTracker: builds events with a per-impression traceId,
  entry reasons (start/forward/back), and terminal-step detection
- Wires WorkflowPaywallView to emit at four points: initial step, forward,
  back, and terminal completion (anchored to onDisappear like paywall_close)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@facumenzella facumenzella marked this pull request as ready for review May 29, 2026 12:32
@facumenzella facumenzella requested review from a team as code owners May 29, 2026 12:32
Comment thread RevenueCatUI/Templates/V2/WorkflowPaywallView.swift Outdated
facumenzella and others added 4 commits May 29, 2026 15:22
trackTerminalCompletionIfNeeded fired on dismiss whenever navigator.currentStep
was non-nil, even if no page ever rendered. That emitted a stepCompleted with no
preceding stepStarted in two cases: the initial step failing to build, and a
forward destination failing to build (the navigator advances but the page does not).

Gate terminal completion on transitionState.currentPage != nil, the same render
guard trackInitialStepIfNeeded already uses. This mirrors Android keying terminal
completion off _workflowState.value?.currentStepId, which is null when a step fails
to render. Extracted the decision into a pure shouldTrackTerminalCompletion helper
and unit tested it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	RevenueCat.xcodeproj/project.pbxproj
…ission

The step event emission state (trace id, fire-once flags, "only if a page
rendered" gating) lived inline in WorkflowPaywallView, so the emission
sequence could only be verified manually. Android tests the equivalent logic
at the PaywallViewModel level; this brings iOS to parity.

Move that state machine into WorkflowStepEventCoordinator (composing the
existing WorkflowStepEventTracker). The view holds it as @State created in
init, so a new presentation yields a fresh traceId, and its four lifecycle
and navigation hooks become one-line delegations. Behavior is unchanged: the
currentPage gate, fire-once semantics, and forward/back/error/terminal
emission points all match the prior inline logic.

Add WorkflowStepEventCoordinatorTests covering the full appear/forward/back/
dismiss sequence, fire-once and no-page gating, traceId continuity and
per-impression reset, and a journey asserted at the MockPurchases
track(workflowEvent:) boundary (content/count, since the dispatcher does not
guarantee delivery order). The three bool-helper terminal tests in
WorkflowPaywallViewTests are migrated here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread RevenueCatUI/Purchasing/MockPurchases.swift
facumenzella and others added 2 commits June 2, 2026 10:17
…ppers

The map(purchase:restore:) and map(trackEvent:) extensions forwarded the
workflow fetch block but not trackWorkflowEventBlock, so a mapped copy's
track(workflowEvent:) silently dropped events. This is DEBUG-only (MockPurchases
is #if DEBUG) and reachable via PurchaseHandler.cancelling()/.with(delay:), so
production analytics were unaffected, but it could drop events in the
PaywallsTester debug-log path used to verify this feature.

Forward trackWorkflowEventBlock to the original, mirroring how workflowBlock and
the paywall trackEvent block are already forwarded. Flagged by Cursor Bugbot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	RevenueCatUI/Templates/V2/WorkflowPaywallView.swift
facumenzella and others added 3 commits June 4, 2026 16:29
# Conflicts:
#	RevenueCat.xcodeproj/project.pbxproj
#	RevenueCatUI/Purchasing/MockPurchases.swift

@vegaro vegaro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good. I only found some nits. Compared it to Android and it looks solid.

Comment thread RevenueCatUI/Purchasing/PaywallEventTracker.swift Outdated
Comment thread RevenueCatUI/Purchasing/PurchaseHandler.swift Outdated
Rename trackWorkflow/trackWorkflowEvent to track(_:), overloading by event
type alongside the existing track(_ PaywallEvent) methods. Addresses review
feedback on PaywallEventTracker and PurchaseHandler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@facumenzella facumenzella added pr:other and removed pr:feat A new feature feat:PaywallV2 labels Jun 9, 2026
@facumenzella facumenzella enabled auto-merge (squash) June 9, 2026 13:16

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c47998e. Configure here.

to: destination.step,
renderedPageIsNil: false,
entryReason: .back
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Back events use phantom navigator step

Medium Severity

After a forward navigation whose destination fails to render, transitionState.currentPage is cleared but WorkflowNavigator already advanced. Back navigation still derives the leaving step from navigator.currentStep and always passes renderedPageIsNil: false, so trackTransition can emit stepCompleted (and stepStarted) for a step that never had stepStarted, skewing funnel metrics.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c47998e. Configure here.

@facumenzella facumenzella merged commit 255c2df into main Jun 9, 2026
17 of 20 checks passed
@facumenzella facumenzella deleted the port/3487 branch June 9, 2026 13:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants