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>
This commit is contained in:
Facundo MenzellaandClaude Opus 5.5 authored and GitHub committed 2026-10-07 11:07:10 +02:00
1 parent f9d0e4642a
commit 4a87bf64f3
9 files changed
+244 -36

No files matched your search

@@ -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.
+13 -10
View File
@@ -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
@@ -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"
}
@@ -99,17 +99,26 @@ import Foundation
}
}
public var experimentId: String? { self.stringParam(Self.experimentIdParam) }
/// Set on khepri's `<id>~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
+7 -3
View File
@@ -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
}
}
@@ -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
@@ -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
@@ -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 = """
{
@@ -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"
)
)
)