diff --git a/RevenueCatUI/Templates/V2/ViewModelHelpers/WorkflowStepEventTracker.swift b/RevenueCatUI/Templates/V2/ViewModelHelpers/WorkflowStepEventTracker.swift index 09a1973049..0ee767d78a 100644 --- a/RevenueCatUI/Templates/V2/ViewModelHelpers/WorkflowStepEventTracker.swift +++ b/RevenueCatUI/Templates/V2/ViewModelHelpers/WorkflowStepEventTracker.swift @@ -114,6 +114,8 @@ final class WorkflowStepEventTracker { entryReason: entryReason, isFirstStep: step.id == (self.resolvedInitialStepId ?? self.workflow.initialStepId), isLastStep: Self.isTerminalStep(step), + workflowBlobRef: self.workflowBlobRef, + fallbackOriginalStepId: step.fallbackOriginalStepId, experiment: self.experimentData(for: step) ) } @@ -121,15 +123,11 @@ final class WorkflowStepEventTracker { private func experimentData(for step: WorkflowStep) -> WorkflowEvent.ExperimentData? { guard let experimentId = step.experimentId, let experimentVariant = step.experimentVariant, - let workflowBlobRef = self.workflowBlobRef else { + self.workflowBlobRef != nil else { return nil } - return .init( - experimentId: experimentId, - experimentVariant: experimentVariant, - workflowBlobRef: workflowBlobRef - ) + return .init(experimentId: experimentId, experimentVariant: experimentVariant) } /// A step is terminal when none of its trigger actions navigate to another step. diff --git a/Sources/Events/FeatureEvents/FeatureEvent.swift b/Sources/Events/FeatureEvents/FeatureEvent.swift index 84e2ebdccd..dafb541904 100644 --- a/Sources/Events/FeatureEvents/FeatureEvent.swift +++ b/Sources/Events/FeatureEvents/FeatureEvent.swift @@ -240,18 +240,18 @@ private extension CustomPaywallEvent { private extension WorkflowEvent { - func workflowEventMap() -> [String: Any] { - let typeName: String = { - switch self { - case .stepStarted: return "workflow_step_started" - case .stepCompleted: return "workflow_step_completed" - case .close: return "workflow_close" - } - }() + var typeName: String { + switch self { + case .stepStarted: return "workflow_step_started" + case .stepCompleted: return "workflow_step_completed" + case .close: return "workflow_close" + } + } + func workflowEventMap() -> [String: Any] { var result: [String: Any] = [ "discriminator": "workflows", - "type": typeName, + "type": self.typeName, "id": self.creationData.id.uuidString, "timestamp": self.creationData.date.millisecondsSince1970, "workflow_id": self.data.workflowId, @@ -265,10 +265,13 @@ private extension WorkflowEvent { if let entryReason = self.data.entryReason { result["entry_reason"] = entryReason } if let isFirstStep = self.data.isFirstStep { result["is_first_step"] = isFirstStep } if let isLastStep = self.data.isLastStep { result["is_last_step"] = isLastStep } + if let workflowBlobRef = self.data.workflowBlobRef { result["blob_ref"] = workflowBlobRef } + if let fallbackOriginalStepId = self.data.fallbackOriginalStepId { + result["fallback_original_step_id"] = fallbackOriginalStepId + } if let experiment = self.data.experiment { result["experiment_id"] = experiment.experimentId result["experiment_variant"] = experiment.experimentVariant - result["blob_ref"] = experiment.workflowBlobRef } return result diff --git a/Sources/Events/FeatureEvents/Networking/FeatureEventsRequest+WorkflowEvent.swift b/Sources/Events/FeatureEvents/Networking/FeatureEventsRequest+WorkflowEvent.swift index 9941a76689..9ed3f05c2b 100644 --- a/Sources/Events/FeatureEvents/Networking/FeatureEventsRequest+WorkflowEvent.swift +++ b/Sources/Events/FeatureEvents/Networking/FeatureEventsRequest+WorkflowEvent.swift @@ -45,6 +45,7 @@ extension FeatureEventsRequest { let experimentId: String? let experimentVariant: String? let workflowBlobRef: String? + let fallbackOriginalStepId: String? } // swiftlint:enable nesting @@ -86,7 +87,8 @@ extension FeatureEventsRequest.WorkflowEvent { isLastStep: event.data.isLastStep, experimentId: event.data.experiment?.experimentId, experimentVariant: event.data.experiment?.experimentVariant, - workflowBlobRef: event.data.experiment?.workflowBlobRef + workflowBlobRef: event.data.workflowBlobRef, + fallbackOriginalStepId: event.data.fallbackOriginalStepId ) ) } catch { @@ -181,6 +183,7 @@ extension FeatureEventsRequest.WorkflowEvent.Properties: Encodable { try container.encodeIfPresent(experimentId, forKey: .experimentId) try container.encodeIfPresent(experimentVariant, forKey: .experimentVariant) try container.encodeIfPresent(workflowBlobRef, forKey: .workflowBlobRef) + try container.encodeIfPresent(fallbackOriginalStepId, forKey: .fallbackOriginalStepId) } private enum CodingKeys: String, CodingKey { @@ -196,6 +199,7 @@ extension FeatureEventsRequest.WorkflowEvent.Properties: Encodable { case experimentId = "experiment_id" case experimentVariant = "experiment_variant" case workflowBlobRef = "blob_ref" + case fallbackOriginalStepId = "fallback_original_step_id" } diff --git a/Sources/Networking/Responses/WorkflowsResponse.swift b/Sources/Networking/Responses/WorkflowsResponse.swift index 8addb82936..874ac456f9 100644 --- a/Sources/Networking/Responses/WorkflowsResponse.swift +++ b/Sources/Networking/Responses/WorkflowsResponse.swift @@ -99,17 +99,26 @@ import Foundation } } - public var experimentId: String? { self.stringParam(Self.experimentIdParam) } + /// Set on khepri's `~f` fallback copies: the id of the step they were copied from. + public var fallbackOriginalStepId: String? { Self.string(self.metadata?[Self.fallbackOriginalStepIdKey]) } - public var experimentVariant: String? { self.stringParam(Self.experimentVariantParam) } + /// `metadata` first, `param_values` for blobs published before khepri moves them to `metadata`. + public var experimentId: String? { self.experimentValueFromMetadataOrParams(Self.experimentIdParam) } - private func stringParam(_ key: String) -> String? { - guard case let .string(value)? = self.paramValues[key] else { return nil } - return value + public var experimentVariant: String? { self.experimentValueFromMetadataOrParams(Self.experimentVariantParam) } + + private func experimentValueFromMetadataOrParams(_ key: String) -> String? { + return Self.string(self.metadata?[key]) ?? Self.string(self.paramValues[key]) + } + + private static func string(_ value: AnyDecodable?) -> String? { + guard case let .string(string)? = value else { return nil } + return string } private static let experimentIdParam = "experiment_id" private static let experimentVariantParam = "experiment_variant" + private static let fallbackOriginalStepIdKey = "fallback_original_step_id" // `paramValues`, `outputs`, and `metadata` carry backend step config that the renderer doesn't // read directly (`metadata` via `stepScreenType`, `paramValues` via the experiment diff --git a/Sources/Paywalls/Events/WorkflowEvent.swift b/Sources/Paywalls/Events/WorkflowEvent.swift index ecd19c3649..627294017e 100644 --- a/Sources/Paywalls/Events/WorkflowEvent.swift +++ b/Sources/Paywalls/Events/WorkflowEvent.swift @@ -66,6 +66,8 @@ extension WorkflowEvent { public var entryReason: String? public var isFirstStep: Bool? public var isLastStep: Bool? + public var workflowBlobRef: String? + public var fallbackOriginalStepId: String? public var experiment: ExperimentData? public init( @@ -81,6 +83,8 @@ extension WorkflowEvent { entryReason: String? = nil, isFirstStep: Bool? = nil, isLastStep: Bool? = nil, + workflowBlobRef: String? = nil, + fallbackOriginalStepId: String? = nil, experiment: ExperimentData? = nil ) { self.workflowId = workflowId @@ -95,6 +99,8 @@ extension WorkflowEvent { self.entryReason = entryReason self.isFirstStep = isFirstStep self.isLastStep = isLastStep + self.workflowBlobRef = workflowBlobRef + self.fallbackOriginalStepId = fallbackOriginalStepId self.experiment = experiment } @@ -128,12 +134,10 @@ extension WorkflowEvent { public var experimentId: String public var experimentVariant: String - public var workflowBlobRef: String - public init(experimentId: String, experimentVariant: String, workflowBlobRef: String) { + public init(experimentId: String, experimentVariant: String) { self.experimentId = experimentId self.experimentVariant = experimentVariant - self.workflowBlobRef = workflowBlobRef } } diff --git a/Tests/RevenueCatUITests/PaywallsV2/WorkflowStepEventTrackerTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WorkflowStepEventTrackerTests.swift index 079f8df6d2..93f6b89bc9 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WorkflowStepEventTrackerTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WorkflowStepEventTrackerTests.swift @@ -195,11 +195,58 @@ final class WorkflowStepEventTrackerTests: TestCase { tracker.trackInitialStep(step2) expect(self.recorded).to(haveCount(3)) - expect(self.recorded[0].data.experiment?.workflowBlobRef) == "blob-ref-1" - expect(self.recorded[1].data.experiment?.workflowBlobRef) == "blob-ref-1" + expect(self.recorded[0].data.workflowBlobRef) == "blob-ref-1" + expect(self.recorded[1].data.workflowBlobRef) == "blob-ref-1" expect(self.recorded[2].data.experiment).to(beNil()) } + func testStepEventsCarryTheWorkflowBlobRefOutsideAnExperiment() throws { + let workflow = try Self.makeWorkflow() + let tracker = self.makeTracker(workflow: workflow, workflowBlobRef: "blob-ref-1") + let step1 = try XCTUnwrap(workflow.steps["step_1"]) + let step2 = try XCTUnwrap(workflow.steps["step_2"]) + + tracker.trackInitialStep(step1) + tracker.trackNavigation(from: step1, to: step2, entryReason: .forward) + tracker.trackClose(step2) + + expect(self.recorded).to(haveCount(4)) + expect(self.recorded.map(\.data.workflowBlobRef)) == Array(repeating: "blob-ref-1", count: 4) + expect(self.recorded.allSatisfy { $0.data.experiment == nil }) == true + } + + // MARK: - Fallback copies + + func testFallbackCopyEventsReportTheRawIdAndTheOriginalStepId() throws { + let workflow = try Self.makeFallbackCopyWorkflow() + let tracker = self.makeTracker(workflow: workflow, workflowBlobRef: "blob-ref-1") + let entry = try XCTUnwrap(workflow.steps["entry"]) + let copy = try XCTUnwrap(workflow.steps["paywall_a~f"]) + + tracker.trackInitialStep(entry) + tracker.trackNavigation(from: entry, to: copy, entryReason: .forward) + tracker.trackClose(copy) + + expect(self.recorded).to(haveCount(4)) + let startedEntry = try XCTUnwrap(Self.startedData(self.recorded[0])) + expect(startedEntry.fallbackOriginalStepId).to(beNil()) + + let completedEntry = try XCTUnwrap(Self.completedData(self.recorded[1])) + expect(completedEntry.toStepId) == "paywall_a~f" + expect(completedEntry.fallbackOriginalStepId).to(beNil()) + + let startedCopy = try XCTUnwrap(Self.startedData(self.recorded[2])) + expect(startedCopy.stepId) == "paywall_a~f" + expect(startedCopy.fromStepId) == "entry" + expect(startedCopy.fallbackOriginalStepId) == "paywall_a" + expect(startedCopy.experiment).to(beNil()) + expect(startedCopy.workflowBlobRef) == "blob-ref-1" + + let closedCopy = self.recorded[3].data + expect(closedCopy.stepId) == "paywall_a~f" + expect(closedCopy.fallbackOriginalStepId) == "paywall_a" + } + func testExperimentIsNotReportedWithoutTheWorkflowBlobRef() throws { let workflow = try Self.makeWorkflow( step1ParamValuesJSON: #"{ "experiment_id": "exp_abc", "experiment_variant": "b" }"# @@ -308,6 +355,46 @@ private extension WorkflowStepEventTrackerTests { return try JSONDecoder.default.decode(PublishedWorkflow.self, from: data) } + /// `entry` navigates to `paywall_a~f`, the fallback copy of `paywall_a`. + static func makeFallbackCopyWorkflow() throws -> PublishedWorkflow { + let json = """ + { + "id": "wf_test", + "display_name": "Test Workflow", + "initial_step_id": "entry", + "steps": { + "entry": { + "id": "entry", + "type": "screen", + "triggers": [ + {"name":"Button","type":"on_press","action_id":"btn","component_id":"btn"} + ], + "trigger_actions": { "btn": {"type":"step","step_id":"paywall_a~f"} } + }, + "paywall_a": { + "id": "paywall_a", + "type": "screen", + "screen_id": "pw_123", + "param_values": { "experiment_id": "exp_abc", "experiment_variant": "b" } + }, + "paywall_a~f": { + "id": "paywall_a~f", + "type": "screen", + "screen_id": "pw_123", + "metadata": { "fallback_original_step_id": "paywall_a" } + } + }, + "screens": {}, + "ui_config": { + "app": { "colors": {}, "fonts": {} }, + "localizations": {} + } + } + """ + let data = try XCTUnwrap(json.data(using: .utf8)) + return try JSONDecoder.default.decode(PublishedWorkflow.self, from: data) + } + static func startedData(_ event: WorkflowEvent) -> WorkflowEvent.Data? { guard case let .stepStarted(_, data) = event else { return nil } return data diff --git a/Tests/UnitTests/Events/EventsManagerTests.swift b/Tests/UnitTests/Events/EventsManagerTests.swift index d479cbeeec..e58e0a30d3 100644 --- a/Tests/UnitTests/Events/EventsManagerTests.swift +++ b/Tests/UnitTests/Events/EventsManagerTests.swift @@ -631,10 +631,11 @@ class EventsManagerTests: TestCase { entryReason: "start", isFirstStep: true, isLastStep: false, + workflowBlobRef: "blob-ref-1", + fallbackOriginalStepId: "step-1-original", experiment: .init( experimentId: "exp-1", - experimentVariant: "variant-a", - workflowBlobRef: "blob-ref-1" + experimentVariant: "variant-a" ) ) ) @@ -648,6 +649,7 @@ class EventsManagerTests: TestCase { expect(map["experiment_id"] as? String) == "exp-1" expect(map["experiment_variant"] as? String) == "variant-a" expect(map["blob_ref"] as? String) == "blob-ref-1" + expect(map["fallback_original_step_id"] as? String) == "step-1-original" } func testWorkflowEventToMapOmitsNilOptionalFields() { @@ -666,6 +668,7 @@ class EventsManagerTests: TestCase { expect(map["experiment_id"]).to(beNil()) expect(map["experiment_variant"]).to(beNil()) expect(map["blob_ref"]).to(beNil()) + expect(map["fallback_original_step_id"]).to(beNil()) } // MARK: - flushAllEvents diff --git a/Tests/UnitTests/Networking/Workflows/WorkflowResponseTests.swift b/Tests/UnitTests/Networking/Workflows/WorkflowResponseTests.swift index 9c70e3bb67..ab7902c60a 100644 --- a/Tests/UnitTests/Networking/Workflows/WorkflowResponseTests.swift +++ b/Tests/UnitTests/Networking/Workflows/WorkflowResponseTests.swift @@ -796,6 +796,85 @@ class WorkflowResponseTests: TestCase { expect(step.experimentVariant).to(beNil()) } + func testDecodeWorkflowStepExperimentParamsFromMetadata() throws { + let json = """ + { + "id": "step_1", + "type": "screen", + "metadata": { "experiment_id": "exp_abc", "experiment_variant": "holdout" } + } + """.data(using: .utf8)! + + let step = try JSONDecoder.default.decode(WorkflowStep.self, from: json) + + expect(step.experimentId) == "exp_abc" + expect(step.experimentVariant) == "holdout" + } + + func testDecodeWorkflowStepExperimentParamsPreferMetadataOverParamValues() throws { + let json = """ + { + "id": "step_1", + "type": "screen", + "param_values": { "experiment_id": "exp_old", "experiment_variant": "a" }, + "metadata": { "experiment_id": "exp_new", "experiment_variant": "b" } + } + """.data(using: .utf8)! + + let step = try JSONDecoder.default.decode(WorkflowStep.self, from: json) + + expect(step.experimentId) == "exp_new" + expect(step.experimentVariant) == "b" + } + + func testDecodeWorkflowWithFallbackCopyStep() throws { + let json = """ + { + "id": "wf_test", + "display_name": "Test", + "initial_step_id": "entry", + "steps": { + "entry": { + "id": "entry", + "type": "screen", + "triggers": [ + {"name":"Button","type":"on_press","action_id":"btn","component_id":"btn"} + ], + "trigger_actions": { "btn": {"type":"step","step_id":"paywall_a~f"} } + }, + "paywall_a": { + "id": "paywall_a", + "type": "screen", + "screen_id": "pw_123", + "param_values": { "experiment_id": "exp_abc", "experiment_variant": "b" }, + "metadata": { "screen_type": ["paywall"] } + }, + "paywall_a~f": { + "id": "paywall_a~f", + "type": "screen", + "screen_id": "pw_123", + "param_values": {}, + "metadata": { "screen_type": ["paywall"], "fallback_original_step_id": "paywall_a" } + } + }, + "screens": {}, + "ui_config": { "app": { "colors": {}, "fonts": {} }, "localizations": {} } + } + """.data(using: .utf8)! + + let workflow = try JSONDecoder.default.decode(PublishedWorkflow.self, from: json) + + let original = try XCTUnwrap(workflow.steps["paywall_a"]) + let copy = try XCTUnwrap(workflow.steps["paywall_a~f"]) + expect(workflow.steps["entry"]?.triggerActions["btn"]) == .step(stepId: "paywall_a~f") + expect(copy.id) == "paywall_a~f" + expect(copy.screenId) == original.screenId + expect(copy.fallbackOriginalStepId) == "paywall_a" + expect(copy.experimentId).to(beNil()) + expect(copy.stepScreenType) == ["paywall"] + expect(original.fallbackOriginalStepId).to(beNil()) + } + func testDecodeWorkflowStepScreenTypeNilWhenMetadataNull() throws { let json = """ { diff --git a/Tests/UnitTests/Paywalls/Events/WorkflowEventsRequestTests.swift b/Tests/UnitTests/Paywalls/Events/WorkflowEventsRequestTests.swift index dbe798b80e..7c725acfdc 100644 --- a/Tests/UnitTests/Paywalls/Events/WorkflowEventsRequestTests.swift +++ b/Tests/UnitTests/Paywalls/Events/WorkflowEventsRequestTests.swift @@ -168,7 +168,7 @@ class WorkflowEventsRequestTests: TestCase { .init( workflowId: "wfl_abc", stepId: "step-1", - experiment: .init(experimentId: "exp-1", experimentVariant: "variant-a", workflowBlobRef: "blob-ref-1") + workflowBlobRef: "blob-ref-1" ) ) let stored = try XCTUnwrap(storedEvent(from: event)) @@ -177,6 +177,26 @@ class WorkflowEventsRequestTests: TestCase { expect(request.properties.workflowBlobRef) == "blob-ref-1" expect(json).to(contain("\"blob_ref\":\"blob-ref-1\"")) + expect(json).toNot(contain("experiment_id")) + } + + func testFallbackOriginalStepIdInWireFormat() throws { + let event = WorkflowEvent.stepStarted( + .init(id: id, date: date), + .init( + workflowId: "wfl_abc", + stepId: "paywall_a~f", + fallbackOriginalStepId: "paywall_a" + ) + ) + let stored = try XCTUnwrap(storedEvent(from: event)) + let request = try XCTUnwrap(FeatureEventsRequest.WorkflowEvent(storedEvent: stored)) + let json = try encodedJSON(from: event) + + expect(request.properties.stepId) == "paywall_a~f" + expect(request.properties.fallbackOriginalStepId) == "paywall_a" + expect(json).to(contain("\"step_id\":\"paywall_a~f\"")) + expect(json).to(contain("\"fallback_original_step_id\":\"paywall_a\"")) } func testCloseKeepsExperimentPropertiesInWireFormat() throws { @@ -185,10 +205,10 @@ class WorkflowEventsRequestTests: TestCase { .init( workflowId: "wfl_abc", stepId: "step-1", + workflowBlobRef: "blob-ref-1", experiment: .init( experimentId: "exp-1", - experimentVariant: "variant-a", - workflowBlobRef: "blob-ref-1" + experimentVariant: "variant-a" ) ) ) @@ -256,6 +276,7 @@ class WorkflowEventsRequestTests: TestCase { expect(json).toNot(contain("experiment_id")) expect(json).toNot(contain("experiment_variant")) expect(json).toNot(contain("blob_ref")) + expect(json).toNot(contain("fallback_original_step_id")) } func testExperimentPropertiesInWireFormat() throws { @@ -264,10 +285,10 @@ class WorkflowEventsRequestTests: TestCase { .init( workflowId: "wfl_abc", stepId: "step-1", + workflowBlobRef: "blob-ref-1", experiment: .init( experimentId: "exp-1", - experimentVariant: "variant-a", - workflowBlobRef: "blob-ref-1" + experimentVariant: "variant-a" ) ) ) @@ -284,10 +305,10 @@ class WorkflowEventsRequestTests: TestCase { .init( workflowId: "wfl_abc", stepId: "step-1", + workflowBlobRef: "blob-ref-1", experiment: .init( experimentId: "exp-1", - experimentVariant: "variant-a", - workflowBlobRef: "blob-ref-1" + experimentVariant: "variant-a" ) ) )