Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/add context to no rule match #167

Merged
merged 4 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup

## 3.3.3

### Enhancements

- When you experience `no_rule_match`, the `TriggerFire` event params will specify which part of the rules didn't match in the format `"unmatched_rule_<id>": "<outcome>"`. Where `outcome` will either be `OCCURRENCE`, referring to the limit applied to a rule, or `EXPRESSION`. The `id` is the experiment id.

### Fixes

- Fixes issue where verification was happening after the finishing of transactions when not using a `PurchaseController`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,11 +233,11 @@ enum InternalSuperwallEvent {
}

struct TriggerFire: TrackableSuperwallEvent {
let triggerResult: TriggerResult
let triggerResult: InternalTriggerResult
var superwallEvent: SuperwallEvent {
return .triggerFire(
eventName: triggerName,
result: triggerResult
result: triggerResult.toPublicType()
)
}
let triggerName: String
Expand All @@ -254,10 +254,14 @@ enum InternalSuperwallEvent {
}

switch triggerResult {
case .noRuleMatch:
return params + [
case .noRuleMatch(let unmatchedRules):
params += [
"result": "no_rule_match"
]
for unmatchedRule in unmatchedRules {
params["unmatched_rule_\(unmatchedRule.experimentId)"] = unmatchedRule.source.rawValue
}
return params
case .holdout(let experiment):
return params + [
"variant_id": experiment.variant.id as Any,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ actor TriggerSessionManager {
for presentationInfo: PresentationInfo,
on presentingViewController: UIViewController? = nil,
paywall: Paywall? = nil,
triggerResult: TriggerResult?,
triggerResult: InternalTriggerResult?,
trackEvent: (Trackable) async -> TrackingResult = Superwall.shared.track
) async {
guard let eventName = presentationInfo.eventName else {
Expand All @@ -152,7 +152,7 @@ actor TriggerSessionManager {
presentationInfo: presentationInfo,
presentingViewController: presentingViewController,
paywall: paywall,
triggerResult: triggerResult
triggerResult: triggerResult?.toPublicType()
) else {
return
}
Expand Down
5 changes: 5 additions & 0 deletions Sources/SuperwallKit/Models/Triggers/RawExperiment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ import Foundation

/// An experiment without a confirmed variant assignment.
struct RawExperiment: Decodable, Hashable {
/// The ID of the experiment
var id: String

/// The campaign ID.
var groupId: String

/// The variants associated with the experiment.
var variants: [VariantOption]
}

Expand Down
48 changes: 48 additions & 0 deletions Sources/SuperwallKit/Models/Triggers/TriggerResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,51 @@ public enum TriggerResult: Sendable, Equatable {
/// In these instances, consider falling back to a native paywall.
case error(NSError)
}

/// The result of a paywall trigger. `noRuleMatch` is an associated enum.
///
/// Triggers can conditionally show paywalls. Contains the possible cases resulting from the trigger.
enum InternalTriggerResult: Equatable {
/// This event was not found on the dashboard.
///
/// Please make sure you have added the event to a campaign on the dashboard and
/// double check its spelling.
case eventNotFound

/// No matching rule was found for this trigger so no paywall will be shown.
case noRuleMatch([UnmatchedRule])

/// A matching rule was found and this user will be shown a paywall.
///
/// - Parameters:
/// - experiment: The experiment associated with the trigger.
case paywall(Experiment)

/// A matching rule was found and this user was assigned to a holdout group so will not be shown a paywall.
///
/// - Parameters:
/// - experiment: The experiment associated with the trigger.
case holdout(Experiment)

/// An error occurred and the user will not be shown a paywall.
///
/// If the error code is `101`, it means that no view controller could be found to present on. Otherwise a network failure may have occurred.
///
/// In these instances, consider falling back to a native paywall.
case error(NSError)

func toPublicType() -> TriggerResult {
switch self {
case .eventNotFound:
return .eventNotFound
case .noRuleMatch:
return .noRuleMatch
case .paywall(let experiment):
return .paywall(experiment)
case .holdout(let experiment):
return .holdout(experiment)
case .error(let nSError):
return .error(nSError)
}
}
}
43 changes: 42 additions & 1 deletion Sources/SuperwallKit/Models/Triggers/TriggerRule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,52 @@

import Foundation

struct TriggerRuleOutcome {
struct UnmatchedRule: Equatable {
enum Source: String {
case expression = "EXPRESSION"
case occurrence = "OCCURRENCE"
}
let source: Source
let experimentId: String
}

struct MatchedItem {
let rule: TriggerRule
let unsavedOccurrence: TriggerRuleOccurrence?
}

enum TriggerRuleOutcome: Equatable {
static func == (lhs: TriggerRuleOutcome, rhs: TriggerRuleOutcome) -> Bool {
switch (lhs, rhs) {
case let (.match(item1), .match(item2)):
return item1.rule == item2.rule
&& item1.unsavedOccurrence == item2.unsavedOccurrence
case let (.noMatch(unmatchedRule1), .noMatch(unmatchedRule2)):
return unmatchedRule1.source == unmatchedRule2.source
&& unmatchedRule1.experimentId == unmatchedRule2.experimentId
default:
return false
}
}

case noMatch(UnmatchedRule)
case match(MatchedItem)

static func noMatch(
source: UnmatchedRule.Source,
experimentId: String
) -> TriggerRuleOutcome {
return .noMatch(.init(source: source, experimentId: experimentId))
}

static func match(
rule: TriggerRule,
unsavedOccurrence: TriggerRuleOccurrence? = nil
) -> TriggerRuleOutcome {
return .match(.init(rule: rule, unsavedOccurrence: unsavedOccurrence))
}
}

struct TriggerRule: Decodable, Hashable {
var experiment: RawExperiment
var expression: String?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Foundation

enum GetPresentationResultLogic {
/// Converts a ``TriggerResult`` to a ``PresentationResult``
static func convertTriggerResult(_ triggerResult: TriggerResult) -> PresentationResult {
static func convertTriggerResult(_ triggerResult: InternalTriggerResult) -> PresentationResult {
switch triggerResult {
case .eventNotFound:
return .eventNotFound
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ extension Superwall {
/// - paywallStatePublisher: A `PassthroughSubject` that gets sent ``PaywallState`` objects.
func checkUserSubscription(
request: PresentationRequest,
triggerResult: TriggerResult,
triggerResult: InternalTriggerResult,
paywallStatePublisher: PassthroughSubject<PaywallState, Never>?
) async throws {
switch triggerResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ extension Superwall {
private func activateSession(
for request: PresentationRequest,
paywall: Paywall,
triggerResult: TriggerResult
triggerResult: InternalTriggerResult
) async {
let sessionEventsManager = dependencyContainer.sessionEventsManager
await sessionEventsManager?.triggerSession.activateSession(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ struct ExpressionEvaluator {
private let storage: Storage
private unowned let factory: RuleAttributesFactory

struct TriggerFireOutcome {
let shouldFire: Bool
var unsavedOccurrence: TriggerRuleOccurrence?
}

init(
storage: Storage,
factory: RuleAttributesFactory
Expand All @@ -28,18 +23,18 @@ struct ExpressionEvaluator {
func evaluateExpression(
fromRule rule: TriggerRule,
eventData: EventData
) async -> TriggerFireOutcome {
) async -> TriggerRuleOutcome {
// Expression matches all
if rule.expressionJs == nil && rule.expression == nil {
let shouldFire = await shouldFire(
forOccurrence: rule.occurrence,
ruleMatched: true
let ruleMatched = await tryToMatchOccurrence(
from: rule,
expressionMatched: true
)
return shouldFire
return ruleMatched
}

guard let jsCtx = JSContext() else {
return TriggerFireOutcome(shouldFire: false)
return .noMatch(source: .expression, experimentId: rule.experiment.id)
}
jsCtx.exceptionHandler = { (_, value: JSValue?) in
guard let value = value else {
Expand All @@ -62,30 +57,30 @@ struct ExpressionEvaluator {
)
}

guard let postfix = await getPostfix(
forRule: rule,
guard let base64Params = await getBase64Params(
from: rule,
withEventData: eventData
) else {
return TriggerFireOutcome(shouldFire: false)
return .noMatch(source: .expression, experimentId: rule.experiment.id)
}

let result = jsCtx.evaluateScript(script + "\n " + postfix)
let result = jsCtx.evaluateScript(script + "\n " + base64Params)
if result?.isString == nil {
return TriggerFireOutcome(shouldFire: false)
return .noMatch(source: .expression, experimentId: rule.experiment.id)
}

let isMatched = result?.toString() == "true"
let expressionMatched = result?.toString() == "true"

let shouldFire = await shouldFire(
forOccurrence: rule.occurrence,
ruleMatched: isMatched
let ruleMatched = await tryToMatchOccurrence(
from: rule,
expressionMatched: expressionMatched
)

return shouldFire
return ruleMatched
}

private func getPostfix(
forRule rule: TriggerRule,
private func getBase64Params(
from rule: TriggerRule,
withEventData eventData: EventData
) async -> String? {
let attributes = await factory.makeRuleAttributes(
Expand Down Expand Up @@ -116,19 +111,19 @@ struct ExpressionEvaluator {
return nil
}

func shouldFire(
forOccurrence occurrence: TriggerRuleOccurrence?,
ruleMatched: Bool
) async -> TriggerFireOutcome {
if ruleMatched {
guard let occurrence = occurrence else {
func tryToMatchOccurrence(
from rule: TriggerRule,
expressionMatched: Bool
) async -> TriggerRuleOutcome {
if expressionMatched {
guard let occurrence = rule.occurrence else {
Logger.debug(
logLevel: .debug,
scope: .paywallPresentation,
message: "No occurrence parameter found for trigger rule."
)

return TriggerFireOutcome(shouldFire: true)
return .match(rule: rule)
}

let count = await storage
Expand All @@ -142,14 +137,12 @@ struct ExpressionEvaluator {

if shouldFire {
unsavedOccurrence = occurrence
return .match(rule: rule, unsavedOccurrence: unsavedOccurrence)
} else {
return .noMatch(source: .occurrence, experimentId: rule.experiment.id)
}

return TriggerFireOutcome(
shouldFire: shouldFire,
unsavedOccurrence: unsavedOccurrence
)
}

return TriggerFireOutcome(shouldFire: false)
return .noMatch(source: .expression, experimentId: rule.experiment.id)
}
}