Skip to content

Commit

Permalink
Add support for targetPercentile RMF rule parameter (#809)
Browse files Browse the repository at this point in the history
Required:

Task/Issue URL: https://app.asana.com/0/0/1207234800675204/f
iOS PR: duckduckgo/iOS#2824
macOS PR: duckduckgo/macos-browser#2772
What kind of version bump will this require?: Major

Description:

This PR adds support for the new targetPercentile option in RMF rules. This allows us to specify a percentage of users for whom the rule matches.
  • Loading branch information
samsymons committed May 14, 2024
1 parent c1ce2cb commit 6568d48
Show file tree
Hide file tree
Showing 11 changed files with 502 additions and 97 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -197,21 +197,29 @@ struct JsonToRemoteMessageModelMapper {
}
}

static func maps(jsonRemoteRules: [RemoteMessageResponse.JsonMatchingRule]) -> [Int: [MatchingAttribute]] {
var rules: [Int: [MatchingAttribute]] = [:]
jsonRemoteRules.forEach { rule in
var matchingAttributes: [MatchingAttribute] = []
rule.attributes.forEach { attribute in
static func maps(jsonRemoteRules: [RemoteMessageResponse.JsonMatchingRule]) -> [RemoteConfigRule] {
return jsonRemoteRules.map { jsonRule in
let mappedAttributes = jsonRule.attributes.map { attribute in
if let key = AttributesKey(rawValue: attribute.key) {
matchingAttributes.append(key.matchingAttribute(jsonMatchingAttribute: attribute.value))
return key.matchingAttribute(jsonMatchingAttribute: attribute.value)
} else {
os_log("Unknown attribute key %s", log: .remoteMessaging, type: .debug, attribute.key)
matchingAttributes.append(UnknownMatchingAttribute(jsonMatchingAttribute: attribute.value))
return UnknownMatchingAttribute(jsonMatchingAttribute: attribute.value)
}
}
rules[rule.id] = matchingAttributes

var mappedTargetPercentile: RemoteConfigTargetPercentile?

if let jsonTargetPercentile = jsonRule.targetPercentile {
mappedTargetPercentile = .init(before: jsonTargetPercentile.before)
}

return RemoteConfigRule(
id: jsonRule.id,
targetPercentile: mappedTargetPercentile,
attributes: mappedAttributes
)
}
return rules
}

static func getTranslation(from translations: [String: RemoteMessageResponse.JsonContentTranslation]?,
Expand Down
5 changes: 5 additions & 0 deletions Sources/RemoteMessaging/Model/JsonRemoteMessagingConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,13 @@ public enum RemoteMessageResponse {
let secondaryActionText: String?
}

struct JsonTargetPercentile: Decodable {
let before: Float?
}

struct JsonMatchingRule: Decodable {
let id: Int
let targetPercentile: JsonTargetPercentile?
let attributes: [String: AnyDecodable]
}

Expand Down
12 changes: 11 additions & 1 deletion Sources/RemoteMessaging/Model/RemoteConfigModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,15 @@ import Foundation

public struct RemoteConfigModel {
let messages: [RemoteMessageModel]
let rules: [Int: [MatchingAttribute]]
let rules: [RemoteConfigRule]
}

public struct RemoteConfigRule {
let id: Int
let targetPercentile: RemoteConfigTargetPercentile?
let attributes: [MatchingAttribute]
}

public struct RemoteConfigTargetPercentile {
let before: Float?
}
39 changes: 31 additions & 8 deletions Sources/RemoteMessaging/RemoteMessagingConfigMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,20 @@ public struct RemoteMessagingConfigMatcher {
private let appAttributeMatcher: AppAttributeMatcher
private let deviceAttributeMatcher: DeviceAttributeMatcher
private let userAttributeMatcher: UserAttributeMatcher
private let percentileStore: RemoteMessagingPercentileStoring
private let dismissedMessageIds: [String]

private let matchers: [AttributeMatcher]

public init(appAttributeMatcher: AppAttributeMatcher,
deviceAttributeMatcher: DeviceAttributeMatcher = DeviceAttributeMatcher(),
userAttributeMatcher: UserAttributeMatcher,
percentileStore: RemoteMessagingPercentileStoring,
dismissedMessageIds: [String]) {
self.appAttributeMatcher = appAttributeMatcher
self.deviceAttributeMatcher = deviceAttributeMatcher
self.userAttributeMatcher = userAttributeMatcher
self.percentileStore = percentileStore
self.dismissedMessageIds = dismissedMessageIds

matchers = [appAttributeMatcher, deviceAttributeMatcher, userAttributeMatcher]
Expand All @@ -49,8 +52,8 @@ public struct RemoteMessagingConfigMatcher {
return message
}

let matchingResult = evaluateMatchingRules(message.matchingRules, fromRules: rules)
let exclusionResult = evaluateExclusionRules(message.exclusionRules, fromRules: rules)
let matchingResult = evaluateMatchingRules(message.matchingRules, messageID: message.id, fromRules: rules)
let exclusionResult = evaluateExclusionRules(message.exclusionRules, messageID: message.id, fromRules: rules)

if matchingResult == .match && exclusionResult == .fail {
return message
Expand All @@ -60,16 +63,26 @@ public struct RemoteMessagingConfigMatcher {
return nil
}

func evaluateMatchingRules(_ matchingRules: [Int], fromRules rules: [Int: [MatchingAttribute]]) -> EvaluationResult {
func evaluateMatchingRules(_ matchingRules: [Int], messageID: String, fromRules rules: [RemoteConfigRule]) -> EvaluationResult {
var result: EvaluationResult = .match

for rule in matchingRules {
guard let matchingAttributes = rules[rule] else {
guard let matchingRule = rules.first(where: { $0.id == rule }) else {
return .nextMessage
}

if let percentile = matchingRule.targetPercentile, let messagePercentile = percentile.before {
let userPercentile = percentileStore.percentile(forMessageId: messageID)

if userPercentile > messagePercentile {
os_log("Matching rule percentile check failed for message with ID %s", log: .remoteMessaging, type: .debug, messageID)
return .fail
}
}

result = .match

for attribute in matchingAttributes {
for attribute in matchingRule.attributes {
result = evaluateAttribute(matchingAttribute: attribute)
if result == .fail || result == .nextMessage {
os_log("First failing matching attribute %s", log: .remoteMessaging, type: .debug, String(describing: attribute))
Expand All @@ -84,16 +97,26 @@ public struct RemoteMessagingConfigMatcher {
return result
}

func evaluateExclusionRules(_ exclusionRules: [Int], fromRules rules: [Int: [MatchingAttribute]]) -> EvaluationResult {
func evaluateExclusionRules(_ exclusionRules: [Int], messageID: String, fromRules rules: [RemoteConfigRule]) -> EvaluationResult {
var result: EvaluationResult = .fail

for rule in exclusionRules {
guard let attributes = rules[rule] else {
guard let matchingRule = rules.first(where: { $0.id == rule }) else {
return .nextMessage
}

if let percentile = matchingRule.targetPercentile, let messagePercentile = percentile.before {
let userPercentile = percentileStore.percentile(forMessageId: messageID)

if userPercentile > messagePercentile {
os_log("Exclusion rule percentile check failed for message with ID %s", log: .remoteMessaging, type: .debug, messageID)
return .fail
}
}

result = .fail

for attribute in attributes {
for attribute in matchingRule.attributes {
result = evaluateAttribute(matchingAttribute: attribute)
if result == .fail || result == .nextMessage {
os_log("First failing exclusion attribute %s", log: .remoteMessaging, type: .debug, String(describing: attribute))
Expand Down
51 changes: 51 additions & 0 deletions Sources/RemoteMessaging/RemoteMessagingPercentileStoring.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// RemoteMessagingPercentileStoring.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

public protocol RemoteMessagingPercentileStoring {
func percentile(forMessageId: String) -> Float
}

public class RemoteMessagingPercentileUserDefaultsStore: RemoteMessagingPercentileStoring {

enum Constants {
static let remoteMessagingPercentileMapping = "com.duckduckgo.app.remoteMessagingPercentileMapping"
}

private let userDefaults: UserDefaults

public init(userDefaults: UserDefaults) {
self.userDefaults = userDefaults
}

public func percentile(forMessageId messageID: String) -> Float {
var percentileMapping = (userDefaults.dictionary(forKey: Constants.remoteMessagingPercentileMapping) as? [String: Float]) ?? [:]

if let percentile = percentileMapping[messageID] {
return percentile
} else {
let newPercentile = Float.random(in: 0...1)
percentileMapping[messageID] = newPercentile
userDefaults.set(percentileMapping, forKey: Constants.remoteMessagingPercentileMapping)

return newPercentile
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -102,39 +102,49 @@ class JsonToRemoteConfigModelMapperTests: XCTestCase {

func testWhenValidJsonParsedThenRulesMappedIntoRemoteConfig() throws {
let config = try decodeAndMapJson(fileName: "Resources/remote-messaging-config.json")
XCTAssertTrue(config.rules.count == 4)
XCTAssertTrue(config.rules.count == 5)

let rule5 = config.rules.filter { $0.key == 5 }.first
let rule5 = config.rules.filter { $0.id == 5 }.first
XCTAssertNotNil(rule5)
XCTAssertTrue(rule5?.value.count == 16)
var attribs = rule5?.value.filter { $0 is LocaleMatchingAttribute }
XCTAssertNil(rule5?.targetPercentile)
XCTAssertTrue(rule5?.attributes.count == 16)
var attribs = rule5?.attributes.filter { $0 is LocaleMatchingAttribute }
XCTAssertEqual(attribs?.count, 1)
XCTAssertEqual(attribs?.first as? LocaleMatchingAttribute, LocaleMatchingAttribute(value: ["en-US", "en-GB"], fallback: true))

let rule6 = config.rules.filter { $0.key == 6 }.first
let rule6 = config.rules.filter { $0.id == 6 }.first
XCTAssertNotNil(rule6)
XCTAssertTrue(rule6?.value.count == 1)
attribs = rule6?.value.filter { $0 is LocaleMatchingAttribute }
XCTAssertNil(rule6?.targetPercentile)
XCTAssertTrue(rule6?.attributes.count == 1)
attribs = rule6?.attributes.filter { $0 is LocaleMatchingAttribute }
XCTAssertEqual(attribs?.count, 1)
XCTAssertEqual(attribs?.first as? LocaleMatchingAttribute, LocaleMatchingAttribute(value: ["en-GB"], fallback: nil))

let rule7 = config.rules.filter { $0.key == 7 }.first
let rule7 = config.rules.filter { $0.id == 7 }.first
XCTAssertNotNil(rule7)
XCTAssertTrue(rule7?.value.count == 1)
attribs = rule7?.value.filter { $0 is WidgetAddedMatchingAttribute }
XCTAssertNil(rule7?.targetPercentile)
XCTAssertTrue(rule7?.attributes.count == 1)
attribs = rule7?.attributes.filter { $0 is WidgetAddedMatchingAttribute }
XCTAssertEqual(attribs?.count, 1)
XCTAssertEqual(attribs?.first as? WidgetAddedMatchingAttribute, WidgetAddedMatchingAttribute(value: false, fallback: nil))

let rule8 = config.rules.filter { $0.key == 8 }.first
let rule8 = config.rules.filter { $0.id == 8 }.first
XCTAssertNotNil(rule8)
XCTAssertTrue(rule8?.value.count == 2)
attribs = rule8?.value.filter { $0 is DaysSinceNetPEnabledMatchingAttribute }
XCTAssertNil(rule8?.targetPercentile)
XCTAssertTrue(rule8?.attributes.count == 2)
attribs = rule8?.attributes.filter { $0 is DaysSinceNetPEnabledMatchingAttribute }
XCTAssertEqual(attribs?.count, 1)
XCTAssertEqual(attribs?.first as? DaysSinceNetPEnabledMatchingAttribute, DaysSinceNetPEnabledMatchingAttribute(min: 5, fallback: nil))

attribs = rule8?.value.filter { $0 is IsNetPWaitlistUserMatchingAttribute }
attribs = rule8?.attributes.filter { $0 is IsNetPWaitlistUserMatchingAttribute }
XCTAssertEqual(attribs?.count, 1)
XCTAssertEqual(attribs?.first as? IsNetPWaitlistUserMatchingAttribute, IsNetPWaitlistUserMatchingAttribute(value: true, fallback: nil))

let rule9 = config.rules.filter { $0.id == 9 }.first
XCTAssertNotNil(rule9)
XCTAssertNotNil(rule9?.targetPercentile)
XCTAssertTrue(rule9?.attributes.count == 1)
XCTAssertEqual(rule9?.targetPercentile?.before, 0.9)
}

func testWhenJsonMessagesHaveUnknownTypesThenMessagesNotMappedIntoConfig() throws {
Expand All @@ -147,15 +157,15 @@ class JsonToRemoteConfigModelMapperTests: XCTestCase {
let config = try decodeAndMapJson(fileName: "Resources/remote-messaging-config-unsupported-items.json")
XCTAssertTrue(config.rules.count == 2)

let rule6 = config.rules.filter { $0.key == 6 }.first
let rule6 = config.rules.filter { $0.id == 6 }.first
XCTAssertNotNil(rule6)
var attribs = rule6?.value.filter { $0 is UnknownMatchingAttribute }
var attribs = rule6?.attributes.filter { $0 is UnknownMatchingAttribute }
XCTAssertEqual(attribs?.count, 1)
XCTAssertEqual(attribs?.first as? UnknownMatchingAttribute, UnknownMatchingAttribute(fallback: true))

let rule7 = config.rules.filter { $0.key == 7 }.first
let rule7 = config.rules.filter { $0.id == 7 }.first
XCTAssertNotNil(rule7)
attribs = rule7?.value.filter { $0 is WidgetAddedMatchingAttribute }
attribs = rule7?.attributes.filter { $0 is WidgetAddedMatchingAttribute }
XCTAssertEqual(attribs?.count, 1)
XCTAssertEqual(attribs?.first as? WidgetAddedMatchingAttribute, WidgetAddedMatchingAttribute(value: true, fallback: nil))
}
Expand All @@ -167,11 +177,11 @@ class JsonToRemoteConfigModelMapperTests: XCTestCase {
let config = JsonToRemoteConfigModelMapper.mapJson(remoteMessagingConfig: remoteMessagingConfig)
XCTAssertTrue(config.rules.count == 2)

let rule6 = config.rules.filter { $0.key == 6 }.first
let rule6 = config.rules.filter { $0.id == 6 }.first
XCTAssertNotNil(rule6)
XCTAssertEqual(rule6?.value.filter { $0 is LocaleMatchingAttribute }.count, 1)
XCTAssertEqual(rule6?.value.filter { $0 is OSMatchingAttribute }.count, 1)
XCTAssertEqual(rule6?.value.filter { $0 is UnknownMatchingAttribute }.count, 1)
XCTAssertEqual(rule6?.attributes.filter { $0 is LocaleMatchingAttribute }.count, 1)
XCTAssertEqual(rule6?.attributes.filter { $0 is OSMatchingAttribute }.count, 1)
XCTAssertEqual(rule6?.attributes.filter { $0 is UnknownMatchingAttribute }.count, 1)
}

func decodeAndMapJson(fileName: String) throws -> RemoteConfigModel {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// MockRemoteMessagePercentileStore.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation
import RemoteMessaging

class MockRemoteMessagePercentileStore: RemoteMessagingPercentileStoring {

var percentileStorage: [String: Float] = [:]
var defaultPercentage: Float = 0

func percentile(forMessageId messageID: String) -> Float {
if let percentile = percentileStorage[messageID] {
return percentile
}

percentileStorage[messageID] = defaultPercentage
return defaultPercentage
}

}

0 comments on commit 6568d48

Please sign in to comment.