diff --git a/Core/DailyPixel.swift b/Core/DailyPixel.swift index e21be88fa4..c82ae35403 100644 --- a/Core/DailyPixel.swift +++ b/Core/DailyPixel.swift @@ -38,9 +38,11 @@ public final class DailyPixel { } - private enum Constant { + public enum Constant { static let dailyPixelStorageIdentifier = "com.duckduckgo.daily.pixel.storage" + public static let dailyPixelSuffixes = (dailySuffix: "_daily", countSuffix: "_count") + public static let legacyDailyPixelSuffixes = (dailySuffix: "_d", countSuffix: "_c") } @@ -80,6 +82,7 @@ public final class DailyPixel { /// This means a pixel will get sent twice the first time it is called per-day, and subsequent calls that day will only send the `_c` variant. /// This is useful in situations where pixels receive spikes in volume, as the daily pixel can be used to determine how many users are actually affected. public static func fireDailyAndCount(pixel: Pixel.Event, + pixelNameSuffixes: (dailySuffix: String, countSuffix: String) = Constant.dailyPixelSuffixes, error: Swift.Error? = nil, withAdditionalParameters params: [String: String] = [:], includedParameters: [Pixel.QueryParameters] = [.appVersion], @@ -91,7 +94,7 @@ public final class DailyPixel { if !hasBeenFiredToday(forKey: key, dailyPixelStore: dailyPixelStore) { pixelFiring.fire( - pixelNamed: pixel.name + "_d", + pixelNamed: pixel.name + pixelNameSuffixes.dailySuffix, withAdditionalParameters: params, includedParameters: includedParameters, onComplete: onDailyComplete @@ -105,7 +108,7 @@ public final class DailyPixel { newParams.appendErrorPixelParams(error: error) } pixelFiring.fire( - pixelNamed: pixel.name + "_c", + pixelNamed: pixel.name + pixelNameSuffixes.countSuffix, withAdditionalParameters: newParams, includedParameters: includedParameters, onComplete: onCountComplete diff --git a/Core/DailyPixelFiring.swift b/Core/DailyPixelFiring.swift index 51b065e6e9..da0d958b66 100644 --- a/Core/DailyPixelFiring.swift +++ b/Core/DailyPixelFiring.swift @@ -18,11 +18,22 @@ // import Foundation +import Persistence public protocol DailyPixelFiring { static func fireDaily(_ pixel: Pixel.Event, withAdditionalParameters params: [String: String]) - + + static func fireDailyAndCount(pixel: Pixel.Event, + pixelNameSuffixes: (dailySuffix: String, countSuffix: String), + error: Swift.Error?, + withAdditionalParameters params: [String: String], + includedParameters: [Pixel.QueryParameters], + pixelFiring: PixelFiring.Type, + dailyPixelStore: KeyValueStoring, + onDailyComplete: @escaping (Swift.Error?) -> Void, + onCountComplete: @escaping (Swift.Error?) -> Void) + static func fireDaily(_ pixel: Pixel.Event) } diff --git a/Core/PersistentPixel.swift b/Core/PersistentPixel.swift new file mode 100644 index 0000000000..d26dd31cba --- /dev/null +++ b/Core/PersistentPixel.swift @@ -0,0 +1,291 @@ +// +// PersistentPixel.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 os.log +import Networking +import Persistence + +public protocol PersistentPixelFiring { + func fire(pixel: Pixel.Event, + error: Swift.Error?, + includedParameters: [Pixel.QueryParameters], + withAdditionalParameters params: [String: String], + onComplete: @escaping (Error?) -> Void) + + func fireDailyAndCount(pixel: Pixel.Event, + pixelNameSuffixes: (dailySuffix: String, countSuffix: String), + error: Swift.Error?, + withAdditionalParameters params: [String: String], + includedParameters: [Pixel.QueryParameters], + completion: @escaping ((dailyPixelStorageError: Error?, countPixelStorageError: Error?)) -> Void) + + func sendQueuedPixels(completion: @escaping (PersistentPixelStorageError?) -> Void) +} + +public final class PersistentPixel: PersistentPixelFiring { + + enum Constants { + static let lastProcessingDateKey = "com.duckduckgo.ios.persistent-pixel.last-processing-timestamp" + +#if DEBUG + static let minimumProcessingInterval: TimeInterval = .minutes(1) +#else + static let minimumProcessingInterval: TimeInterval = .hours(1) +#endif + } + + private let pixelFiring: PixelFiring.Type + private let dailyPixelFiring: DailyPixelFiring.Type + private let persistentPixelStorage: PersistentPixelStoring + private let lastProcessingDateStorage: KeyValueStoring + private let calendar: Calendar + private let dateGenerator: () -> Date + private let workQueue = DispatchQueue(label: "Persistent Pixel Retry Queue") + + private let dateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + public convenience init() { + self.init(pixelFiring: Pixel.self, + dailyPixelFiring: DailyPixel.self, + persistentPixelStorage: DefaultPersistentPixelStorage(), + lastProcessingDateStorage: UserDefaults.standard) + } + + init(pixelFiring: PixelFiring.Type, + dailyPixelFiring: DailyPixelFiring.Type, + persistentPixelStorage: PersistentPixelStoring, + lastProcessingDateStorage: KeyValueStoring, + calendar: Calendar = .current, + dateGenerator: @escaping () -> Date = { Date() }) { + self.pixelFiring = pixelFiring + self.dailyPixelFiring = dailyPixelFiring + self.persistentPixelStorage = persistentPixelStorage + self.lastProcessingDateStorage = lastProcessingDateStorage + self.calendar = calendar + self.dateGenerator = dateGenerator + } + + // MARK: - Pixel Firing + + public func fire(pixel: Pixel.Event, + error: Swift.Error? = nil, + includedParameters: [Pixel.QueryParameters] = [.appVersion], + withAdditionalParameters additionalParameters: [String: String] = [:], + onComplete: @escaping (Error?) -> Void = { _ in }) { + let fireDate = dateGenerator() + let dateString = dateFormatter.string(from: fireDate) + var additionalParameters = additionalParameters + additionalParameters[PixelParameters.originalPixelTimestamp] = dateString + + Logger.general.debug("Firing persistent pixel named \(pixel.name)") + + pixelFiring.fire(pixel: pixel, + error: error, + includedParameters: includedParameters, + withAdditionalParameters: additionalParameters) { pixelFireError in + if pixelFireError != nil { + do { + if let error { + additionalParameters.appendErrorPixelParams(error: error) + } + + try self.persistentPixelStorage.append(pixels: [ + PersistentPixelMetadata(eventName: pixel.name, + additionalParameters: additionalParameters, + includedParameters: includedParameters) + ]) + + onComplete(nil) + } catch { + onComplete(error) + } + } + } + } + + public func fireDailyAndCount(pixel: Pixel.Event, + pixelNameSuffixes: (dailySuffix: String, countSuffix: String) = DailyPixel.Constant.dailyPixelSuffixes, + error: Swift.Error? = nil, + withAdditionalParameters additionalParameters: [String: String], + includedParameters: [Pixel.QueryParameters] = [.appVersion], + completion: @escaping ((dailyPixelStorageError: Error?, countPixelStorageError: Error?)) -> Void = { _ in }) { + let dispatchGroup = DispatchGroup() + + dispatchGroup.enter() // onDailyComplete + dispatchGroup.enter() // onCountComplete + + var dailyPixelStorageError: Error? + var countPixelStorageError: Error? + + let fireDate = dateGenerator() + let dateString = dateFormatter.string(from: fireDate) + var additionalParameters = additionalParameters + additionalParameters[PixelParameters.originalPixelTimestamp] = dateString + + Logger.general.debug("Firing persistent daily/count pixel named \(pixel.name)") + + dailyPixelFiring.fireDailyAndCount( + pixel: pixel, + pixelNameSuffixes: pixelNameSuffixes, + error: error, + withAdditionalParameters: additionalParameters, + includedParameters: includedParameters, + pixelFiring: Pixel.self, + dailyPixelStore: DailyPixel.storage, + onDailyComplete: { dailyError in + if let dailyError, (dailyError as? DailyPixel.Error) != .alreadyFired { + do { + if let error { additionalParameters.appendErrorPixelParams(error: error) } + Logger.general.debug("Saving persistent daily pixel named \(pixel.name)") + try self.persistentPixelStorage.append(pixels: [ + PersistentPixelMetadata(eventName: pixel.name + pixelNameSuffixes.dailySuffix, + additionalParameters: additionalParameters, + includedParameters: includedParameters) + ]) + } catch { + dailyPixelStorageError = error + } + } + + dispatchGroup.leave() + }, onCountComplete: { countError in + if countError != nil { + do { + if let error { additionalParameters.appendErrorPixelParams(error: error) } + Logger.general.debug("Saving persistent count pixel named \(pixel.name)") + try self.persistentPixelStorage.append(pixels: [ + PersistentPixelMetadata(eventName: pixel.name + pixelNameSuffixes.countSuffix, + additionalParameters: additionalParameters, + includedParameters: includedParameters) + ]) + } catch { + countPixelStorageError = error + } + } + + dispatchGroup.leave() + } + ) + + dispatchGroup.notify(queue: .global()) { + completion((dailyPixelStorageError: dailyPixelStorageError, countPixelStorageError: countPixelStorageError)) + } + } + + // MARK: - Queue Processing + + public func sendQueuedPixels(completion: @escaping (PersistentPixelStorageError?) -> Void) { + workQueue.async { + if let lastProcessingDate = self.lastProcessingDateStorage.object(forKey: Constants.lastProcessingDateKey) as? Date { + let threshold = self.dateGenerator().addingTimeInterval(-Constants.minimumProcessingInterval) + if threshold <= lastProcessingDate { + completion(nil) + return + } + } + + self.lastProcessingDateStorage.set(self.dateGenerator(), forKey: Constants.lastProcessingDateKey) + + do { + let queuedPixels = try self.persistentPixelStorage.storedPixels() + + if queuedPixels.isEmpty { + completion(nil) + return + } + + Logger.general.debug("Persistent pixel retrying \(queuedPixels.count, privacy: .public) pixels") + + self.fire(queuedPixels: queuedPixels) { pixelIDsToRemove in + Logger.general.debug("Persistent pixel retrying done, \(pixelIDsToRemove.count, privacy: .public) pixels successfully sent") + + do { + try self.persistentPixelStorage.remove(pixelsWithIDs: pixelIDsToRemove) + completion(nil) + } catch { + completion(PersistentPixelStorageError.writeError(error)) + } + } + } catch { + completion(PersistentPixelStorageError.readError(error)) + } + } + } + + // MARK: - Private + + /// Sends queued pixels and calls the completion handler with those that should be removed. + private func fire(queuedPixels: [PersistentPixelMetadata], completion: @escaping (Set) -> Void) { + let dispatchGroup = DispatchGroup() + + let pixelIDsAccessQueue = DispatchQueue(label: "Failed Pixel Retry Attempt Metadata Queue") + var pixelIDsToRemove: Set = [] + let currentDate = dateGenerator() + let date28DaysAgo = calendar.date(byAdding: .day, value: -28, to: currentDate) + + for pixelMetadata in queuedPixels { + if let sendDateString = pixelMetadata.timestamp, let sendDate = dateFormatter.date(from: sendDateString), let date28DaysAgo { + if sendDate < date28DaysAgo { + pixelIDsAccessQueue.sync { + _ = pixelIDsToRemove.insert(pixelMetadata.id) + } + continue + } + } else { + // If we don't have a timestamp for some reason, ignore the retry - retries are only useful if they have a timestamp attached. + // It's not expected that this will ever happen, so an assertion failure is used to report it when debugging. + assertionFailure("Did not find a timestamp for pixel \(pixelMetadata.eventName)") + pixelIDsAccessQueue.sync { + _ = pixelIDsToRemove.insert(pixelMetadata.id) + } + continue + } + + var pixelParameters = pixelMetadata.additionalParameters + pixelParameters[PixelParameters.retriedPixel] = "1" + + dispatchGroup.enter() + + pixelFiring.fire( + pixelNamed: pixelMetadata.eventName, + withAdditionalParameters: pixelParameters, + includedParameters: pixelMetadata.includedParameters, + onComplete: { error in + if error == nil { + pixelIDsAccessQueue.sync { + _ = pixelIDsToRemove.insert(pixelMetadata.id) + } + } + + dispatchGroup.leave() + } + ) + } + + dispatchGroup.notify(queue: .global()) { + completion(pixelIDsToRemove) + } + } + +} diff --git a/Core/PersistentPixelStoring.swift b/Core/PersistentPixelStoring.swift new file mode 100644 index 0000000000..9210fb787a --- /dev/null +++ b/Core/PersistentPixelStoring.swift @@ -0,0 +1,170 @@ +// +// PersistentPixelStoring.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 Networking + +public struct PersistentPixelMetadata: Identifiable, Codable, Equatable { + + public let id: UUID + public let eventName: String + public let additionalParameters: [String: String] + public let includedParameters: [Pixel.QueryParameters] + + public init(eventName: String, additionalParameters: [String: String], includedParameters: [Pixel.QueryParameters]) { + self.id = UUID() + self.eventName = eventName + self.additionalParameters = additionalParameters + self.includedParameters = includedParameters + } + + var timestamp: String? { + return additionalParameters[PixelParameters.originalPixelTimestamp] + } +} + +protocol PersistentPixelStoring { + func append(pixels: [PersistentPixelMetadata]) throws + func remove(pixelsWithIDs: Set) throws + func storedPixels() throws -> [PersistentPixelMetadata] +} + +public enum PersistentPixelStorageError: Error { + case readError(Error) + case writeError(Error) + case encodingError(Error) + case decodingError(Error) +} + +final class DefaultPersistentPixelStorage: PersistentPixelStoring { + + enum Constants { + static let queuedPixelsFileName = "queued-pixels.json" + static let pixelCountLimit = 100 + } + + private let fileManager: FileManager + private let fileName: String + private let storageDirectory: URL + private let pixelCountLimit: Int + + private let fileAccessQueue = DispatchQueue(label: "Persistent Pixel File Access Queue", qos: .utility) + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private var fileURL: URL { + return storageDirectory.appendingPathComponent(fileName) + } + + init(fileManager: FileManager = .default, + fileName: String = Constants.queuedPixelsFileName, + storageDirectory: URL? = nil, + pixelCountLimit: Int = Constants.pixelCountLimit) { + self.fileManager = fileManager + self.fileName = fileName + self.pixelCountLimit = pixelCountLimit + + if let storageDirectory = storageDirectory { + self.storageDirectory = storageDirectory + } else if let appSupportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + self.storageDirectory = appSupportDirectory + } else { + fatalError("Unable to locate application support directory") + } + } + + func append(pixels newPixels: [PersistentPixelMetadata]) throws { + try fileAccessQueue.sync { + var pixels = try self.readStoredPixelDataFromFileSystem() + pixels.append(contentsOf: newPixels) + + if pixels.count > pixelCountLimit { + pixels = pixels.suffix(Constants.pixelCountLimit) + } + + try writePixelDataToFileSystem(pixels: pixels) + } + } + + func remove(pixelsWithIDs pixelIDs: Set) throws { + try fileAccessQueue.sync { + var pixels = try self.readStoredPixelDataFromFileSystem() + + pixels.removeAll { pixel in + pixelIDs.contains(pixel.id) + } + + try writePixelDataToFileSystem(pixels: pixels) + } + } + + func storedPixels() throws -> [PersistentPixelMetadata] { + try fileAccessQueue.sync { + return try readStoredPixelDataFromFileSystem() + } + } + + // MARK: - Private + + private var cachedPixelMetadata: [PersistentPixelMetadata]? + + private func readStoredPixelDataFromFileSystem() throws -> [PersistentPixelMetadata] { + dispatchPrecondition(condition: .onQueue(fileAccessQueue)) + + if let cachedPixelMetadata { + return cachedPixelMetadata + } + + guard fileManager.fileExists(atPath: fileURL.path) else { + return [] + } + + do { + let pixelFileData = try Data(contentsOf: fileURL) + + do { + let decodedMetadata = try decoder.decode([PersistentPixelMetadata].self, from: pixelFileData) + self.cachedPixelMetadata = decodedMetadata + return decodedMetadata + } catch { + throw PersistentPixelStorageError.decodingError(error) + } + } catch { + throw PersistentPixelStorageError.readError(error) + } + } + + private func writePixelDataToFileSystem(pixels: [PersistentPixelMetadata]) throws { + dispatchPrecondition(condition: .onQueue(fileAccessQueue)) + + do { + let encodedPixelData = try encoder.encode(pixels) + + do { + try encodedPixelData.write(to: fileURL) + self.cachedPixelMetadata = pixels + } catch { + throw PersistentPixelStorageError.writeError(error) + } + } catch { + throw PersistentPixelStorageError.encodingError(error) + } + } + +} diff --git a/Core/Pixel.swift b/Core/Pixel.swift index eb3770f776..a7a6c7e3e2 100644 --- a/Core/Pixel.swift +++ b/Core/Pixel.swift @@ -153,6 +153,10 @@ public struct PixelParameters { // Subscription public static let privacyProKeychainAccessType = "access_type" public static let privacyProKeychainError = "error" + + // Persistent pixel + public static let originalPixelTimestamp = "originalPixelTimestamp" + public static let retriedPixel = "retriedPixel" } public struct PixelValues { @@ -172,7 +176,7 @@ public class Pixel { DefaultInternalUserDecider(store: InternalUserStore()).isInternalUser } - public enum QueryParameters { + public enum QueryParameters: Codable { case atb case appVersion } diff --git a/Core/PrivacyFeatures.swift b/Core/PrivacyFeatures.swift index a4a7abd883..7ed8ba3a03 100644 --- a/Core/PrivacyFeatures.swift +++ b/Core/PrivacyFeatures.swift @@ -46,7 +46,11 @@ public final class PrivacyFeatures { } if dailyAndCount { - DailyPixel.fireDailyAndCount(pixel: domainEvent, error: error, withAdditionalParameters: parameters ?? [:], onCountComplete: onComplete) + DailyPixel.fireDailyAndCount(pixel: domainEvent, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: error, + withAdditionalParameters: parameters ?? [:], + onCountComplete: onComplete) } else { Pixel.fire(pixel: domainEvent, error: error, withAdditionalParameters: parameters ?? [:], onComplete: onComplete) } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 7c6c72e9f5..e27f3415c1 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -225,6 +225,11 @@ 4B0295192537BC6700E00CEF /* ConfigurationDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0295182537BC6700E00CEF /* ConfigurationDebugViewController.swift */; }; 4B0F3F502B9BFF2100392892 /* NetworkProtectionFAQView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0F3F4F2B9BFF2100392892 /* NetworkProtectionFAQView.swift */; }; 4B274F602AFEAECC003F0745 /* NetworkProtectionWidgetRefreshModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B274F5F2AFEAECC003F0745 /* NetworkProtectionWidgetRefreshModel.swift */; }; + 4B27FBAE2C924EC6007E21A7 /* PersistentPixelStoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B27FBAD2C924EC6007E21A7 /* PersistentPixelStoring.swift */; }; + 4B27FBB12C9252F4007E21A7 /* DefaultPersistentPixelStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B27FBAF2C9251B2007E21A7 /* DefaultPersistentPixelStorageTests.swift */; }; + 4B27FBB32C926E51007E21A7 /* PersistentPixel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B27FBB22C926E51007E21A7 /* PersistentPixel.swift */; }; + 4B27FBB52C927435007E21A7 /* PersistentPixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B27FBB42C927435007E21A7 /* PersistentPixelTests.swift */; }; + 4B27FBB82C93F53B007E21A7 /* MockPersistentPixel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B27FBB72C93F53B007E21A7 /* MockPersistentPixel.swift */; }; 4B2C79612C5B27AC00A240CC /* VPNSnoozeActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD96E082C4DCDD2003BC32C /* VPNSnoozeActivityAttributes.swift */; }; 4B37E0502B928CA6009E81CA /* vpn-light-mode.json in Resources */ = {isa = PBXBuildFile; fileRef = 4B37E04F2B928CA6009E81CA /* vpn-light-mode.json */; }; 4B412ACC2BBB3D0900A39F5E /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B412ACB2BBB3D0900A39F5E /* LazyView.swift */; }; @@ -1518,6 +1523,11 @@ 4B0295182537BC6700E00CEF /* ConfigurationDebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationDebugViewController.swift; sourceTree = ""; }; 4B0F3F4F2B9BFF2100392892 /* NetworkProtectionFAQView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionFAQView.swift; sourceTree = ""; }; 4B274F5F2AFEAECC003F0745 /* NetworkProtectionWidgetRefreshModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionWidgetRefreshModel.swift; sourceTree = ""; }; + 4B27FBAD2C924EC6007E21A7 /* PersistentPixelStoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentPixelStoring.swift; sourceTree = ""; }; + 4B27FBAF2C9251B2007E21A7 /* DefaultPersistentPixelStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultPersistentPixelStorageTests.swift; sourceTree = ""; }; + 4B27FBB22C926E51007E21A7 /* PersistentPixel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentPixel.swift; sourceTree = ""; }; + 4B27FBB42C927435007E21A7 /* PersistentPixelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentPixelTests.swift; sourceTree = ""; }; + 4B27FBB72C93F53B007E21A7 /* MockPersistentPixel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPersistentPixel.swift; sourceTree = ""; }; 4B37E04F2B928CA6009E81CA /* vpn-light-mode.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "vpn-light-mode.json"; sourceTree = ""; }; 4B412ACB2BBB3D0900A39F5E /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; 4B52648A25F9613B00CB4C24 /* trackerData.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = trackerData.json; sourceTree = ""; }; @@ -5597,6 +5607,8 @@ 1D8F727E2BA86D8000E31493 /* PixelExperiment.swift */, 85E065B92C73A4DF00D73E2A /* UsageSegmentation.swift */, 85528AA62C7CA95D0017BCCA /* UsageSegmentationCalculator.swift */, + 4B27FBB22C926E51007E21A7 /* PersistentPixel.swift */, + 4B27FBAD2C924EC6007E21A7 /* PersistentPixelStoring.swift */, ); name = Statistics; sourceTree = ""; @@ -5643,6 +5655,8 @@ 85E065BD2C73AD7F00D73E2A /* UsageSegmentationTests.swift */, 8524092E2C78024900CB28FC /* UsageSegmentationCalculationTests.swift */, 85C11E4020904BBE00BFFEB4 /* VariantManagerTests.swift */, + 4B27FBB42C927435007E21A7 /* PersistentPixelTests.swift */, + 4B27FBAF2C9251B2007E21A7 /* DefaultPersistentPixelStorageTests.swift */, ); name = Statistics; sourceTree = ""; @@ -5990,6 +6004,7 @@ 9F8007252C5261AF003EDAF4 /* MockPrivacyDataReporter.swift */, 9F69331C2C5A191400CD6A5D /* MockTutorialSettings.swift */, 852409302C78030D00CB28FC /* MockUsageSegmentation.swift */, + 4B27FBB72C93F53B007E21A7 /* MockPersistentPixel.swift */, ); name = Mocks; sourceTree = ""; @@ -7950,6 +7965,7 @@ D625AAEC2BBEF27600BC189A /* TabURLInterceptorTests.swift in Sources */, EE7623BE2C5D038200FA061C /* MockFeatureFlagger.swift in Sources */, 5694372B2BE3F2D900C0881B /* SyncErrorHandlerTests.swift in Sources */, + 4B27FBB52C927435007E21A7 /* PersistentPixelTests.swift in Sources */, 987130C7294AAB9F00AB05E0 /* MenuBookmarksViewModelTests.swift in Sources */, 858650D32469BFAD00C36F8A /* DaxDialogTests.swift in Sources */, 9F1623092C9D14F10093C4FC /* DefaultVariantManagerOnboardingTests.swift in Sources */, @@ -8038,6 +8054,7 @@ 564DE45E2C45218500D23241 /* OnboardingNavigationDelegateTests.swift in Sources */, C14E2F7729DE14EA002AC515 /* AutofillInterfaceUsernameTruncatorTests.swift in Sources */, 9F7CFF782C86E3E10012833E /* OnboardingManagerTests.swift in Sources */, + 4B27FBB12C9252F4007E21A7 /* DefaultPersistentPixelStorageTests.swift in Sources */, 564DE45A2C450BE600D23241 /* DaxDialogsNewTabTests.swift in Sources */, C174CE602BD6A6CE00AED2EA /* MockDDGSyncing.swift in Sources */, 9F4CC51F2C48D758006A96EB /* ContextualDaxDialogsFactoryTests.swift in Sources */, @@ -8090,6 +8107,7 @@ B6AD9E3728D4510A0019CDE9 /* ContentBlockingUpdatingTests.swift in Sources */, C14882E427F20D9A00D59F0C /* BookmarksImporterTests.swift in Sources */, 8588026A24E424EE00C24AB6 /* AppWidthObserverTests.swift in Sources */, + 4B27FBB82C93F53B007E21A7 /* MockPersistentPixel.swift in Sources */, 857229882BBEE74100E2E802 /* AppRatingPromptDatabaseMigrationTests.swift in Sources */, 8588026624E420BD00C24AB6 /* LargeOmniBarStateTests.swift in Sources */, 5694372E2BE3F5B300C0881B /* CapturingAlertPresenter.swift in Sources */, @@ -8183,6 +8201,7 @@ 858479C92B8792D800D156C1 /* HistoryManager.swift in Sources */, F16393FF1ECCB9CC00DDD653 /* FileLoader.swift in Sources */, F1134EAB1F3E2C6A00B73467 /* StatisticsUserDefaults.swift in Sources */, + 4B27FBB32C926E51007E21A7 /* PersistentPixel.swift in Sources */, CB258D1E29A52AF900DEBA24 /* FileStore.swift in Sources */, F1075C921E9EF827006BE8A8 /* UserDefaultsExtension.swift in Sources */, 851624C22B95F8BD002D5CD7 /* HistoryCapture.swift in Sources */, @@ -8213,6 +8232,7 @@ 85A1B3B220C6CD9900C18F15 /* CookieStorage.swift in Sources */, 9856A1992933D2EB00ACB44F /* BookmarksModelsErrorHandling.swift in Sources */, 850559D023CF647C0055C0D5 /* PreserveLogins.swift in Sources */, + 4B27FBAE2C924EC6007E21A7 /* PersistentPixelStoring.swift in Sources */, 6F7FB8E32C660BF300867DA7 /* DailyPixelFiring.swift in Sources */, C1CCCBA7283E101500CF3791 /* FaviconsHelper.swift in Sources */, 9813F79822BA71AA00A80EDB /* StorageCache.swift in Sources */, diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 3d009fd4ba..0442bb02d9 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -574,6 +574,8 @@ import os.log Task { await privacyProDataReporter.saveWidgetAdded() } + + AppDependencyProvider.shared.persistentPixel.sendQueuedPixels { _ in } } private func stopAndRemoveVPNIfNotAuthenticated() async { diff --git a/DuckDuckGo/AppDependencyProvider.swift b/DuckDuckGo/AppDependencyProvider.swift index 4f87b08207..7460cb6bf4 100644 --- a/DuckDuckGo/AppDependencyProvider.swift +++ b/DuckDuckGo/AppDependencyProvider.swift @@ -48,6 +48,8 @@ protocol DependencyProvider { var connectionObserver: ConnectionStatusObserver { get } var serverInfoObserver: ConnectionServerInfoObserver { get } var vpnSettings: VPNSettings { get } + var persistentPixel: PersistentPixelFiring { get } + } /// Provides dependencies for objects that are not directly instantiated @@ -86,6 +88,7 @@ final class AppDependencyProvider: DependencyProvider { let connectionObserver: ConnectionStatusObserver = ConnectionStatusObserverThroughSession() let serverInfoObserver: ConnectionServerInfoObserver = ConnectionServerInfoObserverThroughSession() let vpnSettings = VPNSettings(defaults: .networkProtectionGroupDefaults) + let persistentPixel: PersistentPixelFiring = PersistentPixel() private init() { featureFlagger = DefaultFeatureFlagger(internalUserDecider: internalUserDecider, @@ -130,7 +133,8 @@ final class AppDependencyProvider: DependencyProvider { networkProtectionKeychainTokenStore = NetworkProtectionKeychainTokenStore(accessTokenProvider: accessTokenProvider) #endif networkProtectionTunnelController = NetworkProtectionTunnelController(accountManager: accountManager, - tokenStore: networkProtectionKeychainTokenStore) + tokenStore: networkProtectionKeychainTokenStore, + persistentPixel: persistentPixel) vpnFeatureVisibility = DefaultNetworkProtectionVisibility(userDefaults: .networkProtectionGroupDefaults, accountManager: accountManager) } diff --git a/DuckDuckGo/BookmarksDatabaseSetup.swift b/DuckDuckGo/BookmarksDatabaseSetup.swift index fcd83415fe..793e8f3b16 100644 --- a/DuckDuckGo/BookmarksDatabaseSetup.swift +++ b/DuckDuckGo/BookmarksDatabaseSetup.swift @@ -52,6 +52,7 @@ struct BookmarksDatabaseSetup { let processedErrors = CoreDataErrorsParser.parse(error: underlyingError as NSError) DailyPixel.fireDailyAndCount(pixel: .debugBookmarksValidationFailed, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, withAdditionalParameters: processedErrors.errorPixelParameters, includedParameters: [.appVersion]) } @@ -130,6 +131,7 @@ struct BookmarksDatabaseSetup { let processedErrors = CoreDataErrorsParser.parse(error: underlyingError as NSError) DailyPixel.fireDailyAndCount(pixel: .debugBookmarksPendingDeletionRepairError, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, withAdditionalParameters: processedErrors.errorPixelParameters, includedParameters: [.appVersion]) } diff --git a/DuckDuckGo/EventMapping+NetworkProtectionError.swift b/DuckDuckGo/EventMapping+NetworkProtectionError.swift index e24613266d..ad32ee887e 100644 --- a/DuckDuckGo/EventMapping+NetworkProtectionError.swift +++ b/DuckDuckGo/EventMapping+NetworkProtectionError.swift @@ -102,6 +102,9 @@ extension EventMapping where Event == NetworkProtectionError { pixelError = error } - DailyPixel.fireDailyAndCount(pixel: pixelEvent, error: pixelError, withAdditionalParameters: params) + DailyPixel.fireDailyAndCount(pixel: pixelEvent, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: pixelError, + withAdditionalParameters: params) } } diff --git a/DuckDuckGo/NetworkProtectionDNSSettingsViewModel.swift b/DuckDuckGo/NetworkProtectionDNSSettingsViewModel.swift index 1acaec5edf..429a9c0bf6 100644 --- a/DuckDuckGo/NetworkProtectionDNSSettingsViewModel.swift +++ b/DuckDuckGo/NetworkProtectionDNSSettingsViewModel.swift @@ -60,9 +60,9 @@ final class NetworkProtectionDNSSettingsViewModel: ObservableObject { /// Updating `dnsSettings` does an IPv4 conversion before actually commiting the change, /// so we do a final check to see which outcome the user ends up with if settings.dnsSettings.usesCustomDNS { - DailyPixel.fireDailyAndCount(pixel: .networkProtectionDNSUpdateCustom) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionDNSUpdateCustom, pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } else { - DailyPixel.fireDailyAndCount(pixel: .networkProtectionDNSUpdateDefault) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionDNSUpdateDefault, pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } } diff --git a/DuckDuckGo/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtectionTunnelController.swift index c7b8ebee6d..c13dedcb06 100644 --- a/DuckDuckGo/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtectionTunnelController.swift @@ -41,6 +41,7 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr private let snoozeTimingStore = NetworkProtectionSnoozeTimingStore(userDefaults: .networkProtectionGroupDefaults) private let notificationCenter: NotificationCenter = .default private var previousStatus: NEVPNStatus = .invalid + private let persistentPixel: PersistentPixelFiring private var cancellables = Set() // MARK: - Manager, Session, & Connection @@ -118,8 +119,9 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr } } - init(accountManager: AccountManager, tokenStore: NetworkProtectionKeychainTokenStore) { + init(accountManager: AccountManager, tokenStore: NetworkProtectionKeychainTokenStore, persistentPixel: PersistentPixelFiring) { self.tokenStore = tokenStore + self.persistentPixel = persistentPixel subscribeToSnoozeTimingChanges() subscribeToStatusChanges() subscribeToConfigurationChanges() @@ -128,16 +130,33 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr /// Starts the VPN connection used for Network Protection /// func start() async { - Pixel.fire(pixel: .networkProtectionControllerStartAttempt, includedParameters: [.appVersion, .atb]) + persistentPixel.fire( + pixel: .networkProtectionControllerStartAttempt, + error: nil, + includedParameters: [.appVersion, .atb], + withAdditionalParameters: [:], + onComplete: { _ in }) do { try await startWithError() - Pixel.fire(pixel: .networkProtectionControllerStartSuccess, includedParameters: [.appVersion, .atb]) + + persistentPixel.fire( + pixel: .networkProtectionControllerStartSuccess, + error: nil, + includedParameters: [.appVersion, .atb], + withAdditionalParameters: [:], + onComplete: { _ in }) } catch { if case StartError.configSystemPermissionsDenied = error { return } - Pixel.fire(pixel: .networkProtectionControllerStartFailure, error: error, includedParameters: [.appVersion, .atb]) + + persistentPixel.fire( + pixel: .networkProtectionControllerStartFailure, + error: error, + includedParameters: [.appVersion, .atb], + withAdditionalParameters: [:], + onComplete: { _ in }) #if DEBUG errorStore.lastErrorMessage = error.localizedDescription @@ -165,13 +184,14 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr do { try await tunnelManager?.removeFromPreferences() - DailyPixel.fireDailyAndCount(pixel: .networkProtectionVPNConfigurationRemoved, withAdditionalParameters: [ - PixelParameters.reason: reason.rawValue - ]) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionVPNConfigurationRemoved, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + withAdditionalParameters: [PixelParameters.reason: reason.rawValue]) } catch { - DailyPixel.fireDailyAndCount(pixel: .networkProtectionVPNConfigurationRemovalFailed, error: error, withAdditionalParameters: [ - PixelParameters.reason: reason.rawValue - ]) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionVPNConfigurationRemovalFailed, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: error, + withAdditionalParameters: [PixelParameters.reason: reason.rawValue]) } } diff --git a/DuckDuckGo/NetworkProtectionVPNLocationViewModel.swift b/DuckDuckGo/NetworkProtectionVPNLocationViewModel.swift index b87bd2e064..6f11dbb17f 100644 --- a/DuckDuckGo/NetworkProtectionVPNLocationViewModel.swift +++ b/DuckDuckGo/NetworkProtectionVPNLocationViewModel.swift @@ -55,13 +55,13 @@ final class NetworkProtectionVPNLocationViewModel: ObservableObject { } func onNearestItemSelection() async { - DailyPixel.fireDailyAndCount(pixel: .networkProtectionGeoswitchingSetNearest) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionGeoswitchingSetNearest, pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) settings.selectedLocation = .nearest await reloadList() } func onCountryItemSelection(id: String, cityId: String? = nil) async { - DailyPixel.fireDailyAndCount(pixel: .networkProtectionGeoswitchingSetCustom) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionGeoswitchingSetCustom, pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) let location = NetworkProtectionSelectedLocation(country: id, city: cityId) settings.selectedLocation = .location(location) await reloadList() @@ -71,7 +71,8 @@ final class NetworkProtectionVPNLocationViewModel: ObservableObject { private func reloadList() async { guard let list = try? await locationListRepository.fetchLocationList() else { return } if list.isEmpty { - DailyPixel.fireDailyAndCount(pixel: .networkProtectionGeoswitchingNoLocations) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionGeoswitchingNoLocations, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } let selectedLocation = self.settings.selectedLocation let isNearestSelected = selectedLocation == .nearest diff --git a/DuckDuckGo/Subscription/DefaultSubscriptionManager+AccountManagerKeychainAccessDelegate.swift b/DuckDuckGo/Subscription/DefaultSubscriptionManager+AccountManagerKeychainAccessDelegate.swift index dbd2042c86..9b7a86bf77 100644 --- a/DuckDuckGo/Subscription/DefaultSubscriptionManager+AccountManagerKeychainAccessDelegate.swift +++ b/DuckDuckGo/Subscription/DefaultSubscriptionManager+AccountManagerKeychainAccessDelegate.swift @@ -31,6 +31,7 @@ extension DefaultSubscriptionManager: AccountManagerKeychainAccessDelegate { ] DailyPixel.fireDailyAndCount(pixel: .privacyProKeychainAccessError, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, withAdditionalParameters: parameters) } } diff --git a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackSender.swift b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackSender.swift index 76f7a03590..efa3b5b218 100644 --- a/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackSender.swift +++ b/DuckDuckGo/Subscription/Feedback/UnifiedFeedbackSender.swift @@ -62,6 +62,7 @@ extension UnifiedFeedbackSender { onComplete: completionHandler) case .dailyAndCount: DailyPixel.fireDailyAndCount(pixel: pixel, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, withAdditionalParameters: Self.additionalParameters(for: pixel), onDailyComplete: { _ in }, onCountComplete: completionHandler) diff --git a/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift b/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift index 69db14249d..178c07bd2f 100644 --- a/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift +++ b/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift @@ -217,7 +217,8 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec func subscriptionSelected(params: Any, original: WKScriptMessage) async -> Encodable? { - DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseAttempt) + DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseAttempt, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) setTransactionError(nil) setTransactionStatus(.purchasing) resetSubscriptionFlow() @@ -275,7 +276,8 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec switch await appStorePurchaseFlow.completeSubscriptionPurchase(with: purchaseTransactionJWS) { case .success(let purchaseUpdate): Logger.subscription.debug("Subscription purchase completed successfully") - DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseSuccess) + DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseSuccess, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) UniquePixel.fire(pixel: .privacyProSubscriptionActivated) Pixel.fireAttribution(pixel: .privacyProSuccessfulSubscriptionAttribution, origin: subscriptionAttributionOrigin, privacyProDataReporter: privacyProDataReporter) setTransactionStatus(.idle) diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift index aba3be0689..75b49918cb 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionEmailViewModel.swift @@ -151,7 +151,8 @@ final class SubscriptionEmailViewModel: ObservableObject { // Feature Callback subFeature.onSetSubscription = { - DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseEmailSuccess) + DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseEmailSuccess, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) UniquePixel.fire(pixel: .privacyProSubscriptionActivated) DispatchQueue.main.async { self.state.subscriptionActive = true diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift index af64bda56c..04a59ff21c 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionFlowViewModel.swift @@ -183,24 +183,28 @@ final class SubscriptionFlowViewModel: ObservableObject { case .cancelledByUser: state.transactionError = nil case .accountCreationFailed: - DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseFailureAccountNotCreated) + DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseFailureAccountNotCreated, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) state.transactionError = .generalError default: state.transactionError = .generalError } if isStoreError { - DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseFailureStoreError) + DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseFailureStoreError, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } if isBackendError { - DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseFailureBackendError) + DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseFailureBackendError, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } if state.transactionError != .hasActiveSubscription && state.transactionError != .cancelledByUser { // The observer of `transactionError` does the same calculation, if the error is anything else than .hasActiveSubscription then shows a "Something went wrong" alert - DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseFailure) + DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseFailure, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } } diff --git a/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift b/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift index 2940149fe3..749a650c14 100644 --- a/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift +++ b/DuckDuckGo/Subscription/ViewModel/SubscriptionRestoreViewModel.swift @@ -113,9 +113,11 @@ final class SubscriptionRestoreViewModel: ObservableObject { } if state.activationResult == .notFound { - DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseStoreFailureNotFound) + DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseStoreFailureNotFound, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } else { - DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseStoreFailureOther) + DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseStoreFailureOther, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } } @@ -126,13 +128,15 @@ final class SubscriptionRestoreViewModel: ObservableObject { @MainActor func restoreAppstoreTransaction() { - DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseStoreStart) + DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseStoreStart, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) Task { state.transactionStatus = .restoring state.activationResult = .unknown do { try await subFeature.restoreAccountFromAppStorePurchase() - DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseStoreSuccess) + DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseStoreSuccess, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) state.activationResult = .activated state.transactionStatus = .idle } catch let error { diff --git a/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift b/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift index 9b8bc29348..5982b085de 100644 --- a/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift +++ b/DuckDuckGo/Subscription/Views/SubscriptionRestoreView.swift @@ -176,7 +176,8 @@ struct SubscriptionRestoreView: View { .foregroundColor(Color(designSystemColor: .textSecondary)) getCellButton(buttonText: UserText.subscriptionActivateEmailButton, action: { - DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseEmailStart) + DailyPixel.fireDailyAndCount(pixel: .privacyProRestorePurchaseEmailStart, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) DailyPixel.fire(pixel: .privacyProWelcomeAddDevice) viewModel.showActivationFlow(true) }) diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index cf77d62e38..772bc1e760 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -1429,7 +1429,9 @@ extension TabViewController: WKNavigationDelegate { urlProvidedBasicAuthCredential = nil if webView.url?.isDuckDuckGoSearch == true, case .connected = netPConnectionStatus { - DailyPixel.fireDailyAndCount(pixel: .networkProtectionEnabledOnSearch, includedParameters: [.appVersion, .atb]) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionEnabledOnSearch, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + includedParameters: [.appVersion, .atb]) } specialErrorPageUserScript?.isEnabled = webView.url == failedURL diff --git a/DuckDuckGoTests/DailyPixelTests.swift b/DuckDuckGoTests/DailyPixelTests.swift index 374e3e83bc..61dd1849e6 100644 --- a/DuckDuckGoTests/DailyPixelTests.swift +++ b/DuckDuckGoTests/DailyPixelTests.swift @@ -18,8 +18,6 @@ // import XCTest -import OHHTTPStubs -import OHHTTPStubsSwift import Networking import TestUtils import Persistence @@ -215,6 +213,7 @@ final class DailyPixelTests: XCTestCase { DailyPixel.fireDailyAndCount( pixel: .forgetAllPressedBrowsing, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, pixelFiring: PixelFiringMock.self, dailyPixelStore: mockStore, onDailyComplete: { error in @@ -241,6 +240,7 @@ final class DailyPixelTests: XCTestCase { DailyPixel.fireDailyAndCount( pixel: .forgetAllPressedBrowsing, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, pixelFiring: PixelFiringMock.self, dailyPixelStore: mockStore, onDailyComplete: { error in @@ -251,6 +251,7 @@ final class DailyPixelTests: XCTestCase { DailyPixel.fireDailyAndCount( pixel: .forgetAllPressedBrowsing, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, pixelFiring: PixelFiringMock.self, dailyPixelStore: mockStore, onDailyComplete: { error in @@ -276,6 +277,7 @@ final class DailyPixelTests: XCTestCase { DailyPixel.fireDailyAndCount( pixel: .forgetAllPressedBrowsing, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, pixelFiring: PixelFiringMock.self, dailyPixelStore: mockStore, onDailyComplete: { error in @@ -301,6 +303,7 @@ final class DailyPixelTests: XCTestCase { DailyPixel.fireDailyAndCount( pixel: .forgetAllPressedBrowsing, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, pixelFiring: PixelFiringMock.self, dailyPixelStore: mockStore, onCountComplete: { error in @@ -311,6 +314,7 @@ final class DailyPixelTests: XCTestCase { DailyPixel.fireDailyAndCount( pixel: .forgetAllPressedBrowsing, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, pixelFiring: PixelFiringMock.self, dailyPixelStore: mockStore, onCountComplete: { error in @@ -334,6 +338,7 @@ final class DailyPixelTests: XCTestCase { DailyPixel.fireDailyAndCount( pixel: .forgetAllPressedBrowsing, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, pixelFiring: PixelFiringMock.self, dailyPixelStore: mockStore, onDailyComplete: { error in @@ -349,19 +354,14 @@ final class DailyPixelTests: XCTestCase { wait(for: [expectation], timeout: 3.0) } - func testThatDailyPixelWithCountWillAppendDToPixelNameForDaily() { + func testThatDailyPixelWithLegacyPixelSuffixAndCountWillAppendDAndC() { let expectation = XCTestExpectation() - stub { request in - request.url?.absoluteString.contains(Pixel.Event.forgetAllPressedBrowsing.name + "_d") == true - } response: { _ in - return HTTPStubsResponse(data: Data(), statusCode: 200, headers: nil) - } - updateLastFireDateToYesterday(for: .forgetAllPressedBrowsing) DailyPixel.fireDailyAndCount( pixel: .forgetAllPressedBrowsing, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, pixelFiring: PixelFiringMock.self, dailyPixelStore: mockStore, onCountComplete: { error in @@ -371,16 +371,37 @@ final class DailyPixelTests: XCTestCase { ) wait(for: [expectation], timeout: 3.0) + + XCTAssertEqual(PixelFiringMock.allPixelsFired.count, 2) + XCTAssertEqual(PixelFiringMock.allPixelsFired[0].pixelName, Pixel.Event.forgetAllPressedBrowsing.name + "_d") + XCTAssertEqual(PixelFiringMock.allPixelsFired[1].pixelName, Pixel.Event.forgetAllPressedBrowsing.name + "_c") } - func testThatDailyPixelWithCountWillAppendCToPixelNameForCount() { + func testThatDailyPixelWithModernPixelSuffixesWillAppendDailyAndCount() { let expectation = XCTestExpectation() - stub { request in - request.url?.absoluteString.contains(Pixel.Event.forgetAllPressedBrowsing.name + "_c") == true - } response: { _ in - return HTTPStubsResponse(data: Data(), statusCode: 200, headers: nil) - } + updateLastFireDateToYesterday(for: .forgetAllPressedBrowsing) + + DailyPixel.fireDailyAndCount( + pixel: .forgetAllPressedBrowsing, + pixelNameSuffixes: DailyPixel.Constant.dailyPixelSuffixes, + pixelFiring: PixelFiringMock.self, + dailyPixelStore: mockStore, + onCountComplete: { error in + XCTAssertNil(error) + expectation.fulfill() + } + ) + + wait(for: [expectation], timeout: 3.0) + + XCTAssertEqual(PixelFiringMock.allPixelsFired.count, 2) + XCTAssertEqual(PixelFiringMock.allPixelsFired[0].pixelName, Pixel.Event.forgetAllPressedBrowsing.name + "_daily") + XCTAssertEqual(PixelFiringMock.allPixelsFired[1].pixelName, Pixel.Event.forgetAllPressedBrowsing.name + "_count") + } + + func testThatDailyPixelWithDefaultPixelSuffixesWillAppendDailyAndCount() { + let expectation = XCTestExpectation() updateLastFireDateToYesterday(for: .forgetAllPressedBrowsing) @@ -388,15 +409,19 @@ final class DailyPixelTests: XCTestCase { pixel: .forgetAllPressedBrowsing, pixelFiring: PixelFiringMock.self, dailyPixelStore: mockStore, - onDailyComplete: { error in + onCountComplete: { error in XCTAssertNil(error) expectation.fulfill() } ) wait(for: [expectation], timeout: 3.0) + + XCTAssertEqual(PixelFiringMock.allPixelsFired.count, 2) + XCTAssertEqual(PixelFiringMock.allPixelsFired[0].pixelName, Pixel.Event.forgetAllPressedBrowsing.name + "_daily") + XCTAssertEqual(PixelFiringMock.allPixelsFired[1].pixelName, Pixel.Event.forgetAllPressedBrowsing.name + "_count") } - + private func updateLastFireDateToYesterday(for pixel: Pixel.Event) { let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: Date()) mockStore.set(yesterday, forKey: pixel.name) diff --git a/DuckDuckGoTests/DefaultPersistentPixelStorageTests.swift b/DuckDuckGoTests/DefaultPersistentPixelStorageTests.swift new file mode 100644 index 0000000000..7ddb6077f9 --- /dev/null +++ b/DuckDuckGoTests/DefaultPersistentPixelStorageTests.swift @@ -0,0 +1,132 @@ +// +// DefaultPersistentPixelStorageTests.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 XCTest +@testable import Core + +class DefaultPersistentPixelStorageTests: XCTestCase { + + var currentStorageURL: URL! + var persistentStorage: DefaultPersistentPixelStorage! + + override func setUp() { + super.setUp() + let (url, storage) = createPersistentStorage() + self.currentStorageURL = url + self.persistentStorage = storage + } + + override func tearDown() { + super.tearDown() + try? FileManager.default.removeItem(at: currentStorageURL) + } + + func testWhenStoringPixel_ThenPixelCanBeSuccessfullyRead() throws { + let metadata = event(named: "test", parameters: ["param": "value"]) + try persistentStorage.append(pixels: [metadata]) + let storedPixels = try persistentStorage.storedPixels() + + XCTAssertEqual([metadata], storedPixels) + } + + func testWhenStoringMultiplePixels_ThenPixelsCanBeSuccessfullyRead() throws { + let metadata1 = event(named: "test1", parameters: ["param1": "value1"]) + let metadata2 = event(named: "test2", parameters: ["param2": "value2"]) + let metadata3 = event(named: "test3", parameters: ["param3": "value3"]) + + try persistentStorage.append(pixels: [metadata1, metadata2, metadata3]) + + let storedPixels = try persistentStorage.storedPixels() + + XCTAssertEqual([metadata1, metadata2, metadata3], storedPixels) + } + + func testWhenStoringMorePixelsThanTheLimit_AndPixelsAreAddedIncrementally_ThenOldPixelsAreDropped() throws { + for index in 1...(DefaultPersistentPixelStorage.Constants.pixelCountLimit + 50) { + let metadata = event(named: "pixel\(index)", parameters: ["param\(index)": "value\(index)"]) + try persistentStorage.append(pixels: [metadata]) + } + + let storedPixels = try persistentStorage.storedPixels() + + XCTAssertEqual(storedPixels.count, DefaultPersistentPixelStorage.Constants.pixelCountLimit) + XCTAssertEqual(storedPixels.first?.eventName, "pixel51") + XCTAssertEqual(storedPixels.last?.eventName, "pixel150") + } + + func testWhenStoringMorePixelsThanTheLimit_AndPixelsAreAddedInASingleBatch_ThenOldPixelsAreDropped() throws { + var pixelsToAdd: [PersistentPixelMetadata] = [] + + for index in 1...(DefaultPersistentPixelStorage.Constants.pixelCountLimit + 50) { + let metadata = event(named: "pixel\(index)", parameters: ["param\(index)": "value\(index)"]) + pixelsToAdd.append(metadata) + } + + try persistentStorage.append(pixels: pixelsToAdd) + + let storedPixels = try persistentStorage.storedPixels() + + XCTAssertEqual(storedPixels.count, DefaultPersistentPixelStorage.Constants.pixelCountLimit) + XCTAssertEqual(storedPixels.first?.eventName, "pixel51") + XCTAssertEqual(storedPixels.last?.eventName, "pixel150") + } + + func testWhenRemovingPixels_AndNoPixelsAreStored_ThenNothingHappens() throws { + try persistentStorage.remove(pixelsWithIDs: Set([UUID()])) + let storedPixels = try persistentStorage.storedPixels() + + XCTAssertEqual([], storedPixels) + } + + func testWhenRemovingPixels_AndIDDoesNotMatchStoredPixel_ThenNothingHappens() throws { + let metadata = event(named: "test", parameters: ["param": "value"]) + try persistentStorage.append(pixels: [metadata]) + try persistentStorage.remove(pixelsWithIDs: Set([UUID()])) + let storedPixels = try persistentStorage.storedPixels() + + XCTAssertEqual([metadata], storedPixels) + } + + func testWhenRemovingPixels_AndIDMatchesStoredPixel_ThenPixelIsRemoved() throws { + let metadata = event(named: "test", parameters: ["param": "value"]) + try persistentStorage.remove(pixelsWithIDs: Set([metadata.id])) + let storedPixels = try persistentStorage.storedPixels() + + XCTAssertEqual([], storedPixels) + } + + + // MARK: - Test Utilities + + private func createPersistentStorage() -> (URL, DefaultPersistentPixelStorage) { + let storageDirectory = FileManager.default.temporaryDirectory + let fileName = UUID().uuidString.appendingPathExtension("json") + + return ( + storageDirectory.appendingPathComponent(fileName), + DefaultPersistentPixelStorage(fileName: fileName, storageDirectory: storageDirectory) + ) + } + + private func event(named name: String, parameters: [String: String]) -> PersistentPixelMetadata { + return PersistentPixelMetadata(eventName: name, additionalParameters: parameters, includedParameters: [.appVersion]) + } + +} diff --git a/DuckDuckGoTests/MockDependencyProvider.swift b/DuckDuckGoTests/MockDependencyProvider.swift index 45265faa53..05e28803f7 100644 --- a/DuckDuckGoTests/MockDependencyProvider.swift +++ b/DuckDuckGoTests/MockDependencyProvider.swift @@ -47,6 +47,7 @@ class MockDependencyProvider: DependencyProvider { var connectionObserver: NetworkProtection.ConnectionStatusObserver var serverInfoObserver: NetworkProtection.ConnectionServerInfoObserver var vpnSettings: NetworkProtection.VPNSettings + var persistentPixel: PersistentPixelFiring init() { let defaultProvider = AppDependencyProvider.makeTestingInstance() @@ -78,12 +79,14 @@ class MockDependencyProvider: DependencyProvider { let accessTokenProvider: () -> String? = { { "sometoken" } }() networkProtectionKeychainTokenStore = NetworkProtectionKeychainTokenStore(accessTokenProvider: accessTokenProvider) networkProtectionTunnelController = NetworkProtectionTunnelController(accountManager: accountManager, - tokenStore: networkProtectionKeychainTokenStore) + tokenStore: networkProtectionKeychainTokenStore, + persistentPixel: MockPersistentPixel()) vpnFeatureVisibility = DefaultNetworkProtectionVisibility(userDefaults: .networkProtectionGroupDefaults, accountManager: accountManager) connectionObserver = ConnectionStatusObserverThroughSession() serverInfoObserver = ConnectionServerInfoObserverThroughSession() vpnSettings = VPNSettings(defaults: .networkProtectionGroupDefaults) + persistentPixel = MockPersistentPixel() } } diff --git a/DuckDuckGoTests/MockPersistentPixel.swift b/DuckDuckGoTests/MockPersistentPixel.swift new file mode 100644 index 0000000000..b7615abb84 --- /dev/null +++ b/DuckDuckGoTests/MockPersistentPixel.swift @@ -0,0 +1,67 @@ +// +// MockPersistentPixel.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 +@testable import Core + +final class MockPersistentPixel: PersistentPixelFiring { + + var expectedFireError: Error? + var expectedDailyPixelStorageError: Error? + var expectedCountPixelStorageError: Error? + + var lastPixelInfo: PixelInfo? + var lastDailyPixelInfo: PixelInfo? + + var lastParams: [String: String]? { lastPixelInfo?.params } + var lastPixelName: String? { lastPixelInfo?.pixelName } + var lastIncludedParams: [Pixel.QueryParameters]? { lastPixelInfo?.includedParams } + + func tearDown() { + lastPixelInfo = nil + lastDailyPixelInfo = nil + expectedFireError = nil + expectedDailyPixelStorageError = nil + expectedCountPixelStorageError = nil + } + + func fire(pixel: Core.Pixel.Event, + error: (any Error)?, + includedParameters: [Core.Pixel.QueryParameters], + withAdditionalParameters params: [String: String], + onComplete: @escaping ((any Error)?) -> Void) { + self.lastPixelInfo = .init(pixelName: pixel.name, error: error, params: params, includedParams: includedParameters) + onComplete(expectedFireError) + } + + func fireDailyAndCount(pixel: Core.Pixel.Event, + pixelNameSuffixes: (dailySuffix: String, countSuffix: String), + error: (any Error)?, + withAdditionalParameters params: [String: String], + includedParameters: [Core.Pixel.QueryParameters], + completion: @escaping ((dailyPixelStorageError: Error?, countPixelStorageError: Error?)) -> Void) { + self.lastDailyPixelInfo = .init(pixelName: pixel.name, error: error, params: params, includedParams: includedParameters) + completion((expectedDailyPixelStorageError, expectedCountPixelStorageError)) + } + + func sendQueuedPixels(completion: @escaping (Core.PersistentPixelStorageError?) -> Void) { + completion(nil) + } + +} diff --git a/DuckDuckGoTests/MockPixelFiring.swift b/DuckDuckGoTests/MockPixelFiring.swift index 8eb632c92a..a0409c38ba 100644 --- a/DuckDuckGoTests/MockPixelFiring.swift +++ b/DuckDuckGoTests/MockPixelFiring.swift @@ -19,6 +19,8 @@ import Foundation import Core +import Networking +import Persistence struct PixelInfo { let pixelName: String? @@ -38,8 +40,10 @@ struct PixelInfo { } final actor PixelFiringMock: PixelFiring, PixelFiringAsync, DailyPixelFiring { - + static var expectedFireError: Error? + static var expectedDailyPixelFireError: Error? + static var expectedCountPixelFireError: Error? static var allPixelsFired = [PixelInfo]() @@ -119,6 +123,21 @@ final actor PixelFiringMock: PixelFiring, PixelFiringAsync, DailyPixelFiring { allPixelsFired.append(info) } + static func fireDailyAndCount(pixel: Pixel.Event, + pixelNameSuffixes: (dailySuffix: String, countSuffix: String), + error: (any Error)?, + withAdditionalParameters params: [String: String], + includedParameters: [Core.Pixel.QueryParameters], + pixelFiring: any PixelFiring.Type, + dailyPixelStore: any Persistence.KeyValueStoring, + onDailyComplete: @escaping ((any Error)?) -> Void, + onCountComplete: @escaping ((any Error)?) -> Void) { + lastDailyPixelInfo = PixelInfo(pixelName: pixel.name, error: error, params: params, includedParams: includedParameters) + + onDailyComplete(expectedDailyPixelFireError) + onCountComplete(expectedCountPixelFireError) + } + // - static func tearDown() { @@ -126,7 +145,78 @@ final actor PixelFiringMock: PixelFiring, PixelFiringAsync, DailyPixelFiring { lastPixelInfo = nil lastDailyPixelInfo = nil expectedFireError = nil + expectedDailyPixelFireError = nil + expectedCountPixelFireError = nil } private init() {} } + +class DelayedPixelFiringMock: PixelFiring { + + static var lastPixelInfo: PixelInfo? + static var lastParams: [String: String]? { lastPixelInfo?.params } + static var lastPixel: String? { lastPixelInfo?.pixelName } + static var lastIncludedParams: [Pixel.QueryParameters]? { lastPixelInfo?.includedParams } + static var completionHandlerUpdateClosure: ((Int) -> Void)? + + static var completionError: Error? + static var lastCompletionHandlers: [(Error?) -> Void] = [] { + didSet { + completionHandlerUpdateClosure?(lastCompletionHandlers.count) + } + } + + static func tearDown() { + lastPixelInfo = nil + completionError = nil + completionHandlerUpdateClosure = nil + lastCompletionHandlers = [] + } + + static func callCompletionHandler() { + for completionHandler in lastCompletionHandlers { + completionHandler(completionError) + } + } + + static func fire(_ pixel: Core.Pixel.Event, + withAdditionalParameters params: [String: String], + includedParameters: [Core.Pixel.QueryParameters], + onComplete: @escaping ((any Error)?) -> Void) { + self.fire(pixelNamed: pixel.name, withAdditionalParameters: params, includedParameters: includedParameters, onComplete: onComplete) + } + + static func fire(pixelNamed pixelName: String, + withAdditionalParameters params: [String: String], + includedParameters: [Core.Pixel.QueryParameters], + onComplete: @escaping ((any Error)?) -> Void) { + lastPixelInfo = PixelInfo(pixelName: pixelName, error: nil, params: params, includedParams: includedParameters) + lastCompletionHandlers.append(onComplete) + } + + static func fire(_ pixel: Core.Pixel.Event, withAdditionalParameters params: [String: String]) { + lastPixelInfo = PixelInfo(pixelName: pixel.name, error: nil, params: params, includedParams: nil) + } + + static func fire(pixelNamed pixelName: String, + forDeviceType deviceType: UIUserInterfaceIdiom?, + withAdditionalParameters params: [String: String], + allowedQueryReservedCharacters: CharacterSet?, + withHeaders headers: Networking.APIRequest.Headers, + includedParameters: [Core.Pixel.QueryParameters], + onComplete: @escaping ((any Error)?) -> Void) { + lastPixelInfo = PixelInfo(pixelName: pixelName, error: nil, params: params, includedParams: includedParameters) + lastCompletionHandlers.append(onComplete) + } + + static func fire(pixel: Pixel.Event, + error: Error?, + includedParameters: [Pixel.QueryParameters], + withAdditionalParameters params: [String: String], + onComplete: @escaping (Error?) -> Void) { + lastPixelInfo = PixelInfo(pixelName: pixel.name, error: nil, params: params, includedParams: includedParameters) + lastCompletionHandlers.append(onComplete) + } + +} diff --git a/DuckDuckGoTests/PersistentPixelTests.swift b/DuckDuckGoTests/PersistentPixelTests.swift new file mode 100644 index 0000000000..1112aa0afc --- /dev/null +++ b/DuckDuckGoTests/PersistentPixelTests.swift @@ -0,0 +1,436 @@ +// +// PersistentPixelTests.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 XCTest +import Networking +import Persistence +import TestUtils +@testable import Core + +final class PersistentPixelTests: XCTestCase { + + var currentStorageURL: URL! + var persistentStorage: DefaultPersistentPixelStorage! + var timestampStorage: KeyValueStoring! + + var testDateString: String! + var oldDateString: String! + + override func setUp() { + super.setUp() + let (url, storage) = createPersistentStorage() + self.currentStorageURL = url + self.persistentStorage = storage + self.timestampStorage = MockKeyValueStore() + + PixelFiringMock.tearDown() + DelayedPixelFiringMock.tearDown() + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + testDateString = formatter.string(from: Date()) + oldDateString = formatter.string(from: Date().addingTimeInterval(-.days(30))) + } + + override func tearDown() { + super.tearDown() + try? FileManager.default.removeItem(at: currentStorageURL) + + PixelFiringMock.tearDown() + DelayedPixelFiringMock.tearDown() + } + + func testWhenDailyAndCountPixelsSendSuccessfully_ThenNoPixelsAreStored() throws { + let persistentPixel = createPersistentPixel() + let expectation = expectation(description: "fireDailyAndCount") + + persistentPixel.fireDailyAndCount( + pixel: .networkProtectionMemoryWarning, + withAdditionalParameters: ["key": "value"], + includedParameters: [.appVersion, .atb], + completion: { errors in + expectation.fulfill() + XCTAssertNil(errors.dailyPixelStorageError) + XCTAssertNil(errors.countPixelStorageError) + } + ) + + wait(for: [expectation], timeout: 1.0) + + let storedPixels = try persistentStorage.storedPixels() + XCTAssertEqual(storedPixels, []) + + XCTAssertEqual(PixelFiringMock.lastDailyPixelInfo?.pixelName, Pixel.Event.networkProtectionMemoryWarning.name) + XCTAssertEqual(PixelFiringMock.lastDailyPixelInfo?.params, ["key": "value", PixelParameters.originalPixelTimestamp: testDateString]) + XCTAssertEqual(PixelFiringMock.lastDailyPixelInfo?.includedParams, [.appVersion, .atb]) + } + + func testWhenDailyPixelFailsDueToAlreadySentError_ThenNoPixelIsStored() throws { + PixelFiringMock.expectedDailyPixelFireError = DailyPixel.Error.alreadyFired // This is expected behaviour from the daily pixel + + let persistentPixel = createPersistentPixel() + let error = NSError(domain: "domain", code: 1) + let expectation = expectation(description: "fireDailyAndCount") + + persistentPixel.fireDailyAndCount( + pixel: .appLaunch, + error: error, + withAdditionalParameters: ["param": "value"], + includedParameters: [.appVersion], + completion: { errors in + expectation.fulfill() + XCTAssertNil(errors.dailyPixelStorageError) + XCTAssertNil(errors.countPixelStorageError) + } + ) + + wait(for: [expectation], timeout: 1.0) + + let storedPixels = try persistentStorage.storedPixels() + XCTAssert(storedPixels.isEmpty) + } + + func testWhenDailyAndCountPixelsFail_ThenPixelsAreStored() throws { + PixelFiringMock.expectedDailyPixelFireError = NSError(domain: "PixelFailure", code: 1) + PixelFiringMock.expectedCountPixelFireError = NSError(domain: "PixelFailure", code: 2) + + let persistentPixel = createPersistentPixel() + let error = NSError(domain: "domain", code: 1) + let expectation = expectation(description: "fireDailyAndCount") + + persistentPixel.fireDailyAndCount( + pixel: .appLaunch, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: error, + withAdditionalParameters: ["param": "value"], + includedParameters: [.appVersion], + completion: { errors in + expectation.fulfill() + XCTAssertNil(errors.dailyPixelStorageError) + XCTAssertNil(errors.countPixelStorageError) + } + ) + + wait(for: [expectation], timeout: 1.0) + + let storedPixels = try persistentStorage.storedPixels() + let expectedParams = [ + "param": "value", + PixelParameters.originalPixelTimestamp: testDateString, + PixelParameters.errorDomain: error.domain, + PixelParameters.errorCode: "\(error.code)" + ] + + XCTAssertEqual(storedPixels.count, 2) + XCTAssert(storedPixels.contains { + $0.eventName == Pixel.Event.appLaunch.name + DailyPixel.Constant.legacyDailyPixelSuffixes.countSuffix && + $0.additionalParameters == expectedParams + }) + + XCTAssert(storedPixels.contains { + $0.eventName == Pixel.Event.appLaunch.name + DailyPixel.Constant.legacyDailyPixelSuffixes.dailySuffix && + $0.additionalParameters == expectedParams + }) + + XCTAssertEqual(PixelFiringMock.lastDailyPixelInfo?.pixelName, Pixel.Event.appLaunch.name) + XCTAssertEqual(PixelFiringMock.lastDailyPixelInfo?.params, ["param": "value", PixelParameters.originalPixelTimestamp: testDateString]) + XCTAssertEqual(PixelFiringMock.lastDailyPixelInfo?.includedParams, [.appVersion]) + } + + func testWhenOnlyCountPixelFails_ThenCountPixelIsStored() throws { + PixelFiringMock.expectedCountPixelFireError = NSError(domain: "PixelFailure", code: 1) + + let persistentPixel = createPersistentPixel() + let expectation = expectation(description: "fireDailyAndCount") + + persistentPixel.fireDailyAndCount( + pixel: .appLaunch, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + withAdditionalParameters: ["param": "value"], + includedParameters: [.appVersion], + completion: { errors in + expectation.fulfill() + XCTAssertNil(errors.dailyPixelStorageError) + XCTAssertNil(errors.countPixelStorageError) + } + ) + + wait(for: [expectation], timeout: 1.0) + + let storedPixels = try persistentStorage.storedPixels() + XCTAssertEqual(storedPixels.count, 1) + XCTAssert(storedPixels.contains { + $0.eventName == Pixel.Event.appLaunch.name + DailyPixel.Constant.legacyDailyPixelSuffixes.countSuffix && + $0.additionalParameters == ["param": "value", PixelParameters.originalPixelTimestamp: testDateString] + }) + + XCTAssertEqual(PixelFiringMock.lastDailyPixelInfo?.pixelName, Pixel.Event.appLaunch.name) + XCTAssertEqual(PixelFiringMock.lastDailyPixelInfo?.params, ["param": "value", PixelParameters.originalPixelTimestamp: testDateString]) + XCTAssertEqual(PixelFiringMock.lastDailyPixelInfo?.includedParams, [.appVersion]) + } + + func testWhenPixelsAreStored_AndSendQueuedPixelsIsCalled_AndPixelRetrySucceeds_ThenPixelsAreRemovedFromStorage() throws { + let persistentPixel = createPersistentPixel() + let expectation = expectation(description: "sendQueuedPixels") + + let params = ["key": "value", PixelParameters.originalPixelTimestamp: testDateString!] + let pixel = PersistentPixelMetadata(eventName: "test1", additionalParameters: params, includedParameters: [.appVersion]) + let pixel2 = PersistentPixelMetadata(eventName: "test2", additionalParameters: params, includedParameters: [.appVersion]) + let pixel3 = PersistentPixelMetadata(eventName: "test3", additionalParameters: params, includedParameters: [.appVersion]) + let pixel4 = PersistentPixelMetadata(eventName: "test4", additionalParameters: params, includedParameters: [.appVersion]) + + try persistentStorage.append(pixels: [pixel, pixel2, pixel3, pixel4]) + persistentPixel.sendQueuedPixels { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: 3.0) + + let storedPixels = try persistentStorage.storedPixels() + XCTAssert(storedPixels.isEmpty) + } + + func testWhenPixelIsStored_AndSendQueuedPixelsIsCalled_ThenPixelIsSent() throws { + let persistentPixel = createPersistentPixel() + let expectation = expectation(description: "sendQueuedPixels") + + let pixel = PersistentPixelMetadata( + eventName: "test", + additionalParameters: ["key": "value", PixelParameters.originalPixelTimestamp: testDateString], + includedParameters: [.appVersion] + ) + + try persistentStorage.append(pixels: [pixel]) + persistentPixel.sendQueuedPixels { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: 3.0) + + let storedPixels = try persistentStorage.storedPixels() + XCTAssert(storedPixels.isEmpty) + + XCTAssertEqual(PixelFiringMock.lastPixelName, "test") + XCTAssertEqual(PixelFiringMock.lastPixelInfo?.params, [ + "key": "value", + PixelParameters.retriedPixel: "1", + PixelParameters.originalPixelTimestamp: testDateString + ]) + XCTAssertEqual(PixelFiringMock.lastPixelInfo?.includedParams, [.appVersion]) + } + + func testWhenPixelIsStored_AndSendQueuedPixelsIsCalled_AndPixelIsOlderThan28Days_ThenPixelIsNotSent_AndPixelIsNoLongerStored() throws { + let persistentPixel = createPersistentPixel() + let expectation = expectation(description: "sendQueuedPixels") + + let pixel = PersistentPixelMetadata( + eventName: "test", + additionalParameters: ["key": "value", PixelParameters.originalPixelTimestamp: oldDateString], + includedParameters: [.appVersion] + ) + + try persistentStorage.append(pixels: [pixel]) + persistentPixel.sendQueuedPixels { _ in + expectation.fulfill() + } + + wait(for: [expectation], timeout: 3.0) + + let storedPixels = try persistentStorage.storedPixels() + XCTAssert(storedPixels.isEmpty) + + XCTAssertNil(PixelFiringMock.lastPixelName) + XCTAssertNil(PixelFiringMock.lastDailyPixelInfo) + } + + func testWhenPixelQueueIsProcessing_AndNewFailedPixelIsReceived_ThenPixelIsStoredEvenIfProcessingIsActive() throws { + PixelFiringMock.expectedCountPixelFireError = NSError(domain: "PixelFailure", code: 1) + + let persistentPixel = createPersistentPixel(pixelFiring: DelayedPixelFiringMock.self) + let sendQueuedPixelsExpectation = expectation(description: "sendQueuedPixels") + + let initialPixel = PersistentPixelMetadata( + eventName: "test", + additionalParameters: [PixelParameters.originalPixelTimestamp: testDateString], + includedParameters: [.appVersion] + ) + + try persistentStorage.append(pixels: [initialPixel]) + + // Wait for the queued pixel completion handlers to be received by the mock: + let delayedPixelPendingClosureExpectation = expectation(description: "completionHandlerUpdateClosure") + DelayedPixelFiringMock.completionHandlerUpdateClosure = { count in + if count == 1 { + delayedPixelPendingClosureExpectation.fulfill() + } + } + + // Initiate pixel queue processing: + persistentPixel.sendQueuedPixels { _ in + sendQueuedPixelsExpectation.fulfill() + } + + wait(for: [delayedPixelPendingClosureExpectation], timeout: 3.0) + + // Trigger a failed pixel call while processing, and wait for it to complete: + let dailyCountPixelExpectation = expectation(description: "sendQueuedPixels") + persistentPixel.fireDailyAndCount(pixel: .appLaunch, withAdditionalParameters: [:], includedParameters: [.appVersion], completion: { _ in + dailyCountPixelExpectation.fulfill() + }) + wait(for: [dailyCountPixelExpectation], timeout: 3.0) + + // Check that the new failed pixel call caused a pixel to get stored: + let storedPixelsWhenSendingQueuedPixels = try persistentStorage.storedPixels() + XCTAssertEqual(storedPixelsWhenSendingQueuedPixels.count, 2) + XCTAssert(storedPixelsWhenSendingQueuedPixels.contains(initialPixel)) + + // Complete pixel processing callback: + DelayedPixelFiringMock.callCompletionHandler() + + wait(for: [sendQueuedPixelsExpectation], timeout: 3.0) + + let storedPixelsAfterSendingQueuedPixels = try persistentStorage.storedPixels() + + XCTAssertEqual(storedPixelsAfterSendingQueuedPixels.count, 1) + XCTAssert(storedPixelsAfterSendingQueuedPixels.contains(where: { pixel in + return pixel.eventName == Pixel.Event.appLaunch.name + DailyPixel.Constant.dailyPixelSuffixes.countSuffix + && pixel.additionalParameters == [PixelParameters.originalPixelTimestamp: testDateString] + && pixel.includedParameters == [.appVersion] + })) + } + + func testWhenPixelQueueIsRetrying_AndNewFailedPixelIsReceived_AndRetryingFails_ThenExistingAndNewPixelsAreStored() throws { + PixelFiringMock.expectedCountPixelFireError = NSError(domain: "PixelFailure", code: 1) + DelayedPixelFiringMock.completionError = NSError(domain: "PixelFailure", code: 1) + + let persistentPixel = createPersistentPixel(pixelFiring: DelayedPixelFiringMock.self) + let initialPixel = PersistentPixelMetadata( + eventName: "test", + additionalParameters: [PixelParameters.originalPixelTimestamp: testDateString], + includedParameters: [.appVersion] + ) + + try persistentStorage.append(pixels: [initialPixel]) + + // Wait for the queued pixel completion handlers to be received by the mock: + let delayedPixelPendingClosureExpectation = expectation(description: "completionHandlerUpdateClosure") + DelayedPixelFiringMock.completionHandlerUpdateClosure = { count in + if count == 1 { + delayedPixelPendingClosureExpectation.fulfill() + } + } + + // Initiate pixel queue processing: + let sendQueuedPixelsExpectation = expectation(description: "sendQueuedPixels") + persistentPixel.sendQueuedPixels { _ in + sendQueuedPixelsExpectation.fulfill() + } + + wait(for: [delayedPixelPendingClosureExpectation], timeout: 3.0) + + // Trigger a failed pixel call while processing, and wait for it to complete: + let dailyCountPixelExpectation = expectation(description: "daily/count pixel call") + persistentPixel.fireDailyAndCount(pixel: .appLaunch, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + withAdditionalParameters: [:], + includedParameters: [.appVersion], + completion: { _ in + dailyCountPixelExpectation.fulfill() + }) + wait(for: [dailyCountPixelExpectation], timeout: 3.0) + + // Check that the new failed pixel call caused a pixel to get stored: + let storedPixelsWhenSendingQueuedPixels = try persistentStorage.storedPixels() + XCTAssertEqual(storedPixelsWhenSendingQueuedPixels.count, 2) + XCTAssert(storedPixelsWhenSendingQueuedPixels.contains(initialPixel)) + + // Complete pixel processing callback: + DelayedPixelFiringMock.callCompletionHandler() + + wait(for: [sendQueuedPixelsExpectation], timeout: 3.0) + + let storedPixelsAfterSendingQueuedPixels = try persistentStorage.storedPixels() + XCTAssertEqual(storedPixelsAfterSendingQueuedPixels.count, 2) + XCTAssert(storedPixelsAfterSendingQueuedPixels.contains(initialPixel)) + XCTAssert(storedPixelsAfterSendingQueuedPixels.contains(where: { pixel in + return pixel.eventName == Pixel.Event.appLaunch.name + DailyPixel.Constant.legacyDailyPixelSuffixes.countSuffix + && pixel.additionalParameters == [PixelParameters.originalPixelTimestamp: testDateString] + && pixel.includedParameters == [.appVersion] + })) + } + + func testWhenPixelQueueHasRecentlyProcessed_ThenPixelsAreNotProcessed() throws { + let currentDate = Date() + let persistentPixel = createPersistentPixel(dateGenerator: { currentDate }) + let sendQueuedPixelsExpectation = expectation(description: "sendQueuedPixels") + + let pixel = PersistentPixelMetadata( + eventName: "unfired_pixel", + additionalParameters: [PixelParameters.originalPixelTimestamp: testDateString], + includedParameters: [.appVersion] + ) + + try persistentStorage.append(pixels: [pixel]) + + // Set a last processing date of 1 minute ago: + timestampStorage.set(currentDate.addingTimeInterval(-60), forKey: PersistentPixel.Constants.lastProcessingDateKey) + + persistentPixel.sendQueuedPixels { _ in + sendQueuedPixelsExpectation.fulfill() + } + + wait(for: [sendQueuedPixelsExpectation], timeout: 3.0) + + let storedPixelsAfterSendingQueuedPixels = try persistentStorage.storedPixels() + XCTAssertEqual(storedPixelsAfterSendingQueuedPixels, [pixel]) + XCTAssertNil(PixelFiringMock.lastPixelName) + } + + // MARK: - Test Utilities + + private func createPersistentPixel(pixelFiring: PixelFiring.Type = PixelFiringMock.self, + dailyPixelFiring: DailyPixelFiring.Type = PixelFiringMock.self, + dateGenerator: (() -> Date)? = nil) -> PersistentPixel { + return PersistentPixel( + pixelFiring: pixelFiring, + dailyPixelFiring: dailyPixelFiring, + persistentPixelStorage: persistentStorage, + lastProcessingDateStorage: timestampStorage, + dateGenerator: dateGenerator ?? self.dateGenerator + ) + } + + private func createPersistentStorage() -> (URL, DefaultPersistentPixelStorage) { + let storageDirectory = FileManager.default.temporaryDirectory + let fileName = UUID().uuidString.appendingPathExtension("json") + + return ( + storageDirectory.appendingPathComponent(fileName), + DefaultPersistentPixelStorage(fileName: fileName, storageDirectory: storageDirectory) + ) + } + + private func dateGenerator() -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: testDateString)! + } + +} diff --git a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift index 9704c74e4e..0d15c548ad 100644 --- a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift +++ b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift @@ -25,6 +25,7 @@ import Core import Networking import NetworkExtension import NetworkProtection +import os.log import Subscription import WidgetKit import WireGuard @@ -34,6 +35,7 @@ import BrowserServicesKit final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { private static var vpnLogger = VPNLogger() + private static let persistentPixel: PersistentPixelFiring = PersistentPixel() private var cancellables = Set() private let accountManager: AccountManager @@ -51,6 +53,10 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { DailyPixel.fire(pixel: .networkProtectionActiveUser, withAdditionalParameters: [PixelParameters.vpnCohort: UniquePixel.cohort(from: defaults.vpnFirstEnabled)], includedParameters: [.appVersion, .atb]) + + persistentPixel.sendQueuedPixels { error in + Logger.networkProtection.error("Failed to send queued pixels, with error: \(error)") + } case .connectionTesterStatusChange(let status, let server): vpnLogger.log(status, server: server) @@ -66,6 +72,7 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { }() DailyPixel.fireDailyAndCount(pixel: pixel, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, withAdditionalParameters: [PixelParameters.server: server], includedParameters: [.appVersion, .atb]) case .recovered(let duration, let failureCount): @@ -79,6 +86,7 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { }() DailyPixel.fireDailyAndCount(pixel: pixel, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, withAdditionalParameters: [ PixelParameters.count: String(failureCount), PixelParameters.server: server @@ -91,15 +99,18 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { switch attempt { case .connecting: DailyPixel.fireDailyAndCount(pixel: .networkProtectionEnableAttemptConnecting, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, includedParameters: [.appVersion, .atb]) case .success: let versionStore = NetworkProtectionLastVersionRunStore(userDefaults: .networkProtectionGroupDefaults) versionStore.lastExtensionVersionRun = AppVersion.shared.versionAndBuildNumber DailyPixel.fireDailyAndCount(pixel: .networkProtectionEnableAttemptSuccess, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, includedParameters: [.appVersion, .atb]) case .failure: DailyPixel.fireDailyAndCount(pixel: .networkProtectionEnableAttemptFailure, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, includedParameters: [.appVersion, .atb]) } case .reportTunnelFailure(result: let result): @@ -107,9 +118,13 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { switch result { case .failureDetected: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelFailureDetected, includedParameters: [.appVersion, .atb]) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelFailureDetected, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + includedParameters: [.appVersion, .atb]) case .failureRecovered: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelFailureRecovered, includedParameters: [.appVersion, .atb]) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelFailureRecovered, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + includedParameters: [.appVersion, .atb]) case .networkPathChanged(let newPath): defaults.updateNetworkPath(with: newPath) } @@ -121,29 +136,63 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { DailyPixel.fire(pixel: .networkProtectionLatencyError, includedParameters: [.appVersion, .atb]) case .quality(let quality): guard quality != .unknown else { return } - DailyPixel.fireDailyAndCount(pixel: .networkProtectionLatency(quality: quality), includedParameters: [.appVersion, .atb]) + DailyPixel.fireDailyAndCount( + pixel: .networkProtectionLatency(quality: quality), + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + includedParameters: [.appVersion, .atb] + ) } case .rekeyAttempt(let step): vpnLogger.log(step, named: "Rekey") switch step { case .begin: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionRekeyAttempt) + persistentPixel.fireDailyAndCount( + pixel: .networkProtectionRekeyAttempt, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: nil, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) { _ in } case .failure(let error): - DailyPixel.fireDailyAndCount(pixel: .networkProtectionRekeyFailure, error: error) + persistentPixel.fireDailyAndCount( + pixel: .networkProtectionRekeyFailure, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: error, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) { _ in } case .success: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionRekeyCompleted) + persistentPixel.fireDailyAndCount( + pixel: .networkProtectionRekeyCompleted, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: nil, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) { _ in } } case .tunnelStartAttempt(let step): vpnLogger.log(step, named: "Tunnel Start") switch step { case .begin: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelStartAttempt) + persistentPixel.fireDailyAndCount( + pixel: .networkProtectionTunnelStartAttempt, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: nil, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) { _ in } case .failure(let error): - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelStartFailure, error: error) + persistentPixel.fireDailyAndCount( + pixel: .networkProtectionTunnelStartFailure, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: error, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) { _ in } case .success: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelStartSuccess) + persistentPixel.fireDailyAndCount( + pixel: .networkProtectionTunnelStartSuccess, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: nil, + withAdditionalParameters: [:], + includedParameters: [.appVersion]) { _ in } } case .tunnelStopAttempt(let step): vpnLogger.log(step, named: "Tunnel Stop") @@ -152,20 +201,27 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { case .begin: Pixel.fire(pixel: .networkProtectionTunnelStopAttempt) case .failure(let error): - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelStopFailure, error: error) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelStopFailure, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: error) case .success: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelStopSuccess) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelStopSuccess, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } case .tunnelUpdateAttempt(let step): vpnLogger.log(step, named: "Tunnel Update") switch step { case .begin: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelUpdateAttempt) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelUpdateAttempt, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) case .failure(let error): - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelUpdateFailure, error: error) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelUpdateFailure, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: error) case .success: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelUpdateSuccess) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelUpdateSuccess, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } case .tunnelWakeAttempt(let step): vpnLogger.log(step, named: "Tunnel Wake") @@ -174,37 +230,50 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { case .begin: Pixel.fire(pixel: .networkProtectionTunnelWakeAttempt) case .failure(let error): - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelWakeFailure, error: error) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelWakeFailure, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: error) case .success: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelWakeSuccess) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelWakeSuccess, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } case .failureRecoveryAttempt(let step): vpnLogger.log(step) switch step { case .started: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionFailureRecoveryStarted) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionFailureRecoveryStarted, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) case .completed(.healthy): - DailyPixel.fireDailyAndCount(pixel: .networkProtectionFailureRecoveryCompletedHealthy) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionFailureRecoveryCompletedHealthy, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) case .completed(.unhealthy): - DailyPixel.fireDailyAndCount(pixel: .networkProtectionFailureRecoveryCompletedUnhealthy) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionFailureRecoveryCompletedUnhealthy, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) case .failed(let error): - DailyPixel.fireDailyAndCount(pixel: .networkProtectionFailureRecoveryFailed, error: error) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionFailureRecoveryFailed, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: error) } case .serverMigrationAttempt(let step): vpnLogger.log(step, named: "Server Migration") switch step { case .begin: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionServerMigrationAttempt) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionServerMigrationAttempt, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) case .failure(let error): - DailyPixel.fireDailyAndCount(pixel: .networkProtectionServerMigrationAttemptFailure, error: error) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionServerMigrationAttemptFailure, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: error) case .success: - DailyPixel.fireDailyAndCount(pixel: .networkProtectionServerMigrationAttemptSuccess) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionServerMigrationAttemptSuccess, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } case .tunnelStartOnDemandWithoutAccessToken: vpnLogger.logStartingWithoutAuthToken() - DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelStartAttemptOnDemandWithoutAccessToken) + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelStartAttemptOnDemandWithoutAccessToken, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes) } } @@ -306,7 +375,10 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { pixelEvent = .networkProtectionClientFailedToParseServerStatusResponse pixelError = error } - DailyPixel.fireDailyAndCount(pixel: pixelEvent, error: pixelError, withAdditionalParameters: params) + DailyPixel.fireDailyAndCount(pixel: pixelEvent, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, + error: pixelError, + withAdditionalParameters: params) } } @@ -317,6 +389,7 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { default: DailyPixel.fireDailyAndCount( pixel: .networkProtectionDisconnected, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, withAdditionalParameters: [PixelParameters.reason: String(reason.rawValue)] ) } @@ -500,6 +573,7 @@ extension NetworkProtectionPacketTunnelProvider: AccountManagerKeychainAccessDel ] DailyPixel.fireDailyAndCount(pixel: .privacyProKeychainAccessError, + pixelNameSuffixes: DailyPixel.Constant.legacyDailyPixelSuffixes, withAdditionalParameters: parameters) } }