Files
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

329 lines
11 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
//
// FeatureEvent.swift
//
// Created by Cesar de la Vega on 6/11/24.
protocol FeatureEvent: Encodable, Sendable {
var feature: Feature { get }
var eventDiscriminator: String? { get }
/// Whether this event should be stored and sent to the backend.
/// WIP: Some PaywallEvents are not yet supported by the backend.
/// We should implement support for these events in the backend first
/// and then we can remove this `shouldStoreEvent` (as it will be always `true`)
var shouldStoreEvent: Bool { get }
/// Whether tracking this event should trigger an immediate flush to the backend.
var isPriorityEvent: Bool { get }
}
extension FeatureEvent {
/// By default, all events should be stored.
var shouldStoreEvent: Bool { true }
/// By default, events are not priority events.
var isPriorityEvent: Bool { false }
}
// MARK: - Dictionary Mapping
extension FeatureEvent {
/// Converts this event into a dictionary suitable for hybrid SDK consumption.
func toMap() -> [String: Any] {
switch self {
case let event as PaywallEvent:
return event.paywallMap()
case let event as CustomerCenterEvent:
return event.customerCenterImpressionMap()
case let event as CustomerCenterAnswerSubmittedEvent:
return event.customerCenterAnswerSubmittedMap()
case let event as CustomPaywallEvent:
return event.customPaywallEventMap()
case let event as WorkflowEvent:
return event.workflowEventMap()
case let event as CheckpointEvent:
return event.checkpointEventMap()
default:
return [
"discriminator": "unknown",
"type": "unknown",
"class_name": String(describing: type(of: self))
]
}
}
}
extension PaywallEvent {
/// Snake-case dictionary of this event, as delivered to `EventsListener`.
@_spi(Internal) public func paywallMap() -> [String: Any] {
let typeName: String = {
switch self {
case .impression: return "paywall_impression"
case .cancel: return "paywall_cancel"
case .close: return "paywall_close"
case .exitOffer: return "paywall_exit_offer"
case .purchaseInitiated: return "paywall_purchase_initiated"
case .purchaseError: return "paywall_purchase_error"
case .componentInteraction: return "paywall_component_interacted"
}
}()
var result: [String: Any] = [
"discriminator": "paywalls",
"type": typeName,
"id": self.creationData.id.uuidString,
"timestamp": self.creationData.date.millisecondsSince1970,
"offering_id": self.data.offeringIdentifier,
"paywall_revision": self.data.paywallRevision,
"session_id": self.data.sessionIdentifier.uuidString,
"display_mode": self.data.displayMode.identifier,
"locale": self.data.localeIdentifier,
"dark_mode": self.data.darkMode
]
if let paywallIdentifier = self.data.paywallIdentifier {
result["paywall_id"] = paywallIdentifier
}
if let interaction = self.componentInteractionData {
interaction.mergeIntoPaywallFeatureMap(&result)
}
return result
}
}
private extension PaywallEvent.ComponentInteractionData {
func mergeIntoPaywallFeatureMap(_ result: inout [String: Any]) {
self.mergeCoreFields(into: &result)
self.mergePackageIdentifiers(into: &result)
self.mergeProductIdentifiers(into: &result)
}
func mergeCoreFields(into result: inout [String: Any]) {
result["component_type"] = self.componentType.rawValue
result["component_value"] = self.componentValue
if let name = self.componentName {
result["component_name"] = name
}
if let url = self.componentURL {
result["component_url"] = url.absoluteString
}
if let originIndex = self.originIndex {
result["origin_index"] = originIndex
}
if let destinationIndex = self.destinationIndex {
result["destination_index"] = destinationIndex
}
if let originContextName = self.originContextName {
result["origin_context_name"] = originContextName
}
if let destinationContextName = self.destinationContextName {
result["destination_context_name"] = destinationContextName
}
if let defaultIndex = self.defaultIndex {
result["default_index"] = defaultIndex
}
}
func mergePackageIdentifiers(into result: inout [String: Any]) {
if let originPackageIdentifier = self.originPackageIdentifier {
result["origin_package_id"] = originPackageIdentifier
}
if let destinationPackageIdentifier = self.destinationPackageIdentifier {
result["destination_package_id"] = destinationPackageIdentifier
}
if let defaultPackageIdentifier = self.defaultPackageIdentifier {
result["default_package_id"] = defaultPackageIdentifier
}
if let currentPackageIdentifier = self.currentPackageIdentifier {
result["current_package_id"] = currentPackageIdentifier
}
if let resultingPackageIdentifier = self.resultingPackageIdentifier {
result["resulting_package_id"] = resultingPackageIdentifier
}
}
func mergeProductIdentifiers(into result: inout [String: Any]) {
if let originProductIdentifier = self.originProductIdentifier {
result["origin_product_id"] = originProductIdentifier
}
if let destinationProductIdentifier = self.destinationProductIdentifier {
result["destination_product_id"] = destinationProductIdentifier
}
if let defaultProductIdentifier = self.defaultProductIdentifier {
result["default_product_id"] = defaultProductIdentifier
}
if let currentProductIdentifier = self.currentProductIdentifier {
result["current_product_id"] = currentProductIdentifier
}
if let resultingProductIdentifier = self.resultingProductIdentifier {
result["resulting_product_id"] = resultingProductIdentifier
}
}
}
private extension CustomerCenterEvent {
func customerCenterImpressionMap() -> [String: Any] {
return [
"discriminator": "customer_center",
"type": "customer_center_impression",
"id": self.creationData.id.uuidString,
"timestamp": self.creationData.date.millisecondsSince1970,
"dark_mode": self.data.darkMode,
"locale": self.data.localeIdentifier,
"display_mode": self.data.displayMode.identifier
]
}
}
private extension CustomPaywallEvent {
func customPaywallEventMap() -> [String: Any] {
let typeName: String = {
switch self {
case .impression: return "custom_paywall_impression"
}
}()
var result: [String: Any] = [
"discriminator": "custom_paywall_event",
"type": typeName,
"id": self.creationData.id.uuidString,
"timestamp": self.creationData.date.millisecondsSince1970
]
if let paywallId = self.data.paywallId {
result["paywall_id"] = paywallId
}
if let offeringId = self.data.offeringId {
result["offering_id"] = offeringId
}
if let placementIdentifier = self.data.placementIdentifier {
result["placement_identifier"] = placementIdentifier
}
if let targetingRevision = self.data.targetingRevision {
result["targeting_revision"] = targetingRevision
}
if let targetingRuleId = self.data.targetingRuleId {
result["targeting_rule_id"] = targetingRuleId
}
return result
}
}
private extension WorkflowEvent {
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": self.typeName,
"id": self.creationData.id.uuidString,
"timestamp": self.creationData.date.millisecondsSince1970,
"workflow_id": self.data.workflowId,
"step_id": self.data.stepId,
"locale": self.data.localeIdentifier
]
if let traceId = self.data.traceId { result["trace_id"] = traceId }
if let fromStepId = self.data.fromStepId { result["from_step_id"] = fromStepId }
if let toStepId = self.data.toStepId { result["to_step_id"] = toStepId }
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
}
return result
}
}
private extension CheckpointEvent {
func checkpointEventMap() -> [String: Any] {
var result: [String: Any] = [
"discriminator": "checkpoint",
"type": self.eventType,
"id": self.data.id.uuidString,
"timestamp": self.data.date.millisecondsSince1970,
"identifier": self.data.identifier,
"checkpoint_type": self.data.checkpointType.rawValue,
"result": self.data.result.rawValue
]
if let workflowID = self.data.workflowID { result["workflow_id"] = workflowID }
if let offeringID = self.data.offeringID { result["offering_id"] = offeringID }
if let ruleID = self.data.checkpointRuleID { result["checkpoint_rule_id"] = ruleID }
if let traceID = self.data.traceID { result["trace_id"] = traceID }
return result
}
}
private extension CustomerCenterAnswerSubmittedEvent {
func customerCenterAnswerSubmittedMap() -> [String: Any] {
var result: [String: Any] = [
"discriminator": "customer_center",
"type": "customer_center_survey_option_chosen",
"id": self.creationData.id.uuidString,
"timestamp": self.creationData.date.millisecondsSince1970,
"dark_mode": self.data.darkMode,
"locale": self.data.localeIdentifier,
"display_mode": self.data.displayMode.identifier,
"survey_option_id": self.data.surveyOptionID,
"path": self.data.path.rawValue,
"revision_id": self.data.revisionID
]
if let url = self.data.url {
result["url"] = url.absoluteString
}
return result
}
}