Files
purchases-ios/Sources/Events/FeatureEvents/Networking/FeatureEventsRequest+WorkflowEvent.swift
T
Facundo MenzellaandClaude Opus 5.5 4a87bf64f3 other: send fallback_original_step_id and always send blob_ref on workflow events (#7878)
* Send fallback_original_step_id and always send blob_ref on workflow events

khepri publishes ~f fallback copies of screens for audience-targeted
experiments (WFL-626). Workflow events now carry the copy's
fallback_original_step_id and send blob_ref whenever the SDK has one, so
khepri resolves each step against its exact blob. Experiment params are
read from step metadata first, then param_values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Trim workflow event comments

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Drop legacy blob_ref decoding for queued workflow events

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Revert unrelated Package.resolved change

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Rename experimentParam to experimentValueFromMetadataOrParams

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 11:07:10 +02:00

207 lines
7.0 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// FeatureEventsRequest+WorkflowEvent.swift
import Foundation
/// Type alias to avoid naming conflict inside `FeatureEventsRequest.WorkflowEvent`.
private typealias StoredWorkflowEvent = WorkflowEvent
extension FeatureEventsRequest {
/// Khepri-compatible wire format for workflow step lifecycle events.
struct WorkflowEvent {
let type: String
let id: String
let version: Int
let eventName: String
let timestampMs: UInt64
let appUserID: String
let context: Context
let properties: Properties
// swiftlint:disable nesting
struct Context {
let locale: String
}
struct Properties {
let workflowId: String
let stepId: String
let traceId: String?
let fromStepId: String?
let toStepId: String?
let entryReason: String?
let isFirstStep: Bool?
let isLastStep: Bool?
let experimentId: String?
let experimentVariant: String?
let workflowBlobRef: String?
let fallbackOriginalStepId: String?
}
// swiftlint:enable nesting
}
}
extension FeatureEventsRequest.WorkflowEvent {
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
init?(storedEvent: StoredFeatureEvent) {
guard storedEvent.feature == .workflows else { return nil }
guard let jsonData = storedEvent.encodedEvent.data(using: .utf8) else {
Logger.error(Strings.paywalls.event_cannot_get_encoded_event)
return nil
}
do {
let event = try JSONDecoder.default.decode(StoredWorkflowEvent.self, from: jsonData)
let wire = Self.wireFields(for: event)
self.init(
type: Self.typeValue,
id: event.creationData.id.uuidString,
version: Self.schemaVersion,
eventName: wire.eventName,
timestampMs: event.creationData.date.millisecondsSince1970,
appUserID: storedEvent.userID,
context: Context(locale: event.data.localeIdentifier),
properties: Properties(
workflowId: event.data.workflowId,
stepId: event.data.stepId,
traceId: event.data.traceId,
fromStepId: wire.fromStepId,
toStepId: wire.toStepId,
entryReason: wire.entryReason,
isFirstStep: event.data.isFirstStep,
isLastStep: event.data.isLastStep,
experimentId: event.data.experiment?.experimentId,
experimentVariant: event.data.experiment?.experimentVariant,
workflowBlobRef: event.data.workflowBlobRef,
fallbackOriginalStepId: event.data.fallbackOriginalStepId
)
)
} catch {
Logger.error(Strings.paywalls.event_cannot_deserialize(error))
return nil
}
}
/// The event-name and navigation fields that vary per `WorkflowEvent` case. `close` is an
/// abandonment signal, not a navigation, so it carries no from/to step or entry reason.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private static func wireFields(for event: StoredWorkflowEvent) -> WireFields {
switch event {
case .stepStarted:
return .init(
eventName: stepStartedEventName,
fromStepId: event.data.fromStepId,
toStepId: nil,
entryReason: event.data.entryReason
)
case .stepCompleted:
return .init(
eventName: stepCompletedEventName,
fromStepId: nil,
toStepId: event.data.toStepId,
entryReason: nil
)
case .close:
return .init(eventName: closeEventName, fromStepId: nil, toStepId: nil, entryReason: nil)
}
}
private struct WireFields {
let eventName: String
let fromStepId: String?
let toStepId: String?
let entryReason: String?
}
private static let schemaVersion = 1
private static let typeValue = "workflows"
private static let stepStartedEventName = "workflow_step_started"
private static let stepCompletedEventName = "workflow_step_completed"
private static let closeEventName = "workflow_close"
}
// MARK: - Encodable
extension FeatureEventsRequest.WorkflowEvent: Encodable {
private enum CodingKeys: String, CodingKey {
case type
case id
case version
case eventName = "event_name"
case timestampMs = "timestamp_ms"
case appUserID = "app_user_id"
case context
case properties
}
}
extension FeatureEventsRequest.WorkflowEvent.Context: Encodable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(locale, forKey: .locale)
}
private enum CodingKeys: String, CodingKey {
case locale
}
}
extension FeatureEventsRequest.WorkflowEvent.Properties: Encodable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(workflowId, forKey: .workflowId)
try container.encode(stepId, forKey: .stepId)
try container.encodeIfPresent(traceId, forKey: .traceId)
try container.encodeIfPresent(fromStepId, forKey: .fromStepId)
try container.encodeIfPresent(toStepId, forKey: .toStepId)
try container.encodeIfPresent(entryReason, forKey: .entryReason)
try container.encodeIfPresent(isFirstStep, forKey: .isFirstStep)
try container.encodeIfPresent(isLastStep, forKey: .isLastStep)
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 {
case workflowId = "workflow_id"
case stepId = "step_id"
case traceId = "trace_id"
case fromStepId = "from_step_id"
case toStepId = "to_step_id"
case entryReason = "entry_reason"
case isFirstStep = "is_first_step"
case isLastStep = "is_last_step"
case experimentId = "experiment_id"
case experimentVariant = "experiment_variant"
case workflowBlobRef = "blob_ref"
case fallbackOriginalStepId = "fallback_original_step_id"
}
}