diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
index 7aeef5267..bcddddeb8 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme
@@ -553,6 +553,20 @@
ReferencedContainer = "container:">
+
+
+
+
+
+
+
+
) -> Double
private let queue = DispatchQueue(label: "com.ExperimentCohortsManager.queue")
+ private let fireCohortAssigned: (_ subfeatureID: SubfeatureID, _ experiment: ExperimentData) -> Void
public var experiments: Experiments? {
get {
@@ -81,9 +82,11 @@ public class ExperimentCohortsManager: ExperimentCohortsManaging {
}
}
- public init(store: ExperimentsDataStoring = ExperimentsDataStore(), randomizer: @escaping (Range) -> Double = Double.random(in:)) {
+ public init(store: ExperimentsDataStoring = ExperimentsDataStore(), randomizer: @escaping (Range) -> Double = Double.random(in:),
+ fireCohortAssigned: @escaping (_ subfeatureID: SubfeatureID, _ experiment: ExperimentData) -> Void) {
self.store = store
self.randomizer = randomizer
+ self.fireCohortAssigned = fireCohortAssigned
}
public func resolveCohort(for experiment: ExperimentSubfeature, allowCohortReassignment: Bool) -> CohortID? {
@@ -113,6 +116,7 @@ extension ExperimentCohortsManager {
cumulativeWeight += Double(cohort.weight)
if randomValue < cumulativeWeight {
saveCohort(cohort.name, in: subfeature.subfeatureID, parentID: subfeature.parentID)
+ fireCohortAssigned(subfeature.subfeatureID, ExperimentData(parentID: subfeature.parentID, cohortID: cohort.name, enrollmentDate: Date()))
return cohort.name
}
}
diff --git a/Sources/PixelExperimentKit/ExperimentEventTracker.swift b/Sources/PixelExperimentKit/ExperimentEventTracker.swift
new file mode 100644
index 000000000..e38f39702
--- /dev/null
+++ b/Sources/PixelExperimentKit/ExperimentEventTracker.swift
@@ -0,0 +1,80 @@
+//
+// ExperimentEventTracker.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 typealias ThresholdCheckResult = Bool
+public typealias ExprimentPixelNameAndParameters = String
+public typealias NumberOfActions = Int
+
+public protocol ExperimentActionPixelStore {
+ func removeObject(forKey defaultName: String)
+ func integer(forKey defaultName: String) -> Int
+ func set(_ value: Int, forKey defaultName: String)
+ }
+
+public protocol ExperimentEventTracking {
+ /// Increments the count for a given event key and checks if the threshold has been exceeded.
+ ///
+ /// This method performs the following actions:
+ /// 1. If the `isInWindow` parameter is `false`, it removes the stored count for the key and returns `false`.
+ /// 2. If `isInWindow` is `true`, it increments the count for the key.
+ /// 3. If the updated count meets or exceeds the specified `threshold`, the stored count is removed, and the method returns `true`.
+ /// 4. If the updated count does not meet the threshold, it updates the count and returns `false`.
+ ///
+ /// - Parameters:
+ /// - key: The key used to store and retrieve the count.
+ /// - threshold: The count threshold that triggers a return of `true`.
+ /// - isInWindow: A flag indicating if the count should be considered (e.g., within a time window).
+ /// - Returns: `true` if the threshold is exceeded and the count is reset, otherwise `false`.
+ func incrementAndCheckThreshold(forKey key: ExprimentPixelNameAndParameters, threshold: NumberOfActions, isInWindow: Bool) -> ThresholdCheckResult
+}
+
+public struct ExperimentEventTracker: ExperimentEventTracking {
+ private let store: ExperimentActionPixelStore
+ private let syncQueue = DispatchQueue(label: "com.pixelkit.experimentActionSyncQueue")
+
+ public init(store: ExperimentActionPixelStore = UserDefaults.standard) {
+ self.store = store
+ }
+
+ public func incrementAndCheckThreshold(forKey key: ExprimentPixelNameAndParameters, threshold: NumberOfActions, isInWindow: Bool) -> ThresholdCheckResult {
+ syncQueue.sync {
+ // Remove the key if is not in window
+ guard isInWindow else {
+ store.removeObject(forKey: key)
+ return false
+ }
+
+ // Increment the current count
+ let currentCount = store.integer(forKey: key)
+ let newCount = currentCount + 1
+ store.set(newCount, forKey: key)
+
+ // Check if the threshold is exceeded
+ if newCount >= threshold {
+ store.removeObject(forKey: key)
+ return true
+ }
+ return false
+ }
+ }
+
+}
+
+extension UserDefaults: ExperimentActionPixelStore {}
diff --git a/Sources/PixelExperimentKit/PixelExperimentKit.swift b/Sources/PixelExperimentKit/PixelExperimentKit.swift
new file mode 100644
index 000000000..d0962f791
--- /dev/null
+++ b/Sources/PixelExperimentKit/PixelExperimentKit.swift
@@ -0,0 +1,249 @@
+//
+// PixelExperimentKit.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 PixelKit
+import BrowserServicesKit
+import Foundation
+
+public typealias ConversionWindow = ClosedRange
+
+struct ExperimentEvent: PixelKitEvent {
+ var name: String
+ var parameters: [String: String]?
+}
+
+extension PixelKit {
+
+ struct Constants {
+ static let enrollmentEventPrefix = "experiment_enroll"
+ static let metricsEventPrefix = "experiment_metrics"
+ static let metricKey = "metric"
+ static let conversionWindowDaysKey = "conversionWindowDays"
+ static let valueKey = "value"
+ static let enrollmentDateKey = "enrollmentDate"
+ static let searchMetricValue = "search"
+ static let appUseMetricValue = "app_use"
+ }
+
+ // Static property to hold shared dependencies
+ struct ExperimentConfig {
+ static var featureFlagger: FeatureFlagger?
+ static var eventTracker: ExperimentEventTracking = ExperimentEventTracker()
+ static var fireFunction: (PixelKitEvent, PixelKit.Frequency, Bool) -> Void = { event, frequency, includeAppVersion in
+ fire(event, frequency: frequency, includeAppVersionParameter: includeAppVersion)
+ }
+ }
+
+ // Setup method to initialize dependencies
+ public static func configureExperimentKit(
+ featureFlagger: FeatureFlagger,
+ eventTracker: ExperimentEventTracking = ExperimentEventTracker(),
+ fire: @escaping (PixelKitEvent, PixelKit.Frequency, Bool) -> Void = { event, frequency, includeAppVersion in
+ fire(event, frequency: frequency, includeAppVersionParameter: includeAppVersion)
+ }
+ ) {
+ ExperimentConfig.featureFlagger = featureFlagger
+ ExperimentConfig.eventTracker = eventTracker
+ ExperimentConfig.fireFunction = fire
+ }
+
+ /// Fires a pixel indicating the user's enrollment in an experiment.
+ /// - Parameters:
+ /// - subfeatureID: Identifier for the subfeature associated with the experiment.
+ /// - experiment: Data about the experiment like cohort and enrollment date
+ public static func fireExperimentEnrollmentPixel(subfeatureID: SubfeatureID, experiment: ExperimentData) {
+ let eventName = "\(Constants.enrollmentEventPrefix)_\(subfeatureID)_\(experiment.cohortID)"
+ let event = ExperimentEvent(name: eventName, parameters: [Constants.enrollmentDateKey: experiment.enrollmentDate.toYYYYMMDDInET()])
+ ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false)
+ }
+
+ /// Fires a pixel for a specific action in an experiment, based on conversion window and value thresholds (if value is a number).
+ /// - Parameters:
+ /// - subfeatureID: Identifier for the subfeature associated with the experiment.
+ /// - metric: The name of the metric being tracked (e.g., "searches").
+ /// - conversionWindowDays: The range of days after enrollment during which the action is valid.
+ /// - value: A specific value associated to the action. It could be the target number of actions required to fire the pixel.
+ ///
+ /// This function:
+ /// 1. Validates if the experiment is active.
+ /// 2. Ensures the user is within the specified conversion window.
+ /// 3. Tracks actions performed and sends the pixel once the target value is reached (if applicable).
+ public static func fireExperimentPixel(for subfeatureID: SubfeatureID,
+ metric: String,
+ conversionWindowDays: ConversionWindow,
+ value: String) {
+ // Check is active experiment for user
+ guard let featureFlagger = ExperimentConfig.featureFlagger else {
+ assertionFailure("PixelKit is not configured for experiments")
+ return
+ }
+ guard let experimentData = featureFlagger.getAllActiveExperiments()[subfeatureID] else { return }
+
+ fireExperimentPixelForActiveExperiment(subfeatureID,
+ experimentData: experimentData,
+ metric: metric,
+ conversionWindowDays: conversionWindowDays,
+ value: value)
+ }
+
+ /// Fires search-related experiment pixels for all active experiments.
+ ///
+ /// This function iterates through all active experiments and triggers
+ /// pixel firing based on predefined search-related value and conversion window mappings.
+ /// - The value and conversion windows define when and how many search actions
+ /// must occur before the pixel is fired.
+ public static func fireSearchExperimentPixels() {
+ let valueConversionDictionary: [NumberOfActions: [ConversionWindow]] = [
+ 1: [0...0, 1...1, 2...2, 3...3, 4...4, 5...5, 6...6, 7...7, 5...7],
+ 4: [5...7, 8...15],
+ 6: [5...7, 8...15],
+ 11: [5...7, 8...15],
+ 21: [5...7, 8...15],
+ 30: [5...7, 8...15]
+ ]
+ guard let featureFlagger = ExperimentConfig.featureFlagger else {
+ assertionFailure("PixelKit is not configured for experiments")
+ return
+ }
+ featureFlagger.getAllActiveExperiments().forEach { experiment in
+ fireExperimentPixels(for:
+ experiment.key,
+ experimentData: experiment.value,
+ metric: Constants.searchMetricValue,
+ valueConversionDictionary: valueConversionDictionary
+ )
+ }
+ }
+
+ /// Fires app retention-related experiment pixels for all active experiments.
+ ///
+ /// This function iterates through all active experiments and triggers
+ /// pixel firing based on predefined app retention value and conversion window mappings.
+ /// - The value and conversion windows define when and how many app usage actions
+ /// must occur before the pixel is fired.
+ public static func fireAppRetentionExperimentPixels() {
+ let valueConversionDictionary: [NumberOfActions: [ConversionWindow]] = [
+ 1: [1...1, 2...2, 3...3, 4...4, 5...5, 6...6, 7...7, 5...7],
+ 4: [5...7, 8...15],
+ 6: [5...7, 8...15],
+ 11: [5...7, 8...15],
+ 21: [5...7, 8...15],
+ 30: [5...7, 8...15]
+ ]
+ guard let featureFlagger = ExperimentConfig.featureFlagger else {
+ assertionFailure("PixelKit is not configured for experiments")
+ return
+ }
+ featureFlagger.getAllActiveExperiments().forEach { experiment in
+ fireExperimentPixels(
+ for: experiment.key,
+ experimentData: experiment.value,
+ metric: Constants.appUseMetricValue,
+ valueConversionDictionary: valueConversionDictionary
+ )
+ }
+ }
+
+ private static func fireExperimentPixels(
+ for experiment: SubfeatureID,
+ experimentData: ExperimentData,
+ metric: String,
+ valueConversionDictionary: [NumberOfActions: [ConversionWindow]]
+ ) {
+ valueConversionDictionary.forEach { value, ranges in
+ ranges.forEach { range in
+ fireExperimentPixelForActiveExperiment(
+ experiment,
+ experimentData: experimentData,
+ metric: metric,
+ conversionWindowDays: range,
+ value: "\(value)"
+ )
+ }
+ }
+ }
+
+ private static func fireExperimentPixelForActiveExperiment(_ subfeatureID: SubfeatureID,
+ experimentData: ExperimentData,
+ metric: String,
+ conversionWindowDays: ConversionWindow,
+ value: String) {
+ // Set parameters, event name, store key
+ let eventName = "\(Constants.metricsEventPrefix)_\(subfeatureID)_\(experimentData.cohortID)"
+ let conversionWindowValue = (conversionWindowDays.lowerBound != conversionWindowDays.upperBound) ?
+ "\(conversionWindowDays.lowerBound)-\(conversionWindowDays.upperBound)" :
+ "\(conversionWindowDays.lowerBound)"
+ let parameters: [String: String] = [
+ Constants.metricKey: metric,
+ Constants.conversionWindowDaysKey: conversionWindowValue,
+ Constants.valueKey: value,
+ Constants.enrollmentDateKey: experimentData.enrollmentDate.toYYYYMMDDInET()
+ ]
+ let event = ExperimentEvent(name: eventName, parameters: parameters)
+ let eventStoreKey = "\(eventName)_\(parameters.toString())"
+
+ // Determine if the user is within the conversion window
+ let isInWindow = isUserInConversionWindow(conversionWindowDays, enrollmentDate: experimentData.enrollmentDate)
+
+ // Check if value is a number
+ if let numberOfAction = NumberOfActions(value), numberOfAction > 1 {
+ // Increment or remove based on conversion window status
+ let shouldSendPixel = ExperimentConfig.eventTracker.incrementAndCheckThreshold(
+ forKey: eventStoreKey,
+ threshold: numberOfAction,
+ isInWindow: isInWindow
+ )
+
+ // Send the pixel only if conditions are met
+ if shouldSendPixel {
+ ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false)
+ }
+ } else if isInWindow {
+ // If value is not a number, send the pixel only if within the window
+ ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false)
+ }
+ }
+
+ private static func isUserInConversionWindow(
+ _ conversionWindowRange: ConversionWindow,
+ enrollmentDate: Date
+ ) -> Bool {
+ let calendar = Calendar.current
+ guard let startOfWindow = enrollmentDate.addDays(conversionWindowRange.lowerBound),
+ let endOfWindow = enrollmentDate.addDays(conversionWindowRange.upperBound) else {
+ return false
+ }
+
+ let currentDate = calendar.startOfDay(for: Date())
+ return currentDate >= calendar.startOfDay(for: startOfWindow) &&
+ currentDate <= calendar.startOfDay(for: endOfWindow)
+ }
+}
+
+extension Date {
+ public func toYYYYMMDDInET() -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd"
+ formatter.timeZone = TimeZone(identifier: "America/New_York")
+ return formatter.string(from: self)
+ }
+
+ func addDays(_ days: Int) -> Date? {
+ Calendar.current.date(byAdding: .day, value: days, to: self)
+ }
+}
diff --git a/Sources/PixelKit/Extensions/DictionaryExtension.swift b/Sources/PixelKit/Extensions/DictionaryExtension.swift
new file mode 100644
index 000000000..a905a9854
--- /dev/null
+++ b/Sources/PixelKit/Extensions/DictionaryExtension.swift
@@ -0,0 +1,25 @@
+//
+// DictionaryExtension.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.
+//
+
+public extension Dictionary where Key: Comparable {
+ func toString(pairSeparator: String = ":", entrySeparator: String = ",") -> String {
+ sorted(by: { $0.key < $1.key })
+ .map { "\($0.key)\(pairSeparator)\($0.value)" }
+ .joined(separator: entrySeparator)
+ }
+}
diff --git a/Sources/PixelKit/PixelKit.swift b/Sources/PixelKit/PixelKit.swift
index 4f01a5647..2e719dd26 100644
--- a/Sources/PixelKit/PixelKit.swift
+++ b/Sources/PixelKit/PixelKit.swift
@@ -31,9 +31,12 @@ public final class PixelKit {
/// [Legacy] Used in Pixel.fire(...) as .unique but without the `_u` requirement in the name
case legacyInitial
- /// Sent only once ever. The timestamp for this pixel is stored.
+ /// Sent only once ever (based on pixel name only.) The timestamp for this pixel is stored.
/// Note: This is the only pixel that MUST end with `_u`, Name for pixels of this type must end with if it doesn't an assertion is fired.
- case unique
+ case uniqueByName
+
+ /// Sent only once ever (based on pixel name AND parameters). The timestamp for this pixel is stored.
+ case uniqueByNameAndParameters
/// [Legacy] Used in Pixel.fire(...) as .daily but without the `_d` automatically added to the name
case legacyDaily
@@ -57,7 +60,7 @@ public final class PixelKit {
"Standard"
case .legacyInitial:
"Legacy Initial"
- case .unique:
+ case .uniqueByName:
"Unique"
case .legacyDaily:
"Legacy Daily"
@@ -67,6 +70,8 @@ public final class PixelKit {
"Legacy Daily and Count"
case .dailyAndCount:
"Daily and Count"
+ case .uniqueByNameAndParameters:
+ "Unique By Name And Parameters"
}
}
}
@@ -190,81 +195,165 @@ public final class PixelKit {
var headers = headers ?? defaultHeaders
headers[Header.moreInfo] = "See " + Self.duckDuckGoMorePrivacyInfo.absoluteString
- headers[Header.client] = "macOS"
+ // Needs to be updated/generalised when fully adopted by iOS
+ if let source {
+ switch source {
+ case Source.iOS.rawValue:
+ headers[Header.client] = "iOS"
+ case Source.iPadOS.rawValue:
+ headers[Header.client] = "iPadOS"
+ case Source.macDMG.rawValue, Source.macStore.rawValue:
+ headers[Header.client] = "macOS"
+ default:
+ headers[Header.client] = "macOS"
+ }
+ }
// The event name can't contain `.`
reportErrorIf(pixel: pixelName, contains: ".")
switch frequency {
case .standard:
- reportErrorIf(pixel: pixelName, endsWith: "_u")
- reportErrorIf(pixel: pixelName, endsWith: "_d")
- fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete)
+ handleStandardFrequency(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete)
case .legacyInitial:
- reportErrorIf(pixel: pixelName, endsWith: "_u")
- reportErrorIf(pixel: pixelName, endsWith: "_d")
- if !pixelHasBeenFiredEver(pixelName) {
- fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete)
- updatePixelLastFireDate(pixelName: pixelName)
- } else {
- printDebugInfo(pixelName: pixelName, frequency: frequency, parameters: newParams, skipped: true)
- }
- case .unique:
- reportErrorIf(pixel: pixelName, endsWith: "_d")
- guard pixelName.hasSuffix("_u") else {
- assertionFailure("Unique pixel: must end with _u")
- onComplete(false, nil)
- return
- }
- if !pixelHasBeenFiredEver(pixelName) {
- fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete)
- updatePixelLastFireDate(pixelName: pixelName)
- } else {
- printDebugInfo(pixelName: pixelName, frequency: frequency, parameters: newParams, skipped: true)
- }
+ handleLegacyInitial(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete)
+ case .uniqueByName:
+ handleUnique(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete)
+ case .uniqueByNameAndParameters:
+ handleUniqueByNameAndParameters(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete)
case .legacyDaily:
- reportErrorIf(pixel: pixelName, endsWith: "_u")
- reportErrorIf(pixel: pixelName, endsWith: "_d")
- if !pixelHasBeenFiredToday(pixelName) {
- fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete)
- updatePixelLastFireDate(pixelName: pixelName)
- } else {
- printDebugInfo(pixelName: pixelName, frequency: frequency, parameters: newParams, skipped: true)
- }
+ handleLegacyDaily(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete)
case .daily:
- reportErrorIf(pixel: pixelName, endsWith: "_u")
- reportErrorIf(pixel: pixelName, endsWith: "_d") // Because is added automatically
- if !pixelHasBeenFiredToday(pixelName) {
- fireRequestWrapper(pixelName + "_d", headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete)
- updatePixelLastFireDate(pixelName: pixelName)
- } else {
- printDebugInfo(pixelName: pixelName + "_d", frequency: frequency, parameters: newParams, skipped: true)
- }
+ handleDaily(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete)
case .legacyDailyAndCount:
- reportErrorIf(pixel: pixelName, endsWith: "_u")
- reportErrorIf(pixel: pixelName, endsWith: "_d") // Because is added automatically
- reportErrorIf(pixel: pixelName, endsWith: "_c") // Because is added automatically
- if !pixelHasBeenFiredToday(pixelName) {
- fireRequestWrapper(pixelName + "_d", headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete)
- updatePixelLastFireDate(pixelName: pixelName)
- } else {
- printDebugInfo(pixelName: pixelName + "_d", frequency: frequency, parameters: newParams, skipped: true)
- }
-
- fireRequestWrapper(pixelName + "_c", headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete)
+ handleLegacyDailyAndCount(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete)
case .dailyAndCount:
- reportErrorIf(pixel: pixelName, endsWith: "_u")
- reportErrorIf(pixel: pixelName, endsWith: "_daily") // Because is added automatically
- reportErrorIf(pixel: pixelName, endsWith: "_count") // Because is added automatically
- if !pixelHasBeenFiredToday(pixelName) {
- fireRequestWrapper(pixelName + "_daily", headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete)
- updatePixelLastFireDate(pixelName: pixelName)
- } else {
- printDebugInfo(pixelName: pixelName + "_daily", frequency: frequency, parameters: newParams, skipped: true)
- }
+ handleDailyAndCount(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete)
+ }
+ }
+
+ private func handleStandardFrequency(_ pixelName: String,
+ _ headers: [String: String],
+ _ params: [String: String],
+ _ allowedQueryReservedCharacters: CharacterSet?,
+ _ onComplete: @escaping CompletionBlock) {
+ reportErrorIf(pixel: pixelName, endsWith: "_u")
+ reportErrorIf(pixel: pixelName, endsWith: "_d")
+ fireRequestWrapper(pixelName, headers, params, allowedQueryReservedCharacters, true, .standard, onComplete)
+ }
+
+ private func handleLegacyInitial(_ pixelName: String,
+ _ headers: [String: String],
+ _ newParams: [String: String],
+ _ allowedQueryReservedCharacters: CharacterSet?,
+ _ onComplete: @escaping CompletionBlock) {
+ reportErrorIf(pixel: pixelName, endsWith: "_u")
+ reportErrorIf(pixel: pixelName, endsWith: "_d")
+ if !pixelHasBeenFiredEver(pixelName) {
+ fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, .legacyInitial, onComplete)
+ updatePixelLastFireDate(pixelName: pixelName)
+ } else {
+ printDebugInfo(pixelName: pixelName, frequency: .legacyInitial, parameters: newParams, skipped: true)
+ }
+ }
+
+ private func handleUnique(_ pixelName: String,
+ _ headers: [String: String],
+ _ newParams: [String: String],
+ _ allowedQueryReservedCharacters: CharacterSet?,
+ _ onComplete: @escaping CompletionBlock) {
+ reportErrorIf(pixel: pixelName, endsWith: "_d")
+ guard pixelName.hasSuffix("_u") else {
+ assertionFailure("Unique pixel: must end with _u")
+ onComplete(false, nil)
+ return
+ }
+ if !pixelHasBeenFiredEver(pixelName) {
+ fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, .uniqueByName, onComplete)
+ updatePixelLastFireDate(pixelName: pixelName)
+ } else {
+ printDebugInfo(pixelName: pixelName, frequency: .uniqueByName, parameters: newParams, skipped: true)
+ }
+ }
+
+ private func handleUniqueByNameAndParameters(_ pixelName: String,
+ _ headers: [String: String],
+ _ newParams: [String: String],
+ _ allowedQueryReservedCharacters: CharacterSet?,
+ _ onComplete: @escaping CompletionBlock) {
+ let pixelNameAndParams = pixelName + newParams.toString()
+ if !pixelHasBeenFiredEver(pixelNameAndParams) {
+ fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, .uniqueByNameAndParameters, onComplete)
+ updatePixelLastFireDate(pixelName: pixelNameAndParams)
+ } else {
+ printDebugInfo(pixelName: pixelName, frequency: .uniqueByNameAndParameters, parameters: newParams, skipped: true)
+ }
+ }
+
+ private func handleLegacyDaily(_ pixelName: String,
+ _ headers: [String: String],
+ _ newParams: [String: String],
+ _ allowedQueryReservedCharacters: CharacterSet?,
+ _ onComplete: @escaping CompletionBlock) {
+ reportErrorIf(pixel: pixelName, endsWith: "_u")
+ reportErrorIf(pixel: pixelName, endsWith: "_d")
+ if !pixelHasBeenFiredToday(pixelName) {
+ fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, .legacyDaily, onComplete)
+ updatePixelLastFireDate(pixelName: pixelName)
+ } else {
+ printDebugInfo(pixelName: pixelName, frequency: .legacyDaily, parameters: newParams, skipped: true)
+ }
+ }
+
+ private func handleDaily(_ pixelName: String,
+ _ headers: [String: String],
+ _ newParams: [String: String],
+ _ allowedQueryReservedCharacters: CharacterSet?,
+ _ onComplete: @escaping CompletionBlock) {
+ reportErrorIf(pixel: pixelName, endsWith: "_u")
+ reportErrorIf(pixel: pixelName, endsWith: "_d") // Because is added automatically
+ if !pixelHasBeenFiredToday(pixelName) {
+ fireRequestWrapper(pixelName + "_d", headers, newParams, allowedQueryReservedCharacters, true, .daily, onComplete)
+ updatePixelLastFireDate(pixelName: pixelName)
+ } else {
+ printDebugInfo(pixelName: pixelName + "_d", frequency: .daily, parameters: newParams, skipped: true)
+ }
+ }
+
+ private func handleLegacyDailyAndCount(_ pixelName: String,
+ _ headers: [String: String],
+ _ newParams: [String: String],
+ _ allowedQueryReservedCharacters: CharacterSet?,
+ _ onComplete: @escaping CompletionBlock) {
+ reportErrorIf(pixel: pixelName, endsWith: "_u")
+ reportErrorIf(pixel: pixelName, endsWith: "_d") // Because is added automatically
+ reportErrorIf(pixel: pixelName, endsWith: "_c") // Because is added automatically
+ if !pixelHasBeenFiredToday(pixelName) {
+ fireRequestWrapper(pixelName + "_d", headers, newParams, allowedQueryReservedCharacters, true, .legacyDailyAndCount, onComplete)
+ updatePixelLastFireDate(pixelName: pixelName)
+ } else {
+ printDebugInfo(pixelName: pixelName + "_d", frequency: .legacyDailyAndCount, parameters: newParams, skipped: true)
+ }
- fireRequestWrapper(pixelName + "_count", headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete)
+ fireRequestWrapper(pixelName + "_c", headers, newParams, allowedQueryReservedCharacters, true, .legacyDailyAndCount, onComplete)
+ }
+
+ private func handleDailyAndCount(_ pixelName: String,
+ _ headers: [String: String],
+ _ newParams: [String: String],
+ _ allowedQueryReservedCharacters: CharacterSet?,
+ _ onComplete: @escaping CompletionBlock) {
+ reportErrorIf(pixel: pixelName, endsWith: "_u")
+ reportErrorIf(pixel: pixelName, endsWith: "_daily") // Because is added automatically
+ reportErrorIf(pixel: pixelName, endsWith: "_count") // Because is added automatically
+ if !pixelHasBeenFiredToday(pixelName) {
+ fireRequestWrapper(pixelName + "_daily", headers, newParams, allowedQueryReservedCharacters, true, .dailyAndCount, onComplete)
+ updatePixelLastFireDate(pixelName: pixelName)
+ } else {
+ printDebugInfo(pixelName: pixelName + "_daily", frequency: .dailyAndCount, parameters: newParams, skipped: true)
}
+
+ fireRequestWrapper(pixelName + "_count", headers, newParams, allowedQueryReservedCharacters, true, .dailyAndCount, onComplete)
}
/// If the pixel name ends with the forbiddenString then an error is logged or an assertion failure is fired in debug
@@ -307,7 +396,11 @@ public final class PixelKit {
fireRequest(pixelName, headers, parameters, allowedQueryReservedCharacters, callBackOnMainThread, onComplete)
}
- private func prefixedName(for event: Event) -> String {
+ // Only set up for macOS and for Experiments
+ private func prefixedAndSuffixedName(for event: Event) -> String {
+ if event.name.hasPrefix("experiment") {
+ return addPlatformSuffix(to: event.name)
+ }
if event.name.hasPrefix("m_mac_") {
// Can be a debug event or not, if already prefixed the name remains unchanged
return event.name
@@ -322,6 +415,22 @@ public final class PixelKit {
}
}
+ private func addPlatformSuffix(to name: String) -> String {
+ if let source {
+ switch source {
+ case Source.iOS.rawValue:
+ return "\(name)_ios_phone"
+ case Source.iPadOS.rawValue:
+ return "\(name)_ios_tablet"
+ case Source.macStore.rawValue, Source.macDMG.rawValue:
+ return "\(name)_mac"
+ default:
+ return name
+ }
+ }
+ return name
+ }
+
public func fire(_ event: Event,
frequency: Frequency = .standard,
withHeaders headers: [String: String]? = nil,
@@ -331,13 +440,13 @@ public final class PixelKit {
includeAppVersionParameter: Bool = true,
onComplete: @escaping CompletionBlock = { _, _ in }) {
- let pixelName = prefixedName(for: event)
+ let pixelName = prefixedAndSuffixedName(for: event)
if !dryRun {
if frequency == .daily, pixelHasBeenFiredToday(pixelName) {
onComplete(false, nil)
return
- } else if frequency == .unique, pixelHasBeenFiredEver(pixelName) {
+ } else if frequency == .uniqueByName, pixelHasBeenFiredEver(pixelName) {
onComplete(false, nil)
return
}
@@ -355,6 +464,14 @@ public final class PixelKit {
newParams = nil
}
+ if !dryRun, let newParams {
+ let pixelNameAndParams = pixelName + newParams.toString()
+ if frequency == .uniqueByNameAndParameters, pixelHasBeenFiredEver(pixelNameAndParams) {
+ onComplete(false, nil)
+ return
+ }
+ }
+
let newError: Error?
if let event = event as? PixelKitEventV2,
@@ -430,7 +547,7 @@ public final class PixelKit {
}
public func pixelLastFireDate(event: Event) -> Date? {
- pixelLastFireDate(pixelName: prefixedName(for: event))
+ pixelLastFireDate(pixelName: prefixedAndSuffixedName(for: event))
}
private func updatePixelLastFireDate(pixelName: String) {
diff --git a/Sources/PixelKitTestingUtilities/XCTestCase+PixelKit.swift b/Sources/PixelKitTestingUtilities/XCTestCase+PixelKit.swift
index 371bd3c42..8ae6efb40 100644
--- a/Sources/PixelKitTestingUtilities/XCTestCase+PixelKit.swift
+++ b/Sources/PixelKitTestingUtilities/XCTestCase+PixelKit.swift
@@ -149,7 +149,7 @@ public extension XCTestCase {
expectedPixelNames.append(originalName)
case .legacyInitial:
expectedPixelNames.append(originalName)
- case .unique:
+ case .uniqueByName:
expectedPixelNames.append(originalName)
case .legacyDaily:
expectedPixelNames.append(originalName)
@@ -161,6 +161,8 @@ public extension XCTestCase {
case .dailyAndCount:
expectedPixelNames.append(originalName.appending("_daily"))
expectedPixelNames.append(originalName.appending("_count"))
+ case .uniqueByNameAndParameters:
+ expectedPixelNames.append(originalName)
}
return expectedPixelNames
}
diff --git a/Tests/BrowserServicesKitTests/Autofill/AutofillTestHelper.swift b/Tests/BrowserServicesKitTests/Autofill/AutofillTestHelper.swift
index 67e825e89..f0d7dd597 100644
--- a/Tests/BrowserServicesKitTests/Autofill/AutofillTestHelper.swift
+++ b/Tests/BrowserServicesKitTests/Autofill/AutofillTestHelper.swift
@@ -31,7 +31,7 @@ struct AutofillTestHelper {
fetchedData: nil,
embeddedDataProvider: mockEmbeddedData,
localProtection: MockDomainsProtectionStore(),
- internalUserDecider: DefaultInternalUserDecider())
+ internalUserDecider: DefaultInternalUserDecider())
return manager
}
}
diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift
index 6f5fffbd4..5687c5267 100644
--- a/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift
+++ b/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift
@@ -309,7 +309,7 @@ final class DefaultFeatureFlaggerTests: XCTestCase {
fetchedData: nil,
embeddedDataProvider: mockEmbeddedData,
localProtection: MockDomainsProtectionStore(),
- internalUserDecider: DefaultInternalUserDecider())
+ internalUserDecider: DefaultInternalUserDecider())
let internalUserDecider = DefaultInternalUserDecider(store: internalUserDeciderStore)
return DefaultFeatureFlagger(internalUserDecider: internalUserDecider, privacyConfigManager: manager, experimentManager: experimentManager)
}
@@ -320,7 +320,7 @@ final class DefaultFeatureFlaggerTests: XCTestCase {
fetchedData: nil,
embeddedDataProvider: mockEmbeddedData,
localProtection: MockDomainsProtectionStore(),
- internalUserDecider: DefaultInternalUserDecider())
+ internalUserDecider: DefaultInternalUserDecider())
let internalUserDecider = DefaultInternalUserDecider(store: internalUserDeciderStore)
overrides = CapturingFeatureFlagOverriding()
diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentCohortsManagerTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentCohortsManagerTests.swift
index 48fc85355..7a2be7abc 100644
--- a/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentCohortsManagerTests.swift
+++ b/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentCohortsManagerTests.swift
@@ -41,6 +41,9 @@ final class ExperimentCohortsManagerTests: XCTestCase {
let subfeatureName4 = "TestSubfeature4"
var experimentData4: ExperimentData!
+ var firedSubfeatureID: SubfeatureID?
+ var firedExperimentData: ExperimentData?
+
let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .secondsSince1970
@@ -50,8 +53,12 @@ final class ExperimentCohortsManagerTests: XCTestCase {
override func setUp() {
super.setUp()
mockStore = MockExperimentDataStore()
+
experimentCohortsManager = ExperimentCohortsManager(
- store: mockStore
+ store: mockStore, fireCohortAssigned: {subfeatureID, experimentData in
+ self.firedSubfeatureID = subfeatureID
+ self.firedExperimentData = experimentData
+ }
)
let expectedDate1 = Date()
@@ -87,6 +94,8 @@ final class ExperimentCohortsManagerTests: XCTestCase {
XCTAssertEqual(experiments?[subfeatureName1], experimentData1)
XCTAssertEqual(experiments?[subfeatureName2], experimentData2)
XCTAssertNil(experiments?[subfeatureName3])
+ XCTAssertNil(firedSubfeatureID)
+ XCTAssertNil(firedExperimentData)
}
func testCohortReturnsCohortIDIfExistsForMultipleSubfeatures() {
@@ -100,6 +109,8 @@ final class ExperimentCohortsManagerTests: XCTestCase {
// THEN
XCTAssertEqual(result1, experimentData1.cohortID)
XCTAssertEqual(result2, experimentData2.cohortID)
+ XCTAssertNil(firedSubfeatureID)
+ XCTAssertNil(firedExperimentData)
}
func testCohortAssignIfEnabledWhenNoCohortExists() {
@@ -114,6 +125,10 @@ final class ExperimentCohortsManagerTests: XCTestCase {
// THEN
XCTAssertNotNil(result)
XCTAssertEqual(result, experimentData1.cohortID)
+ XCTAssertEqual(firedSubfeatureID, subfeatureName1)
+ XCTAssertEqual(firedExperimentData?.cohortID, experimentData1.cohortID)
+ XCTAssertEqual(firedExperimentData?.parentID, experimentData1.parentID)
+ XCTAssertEqual(firedExperimentData?.enrollmentDate.daySinceReferenceDate, experimentData1.enrollmentDate.daySinceReferenceDate)
}
func testCohortDoesNotAssignIfAssignIfEnabledIsFalse() {
@@ -127,6 +142,8 @@ final class ExperimentCohortsManagerTests: XCTestCase {
// THEN
XCTAssertNil(result)
+ XCTAssertNil(firedSubfeatureID)
+ XCTAssertNil(firedExperimentData)
}
func testCohortDoesNotAssignIfAssignIfEnabledIsTrueButNoCohortsAvailable() {
@@ -139,6 +156,8 @@ final class ExperimentCohortsManagerTests: XCTestCase {
// THEN
XCTAssertNil(result)
+ XCTAssertNil(firedSubfeatureID)
+ XCTAssertNil(firedExperimentData)
}
func testCohortReassignsCohortIfAssignedCohortDoesNotExistAndAssignIfEnabledIsTrue() {
@@ -150,6 +169,10 @@ final class ExperimentCohortsManagerTests: XCTestCase {
// THEN
XCTAssertEqual(result1, experimentData3.cohortID)
+ XCTAssertEqual(firedSubfeatureID, subfeatureName1)
+ XCTAssertEqual(firedExperimentData?.cohortID, experimentData3.cohortID)
+ XCTAssertEqual(firedExperimentData?.parentID, experimentData3.parentID)
+ XCTAssertEqual(firedExperimentData?.enrollmentDate.daySinceReferenceDate, experimentData3.enrollmentDate.daySinceReferenceDate)
}
func testCohortDoesNotReassignsCohortIfAssignedCohortDoesNotExistAndAssignIfEnabledIsTrue() {
@@ -161,6 +184,8 @@ final class ExperimentCohortsManagerTests: XCTestCase {
// THEN
XCTAssertNil(result1)
+ XCTAssertNil(firedSubfeatureID)
+ XCTAssertNil(firedExperimentData)
}
func testCohortAssignsBasedOnWeight() {
@@ -173,7 +198,7 @@ final class ExperimentCohortsManagerTests: XCTestCase {
experimentCohortsManager = ExperimentCohortsManager(
store: mockStore,
- randomizer: randomizer
+ randomizer: randomizer, fireCohortAssigned: { _, _ in }
)
// WHEN
diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlaggerExperimentsTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlaggerExperimentsTests.swift
index b6931ef22..933eb11b3 100644
--- a/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlaggerExperimentsTests.swift
+++ b/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlaggerExperimentsTests.swift
@@ -81,7 +81,7 @@ final class FeatureFlaggerExperimentsTests: XCTestCase {
mockEmbeddedData = MockEmbeddedDataProvider(data: featureJson, etag: "test")
let mockInternalUserStore = MockInternalUserStoring()
mockStore = MockExperimentDataStore()
- experimentManager = ExperimentCohortsManager(store: mockStore)
+ experimentManager = ExperimentCohortsManager(store: mockStore, fireCohortAssigned: { _, _ in })
manager = PrivacyConfigurationManager(fetchedETag: nil,
fetchedData: nil,
embeddedDataProvider: mockEmbeddedData,
diff --git a/Tests/PixelExperimentKitTests/PixelExperimentKitTests.swift b/Tests/PixelExperimentKitTests/PixelExperimentKitTests.swift
new file mode 100644
index 000000000..30334f6e6
--- /dev/null
+++ b/Tests/PixelExperimentKitTests/PixelExperimentKitTests.swift
@@ -0,0 +1,556 @@
+//
+// PixelExperimentKitTests.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 XCTest
+@testable import PixelExperimentKit
+@testable import BrowserServicesKit
+import PixelKit
+import Combine
+
+final class PixelExperimentKitTests: XCTestCase {
+ var featureJson: Data = "{}".data(using: .utf8)!
+ var mockPixelStore: MockExperimentActionPixelStore!
+ var mockFeatureFlagger: MockFeatureFlagger!
+ var firedEventSet = Set()
+ var firedEvent = [PixelKitEvent]()
+ var firedFrequency = [PixelKit.Frequency]()
+ var firedIncludeAppVersion = [Bool]()
+
+ override func setUp() {
+ super.setUp()
+ mockPixelStore = MockExperimentActionPixelStore()
+ mockFeatureFlagger = MockFeatureFlagger()
+ PixelKit.configureExperimentKit(featureFlagger: mockFeatureFlagger, eventTracker: ExperimentEventTracker(store: mockPixelStore), fire: { event, frequency, includeAppVersion in
+ self.firedEventSet.insert(event.name + "_" + (event.parameters?.toString() ?? ""))
+ self.firedEvent.append(event)
+ self.firedFrequency.append(frequency)
+ self.firedIncludeAppVersion.append(includeAppVersion)
+ })
+ }
+
+ override func tearDown() {
+ mockPixelStore = nil
+ mockFeatureFlagger = nil
+ firedEvent = []
+ firedFrequency = []
+ firedIncludeAppVersion = []
+ }
+
+ func testFireExperimentEnrollmentPixelSendsExpectedData() {
+ // GIVEN
+ let subfeatureID = "testSubfeature"
+ let cohort = "A"
+ let enrollmentDate = Date(timeIntervalSince1970: 0)
+ let experimentData = ExperimentData(parentID: "parent", cohortID: cohort, enrollmentDate: enrollmentDate)
+ let expectedEventName = "experiment_enroll_\(subfeatureID)_\(cohort)"
+ let expectedParameters = ["enrollmentDate": enrollmentDate.toYYYYMMDDInET()]
+
+ // WHEN
+ PixelKit.fireExperimentEnrollmentPixel(subfeatureID: subfeatureID, experiment: experimentData)
+
+ // THEN
+ XCTAssertEqual(firedEvent[0].name, expectedEventName)
+ XCTAssertEqual(firedEvent[0].parameters, expectedParameters)
+ XCTAssertEqual(firedFrequency[0], .uniqueByNameAndParameters)
+ XCTAssertFalse(firedIncludeAppVersion[0])
+ }
+
+ func testFireExperimentPixel_WithValidExperimentAndConversionWindowAndValueNotNumber() {
+ // GIVEN
+
+ let subfeatureID = "credentialsSaving"
+ let cohort = "control"
+ let enrollmentDate = Date().addingTimeInterval(-3 * 24 * 60 * 60) // 5 days ago
+ let conversionWindow = 3...3
+ let value = "true"
+ let expectedEventName = "experiment_metrics_\(subfeatureID)_\(cohort)"
+ let expectedParameters = [
+ "metric": "someMetric",
+ "conversionWindowDays": "3",
+ "value": value,
+ "enrollmentDate": enrollmentDate.toYYYYMMDDInET()
+ ]
+ let experimentData = ExperimentData(parentID: "autofill", cohortID: cohort, enrollmentDate: enrollmentDate)
+ mockFeatureFlagger.experiments = [subfeatureID: experimentData]
+
+ // WHEN
+ PixelKit.fireExperimentPixel(for: subfeatureID, metric: "someMetric", conversionWindowDays: conversionWindow, value: value)
+
+ // THEN
+ XCTAssertEqual(firedEvent[0].name, expectedEventName)
+ XCTAssertEqual(firedEvent[0].parameters, expectedParameters)
+ XCTAssertEqual(firedFrequency[0], .uniqueByNameAndParameters)
+ XCTAssertFalse(firedIncludeAppVersion[0])
+ XCTAssertEqual(mockPixelStore.store.count, 0)
+ }
+
+ func testFireExperimentPixel_WithValidExperimentAndConversionWindowAndValue1() {
+ // GIVEN
+ let subfeatureID = "credentialsSaving"
+ let cohort = "control"
+ let enrollmentDate = Date().addingTimeInterval(-3 * 24 * 60 * 60) // 5 days ago
+ let conversionWindow = 3...7
+ let value = "1"
+ let expectedEventName = "experiment_metrics_\(subfeatureID)_\(cohort)"
+ let expectedParameters = [
+ "metric": "someMetric",
+ "conversionWindowDays": "3-7",
+ "value": value,
+ "enrollmentDate": enrollmentDate.toYYYYMMDDInET()
+ ]
+ let experimentData = ExperimentData(parentID: "autofill", cohortID: cohort, enrollmentDate: enrollmentDate)
+ mockFeatureFlagger.experiments = [subfeatureID: experimentData]
+
+ // WHEN
+ PixelKit.fireExperimentPixel(for: subfeatureID, metric: "someMetric", conversionWindowDays: conversionWindow, value: value)
+
+ // THEN
+ XCTAssertEqual(firedEvent[0].name, expectedEventName)
+ XCTAssertEqual(firedEvent[0].parameters, expectedParameters)
+ XCTAssertEqual(firedFrequency[0], .uniqueByNameAndParameters)
+ XCTAssertFalse(firedIncludeAppVersion[0])
+ XCTAssertEqual(mockPixelStore.store.count, 0)
+ }
+
+ func testFireExperimentPixel_WithValidExperimentAndConversionWindowAndValueN() {
+ // GIVEN
+ let subfeatureID = "credentialsSaving"
+ let cohort = "control"
+ let enrollmentDate = Date().addingTimeInterval(-7 * 24 * 60 * 60) // 5 days ago
+ let conversionWindow = 3...7
+ let randomNumber = Int.random(in: 1...100)
+ let value = "\(randomNumber)"
+ let expectedEventName = "experiment_metrics_\(subfeatureID)_\(cohort)"
+ let expectedParameters = [
+ "metric": "someMetric",
+ "conversionWindowDays": "3-7",
+ "value": value,
+ "enrollmentDate": enrollmentDate.toYYYYMMDDInET()
+ ]
+ let experimentData = ExperimentData(parentID: "autofill", cohortID: cohort, enrollmentDate: enrollmentDate)
+ mockFeatureFlagger.experiments = [subfeatureID: experimentData]
+
+ // WHEN calling fire before expected number of calls
+ for n in 1.. Int {
+ return store[defaultName] ?? 0
+ }
+
+ func set(_ value: Int, forKey defaultName: String) {
+ store[defaultName] = value
+ }
+}
+
+class MockFeatureFlagger: FeatureFlagger {
+ var experiments: Experiments = [:]
+
+ var internalUserDecider: any InternalUserDecider = MockInternalUserDecider()
+
+ var localOverrides: (any BrowserServicesKit.FeatureFlagLocalOverriding)?
+
+ func getCohortIfEnabled(for featureFlag: Flag) -> (any FlagCohort)? where Flag: FeatureFlagExperimentDescribing {
+ return nil
+ }
+
+ func getAllActiveExperiments() -> Experiments {
+ return experiments
+ }
+
+ func isFeatureOn(for featureFlag: Flag, allowOverride: Bool) -> Bool where Flag: FeatureFlagDescribing {
+ return false
+ }
+}
+
+final class MockInternalUserDecider: InternalUserDecider {
+ var isInternalUser: Bool = false
+
+ var isInternalUserPublisher: AnyPublisher {
+ Just(false).eraseToAnyPublisher()
+ }
+
+ func markUserAsInternalIfNeeded(forUrl url: URL?, response: HTTPURLResponse?) -> Bool {
+ return false
+ }
+}
diff --git a/Tests/PixelKitTests/PixelKitTests.swift b/Tests/PixelKitTests/PixelKitTests.swift
index 2a6b98acc..5225fbdf4 100644
--- a/Tests/PixelKitTests/PixelKitTests.swift
+++ b/Tests/PixelKitTests/PixelKitTests.swift
@@ -82,7 +82,7 @@ final class PixelKitTests: XCTestCase {
case .testEvent, .testEventWithoutParameters, .nameWithDot:
return .standard
case .uniqueEvent:
- return .unique
+ return .uniqueByName
case .dailyEvent, .dailyEventWithoutParameters:
return .daily
case .dailyAndContinuousEvent, .dailyAndContinuousEventWithoutParameters:
@@ -209,6 +209,7 @@ final class PixelKitTests: XCTestCase {
// Prepare mock to validate expectations
let pixelKit = PixelKit(dryRun: false,
appVersion: appVersion,
+ source: PixelKit.Source.macDMG.rawValue,
defaultHeaders: headers,
dailyPixelCalendar: nil,
defaults: userDefaults) { firedPixelName, firedHeaders, parameters, _, _, _ in
@@ -254,6 +255,7 @@ final class PixelKitTests: XCTestCase {
// Prepare mock to validate expectations
let pixelKit = PixelKit(dryRun: false,
appVersion: appVersion,
+ source: PixelKit.Source.macDMG.rawValue,
defaultHeaders: headers,
dailyPixelCalendar: nil,
defaults: userDefaults) { firedPixelName, firedHeaders, parameters, _, _, _ in
@@ -300,6 +302,7 @@ final class PixelKitTests: XCTestCase {
// Prepare mock to validate expectations
let pixelKit = PixelKit(dryRun: false,
appVersion: appVersion,
+ source: PixelKit.Source.macDMG.rawValue,
defaultHeaders: headers,
dailyPixelCalendar: nil,
defaults: userDefaults) { firedPixelName, firedHeaders, parameters, _, _, _ in
@@ -397,19 +400,69 @@ final class PixelKitTests: XCTestCase {
}
// Run test
- pixelKit.fire(event, frequency: .unique) // Fired
+ pixelKit.fire(event, frequency: .uniqueByName) // Fired
timeMachine.travel(by: .hour, value: 2)
- pixelKit.fire(event, frequency: .unique) // Skipped (already fired)
+ pixelKit.fire(event, frequency: .uniqueByName) // Skipped (already fired)
timeMachine.travel(by: .day, value: 1)
timeMachine.travel(by: .hour, value: 2)
- pixelKit.fire(event, frequency: .unique) // Skipped (already fired)
+ pixelKit.fire(event, frequency: .uniqueByName) // Skipped (already fired)
timeMachine.travel(by: .hour, value: 10)
- pixelKit.fire(event, frequency: .unique) // Skipped (already fired)
+ pixelKit.fire(event, frequency: .uniqueByName) // Skipped (already fired)
timeMachine.travel(by: .day, value: 1)
- pixelKit.fire(event, frequency: .unique) // Skipped (already fired)
+ pixelKit.fire(event, frequency: .uniqueByName) // Skipped (already fired)
+
+ // Wait for expectations to be fulfilled
+ wait(for: [fireCallbackCalled], timeout: 0.5)
+ }
+
+ func testUniqueNyNameAndParameterPixel() {
+ // Prepare test parameters
+ let appVersion = "1.0.5"
+ let headers = ["a": "2", "b": "3", "c": "2000"]
+ let event = TestEventV2.uniqueEvent
+ let userDefaults = userDefaults()
+
+ let timeMachine = TimeMachine()
+
+ // Set expectations
+ let fireCallbackCalled = expectation(description: "Expect the pixel firing callback to be called")
+ fireCallbackCalled.expectedFulfillmentCount = 3
+ fireCallbackCalled.assertForOverFulfill = true
+
+ let pixelKit = PixelKit(dryRun: false,
+ appVersion: appVersion,
+ defaultHeaders: headers,
+ dailyPixelCalendar: nil,
+ dateGenerator: timeMachine.now,
+ defaults: userDefaults) { _, _, _, _, _, _ in
+ fireCallbackCalled.fulfill()
+ }
+
+ // Run test
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100"]) // Fired
+ timeMachine.travel(by: .hour, value: 2)
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["b": "200"]) // Fired
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100"]) // Skipped (already fired)
+
+ timeMachine.travel(by: .day, value: 1)
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100", "c": "300"]) // Fired
+ timeMachine.travel(by: .hour, value: 2)
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100"]) // Skipped (already fired)
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["b": "200"]) // Skipped (already fired)
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["c": "300", "a": "100"]) // Skipped (already fired)
+
+ timeMachine.travel(by: .hour, value: 10)
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100"]) // Skipped (already fired)
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["b": "200"]) // Skipped (already fired)
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100", "c": "300"]) // Skipped (already fired)
+
+ timeMachine.travel(by: .day, value: 1)
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100"]) // Skipped (already fired)
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["b": "200"]) // Skipped (already fired)
+ pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100", "c": "300"]) // Skipped (already fired)
// Wait for expectations to be fulfilled
wait(for: [fireCallbackCalled], timeout: 0.5)