feat(workflows): wire workflow step lifecycle events in PaywallViewModel#6868
Conversation
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>
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>
…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
# Conflicts: # RevenueCat.xcodeproj/project.pbxproj # RevenueCatUI/Purchasing/MockPurchases.swift
vegaro
left a comment
There was a problem hiding this comment.
Looks good. I only found some nits. Compared it to Android and it looks solid.
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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 | ||
| ) |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit c47998e. Configure here.


Port of RevenueCat/purchases-android#3487
Builds on #6858 (the
WorkflowEventmodel + wire format, the iOS port of android #3486), which is now merged. This branch is also up to date withmain.Summary
Emits
WorkflowEvent.stepStarted/stepCompletedduring multi-step workflow paywalls. Key pieces:Purchases.track(workflowEvent:)— tracking entry point.WorkflowStepEventTracker— builds the events (isFirstStep/isLastStep, terminal-step detection, andstart/forward/backentry reasons).WorkflowStepEventCoordinator— owns the per-impressiontraceIdand 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. TerminalstepCompletedis anchored toonDisappear.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 terminalstepCompletedis anchored toonDisappear, the same lifecycle hookpaywall_closealready uses.Locale. Android's #3487 wiring sets no locale, so its
context.localedefaults tonull. This wiring likewise sets no locale. iOS'sWorkflowEvent.Data(added in #6858) defaultslocaleIdentifierto the device locale and serializes it, so iOS will emit the device locale rather thannull. 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:
Literaltypes (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.is_first_step/is_last_stepand derivesstep_type/screen_typefrom its own DB step model, so the SDK correctly omitsstep_type/screen_type(andworkflow_type, which the backend doesn't read yet). Android omits the same three (asserted by its serialization tests).rc_workflow_*_event, but that is unshipped everywhere (SDKs and Khepri still use pluralworkflows). Renaming the iOS strings in isolation would break ingestion, so this PR intentionally matches today's contract.AI session context
AI Context
Metadata
Goal
Emit
stepStarted/stepCompletedworkflow lifecycle events during multi-step workflow paywalls (port of android #3487). This session brought the branch up to date withmain, 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
maininto the branch and fix conflicts (verbatim opener: "Check #6868", followed by "Fix conflicts, and bring main to this branch").Important Follow-up Prompts
Agent Contribution
origin/mainintoport/3487(branch was 15 behind); resolved the single conflict inWorkflowPaywallView.swiftinit(both-add: kept main'sseenPages/transitionStateplus this branch'sstepEventCoordinator).swift buildclean, committed the merge, pushed; PR went from CONFLICTING to MERGEABLE.PaywallViewModelemission logic.Human Decisions
mainin (merge commit) rather than rebase + force-push the open PR.Key Implementation Decisions
stepCompletedtoWorkflowPaywallView.onDisappear.onDisappearis the one signal that catches every dismissal path. Faithful to Android, which fires terminal completion offcurrentStepIdon dismiss/purchase/restore.workflowsevent names /type, no_eventsuffix.Literalcontract and Android. Spec's singular rename is unshipped everywhere.rc_workflow_*_eventnow (would break ingestion until backend updates).workflow_type/step_type/screen_type.workflow_typeisn't read yet andPublishedWorkflowhas no type field.Files / Symbols Touched
RevenueCatUI/Templates/V2/WorkflowPaywallView.swiftinit(this session); feature lifecycle hooks (PR).init,trackInitialStep,trackTerminalCompletion,trackTransitionhasRenderedPagenow reads main'stransitionState.currentPage.WorkflowStepEventCoordinator.swift,WorkflowStepEventTracker.swift,PurchaseHandler.swift,PaywallEventTracker.swift,PaywallPurchasesType.swift,MockPurchases.swift,Purchases.swift, plus tests underTests/RevenueCatUITests/.Dependencies / Config / Migrations
workflow_typedata-model field, checkout event family.Validation
swift build: clean (build complete).git merge origin/main: one conflict, resolved; merge commit632b853.Literaltypes match iOS wire format exactly. Proof:khepri/services/workflows/domain/workflows_events.py:32-53,khepri/events/event_types.py:46-48.Validation Gaps
swift build). Cheap local check:WorkflowStepEventCoordinatorTests+WorkflowPaywallViewTestsvia the Tuist UnitTests scheme.Review Focus
stepCompletedfires 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.null) — intentional, inherited from feat(workflows): add WorkflowEvent model and wire format serialization #6858.WorkflowPaywallView.init.Risks / Reviewer Notes
WorkflowStepEventCoordinatorenforces fire-once + page-rendered gating.WorkflowStepEventCoordinatorTestscovers the sequence/gating.Non-goals / Out of Scope
rc_prefix,_eventsuffix),workflow_type/checkout events — coordinated cross-repo work, not this PR.Omitted Context
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) andWorkflowStepEventCoordinator(per-impressiontraceId, fire-once rules, and “only if a page rendered” gating).WorkflowPaywallViewforwards appear, forward/back navigation, andonDisappear(terminal completion for all dismiss paths) into the coordinator, which sinks intoPurchaseHandler.track.Extends the existing paywall event pipeline:
PaywallEventTracker,PaywallPurchasesType,MockPurchases(includingmapwrappers),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.