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

Add support for targetPercentile RMF rule parameter #809

Merged
merged 13 commits into from
May 14, 2024
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]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is technically less efficient than the old implementation, but a bit simpler. We have so few rules at one time that the difference here is negligible, but let me know if you feel otherwise.

}

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

public struct RemoteConfigTargetPercentile {
let before: Float?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the percentile that will be sampled before we apply the attributes. The alternative here is that we do the sampling after matching attributes, but it was agreed that we'll only support the before case for now.

}
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
37 changes: 37 additions & 0 deletions Sources/RemoteMessaging/RemoteMessagingPercentileStoring.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// 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 {
samsymons marked this conversation as resolved.
Show resolved Hide resolved

private let userDefaults: UserDefaults

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

public func percentile(forMessageId: String) -> Float {
return 0.95
}

}
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,37 @@
//

Check failure on line 1 in Tests/BrowserServicesKitTests/RemoteMessaging/Mocks/MockRemoteMessagePercentileStore.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

Header comments should be consistent with project patterns (file_header)
// MockRemoteMessagePercentileStore.swift
// DuckDuckGo
//
// 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
}

}
Loading
Loading