From 8697128b9007d0564112e7a6452c6797a4904de5 Mon Sep 17 00:00:00 2001 From: Anh Do <18567+quanganhdo@users.noreply.github.com> Date: Tue, 12 Dec 2023 14:15:48 -0500 Subject: [PATCH] Add iOS NetP pixels (#2261) Task/Issue URL: https://app.asana.com/0/72649045549333/1206057769488160/f Description: Add iOS NetP pixels for active/new user, VPN enabled success rate, tunnel failure/recovery, and latency. Steps to test this PR: Confirm pixel submission on Kibana Make sure active user pixel includes the cohort value --- Core/PixelEvent.swift | 23 ++++- Core/UniquePixel.swift | 84 +++++++++++++++++++ DuckDuckGo.xcodeproj/project.pbxproj | 4 + .../NetworkProtectionTunnelController.swift | 6 ++ ...etworkProtectionPacketTunnelProvider.swift | 31 ++++++- 5 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 Core/UniquePixel.swift diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index d35c102063..a9c8def9f4 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -22,6 +22,7 @@ import BrowserServicesKit import Bookmarks import Configuration import DDGSync +import NetworkProtection // swiftlint:disable file_length extension Pixel { @@ -316,8 +317,19 @@ extension Pixel { case networkProtectionActiveUser case networkProtectionNewUser + case networkProtectionEnableAttemptConnecting + case networkProtectionEnableAttemptSuccess + case networkProtectionEnableAttemptFailure + + case networkProtectionTunnelFailureDetected + case networkProtectionTunnelFailureRecovered + + case networkProtectionLatency(quality: NetworkProtectionLatencyMonitor.ConnectionQuality) + case networkProtectionLatencyError + + case networkProtectionEnabledOnSearch + case networkProtectionRekeyCompleted - case networkProtectionLatency case networkProtectionTunnelConfigurationNoServerRegistrationInfo case networkProtectionTunnelConfigurationCouldNotSelectClosestServer @@ -851,8 +863,15 @@ extension Pixel.Event { case .networkProtectionActiveUser: return "m_netp_daily_active_d" case .networkProtectionNewUser: return "m_netp_daily_active_u" + case .networkProtectionEnableAttemptConnecting: return "m_netp_ev_enable_attempt" + case .networkProtectionEnableAttemptSuccess: return "m_netp_ev_enable_attempt_success" + case .networkProtectionEnableAttemptFailure: return "m_netp_ev_enable_attempt_failure" + case .networkProtectionTunnelFailureDetected: return "m_netp_ev_tunnel_failure" + case .networkProtectionTunnelFailureRecovered: return "m_netp_ev_tunnel_failure_recovered" + case .networkProtectionLatency(let quality): return "m_netp_ev_\(quality.rawValue)_latency" + case .networkProtectionLatencyError: return "m_netp_ev_latency_error_d" case .networkProtectionRekeyCompleted: return "m_netp_rekey_completed" - case .networkProtectionLatency: return "m_netp_latency" + case .networkProtectionEnabledOnSearch: return "m_netp_enabled_on_search" case .networkProtectionTunnelConfigurationNoServerRegistrationInfo: return "m_netp_tunnel_config_error_no_server_registration_info" case .networkProtectionTunnelConfigurationCouldNotSelectClosestServer: return "m_netp_tunnel_config_error_could_not_select_closest_server" case .networkProtectionTunnelConfigurationCouldNotGetPeerPublicKey: return "m_netp_tunnel_config_error_could_not_get_peer_public_key" diff --git a/Core/UniquePixel.swift b/Core/UniquePixel.swift new file mode 100644 index 0000000000..4be27a5e61 --- /dev/null +++ b/Core/UniquePixel.swift @@ -0,0 +1,84 @@ +// +// UniquePixel.swift +// DuckDuckGo +// +// Copyright © 2023 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 + +/// A variant of pixel that is fired just once. Ever. +/// +/// The 'fire' method mimics standard Pixel API. +/// The 'onComplete' closure is always called - even when no pixel is fired. +/// In those scenarios a 'UniquePixelError' is returned denoting the reason. +/// +public final class UniquePixel { + + public enum Error: Swift.Error { + + case alreadyFired + + } + + private enum Constant { + + static let uniquePixelStorageIdentifier = "com.duckduckgo.unique.pixel.storage" + + } + + public static let storage = UserDefaults(suiteName: Constant.uniquePixelStorageIdentifier)! + + /// Sends a unique Pixel + /// This requires the pixel name to end with `_u` + public static func fire(pixel: Pixel.Event, + withAdditionalParameters params: [String: String] = [:], + onComplete: @escaping (Swift.Error?) -> Void = { _ in }) { + guard pixel.name.hasSuffix("_u") else { + assertionFailure("Unique pixel: must end with _u") + return + } + + if !pixel.hasBeenFiredEver(uniquePixelStorage: storage) { + Pixel.fire(pixel: pixel, withAdditionalParameters: params, onComplete: onComplete) + storage.set(Date(), forKey: pixel.name) + } else { + onComplete(Error.alreadyFired) + } + } + + public static func dateString(for date: Date?) -> String { + guard let date else { return "" } + + let dateFormatter = DateFormatter() + dateFormatter.calendar = Calendar.current + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + dateFormatter.dateFormat = "yyyy-MM-dd" + + return dateFormatter.string(from: date) + } +} + +extension Pixel.Event { + + public func lastFireDate(uniquePixelStorage: UserDefaults) -> Date? { + uniquePixelStorage.object(forKey: name) as? Date + } + + func hasBeenFiredEver(uniquePixelStorage: UserDefaults) -> Bool { + lastFireDate(uniquePixelStorage: uniquePixelStorage) != nil + } + +} diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index a5008c6ff9..70d577e700 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -694,6 +694,7 @@ B6BA95C528894A28004ABA20 /* BrowsingMenuViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B6BA95C428894A28004ABA20 /* BrowsingMenuViewController.storyboard */; }; B6BA95E828924730004ABA20 /* JSAlertController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B6BA95E728924730004ABA20 /* JSAlertController.storyboard */; }; B6CB93E5286445AB0090FEB4 /* Base64DownloadSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CB93E4286445AB0090FEB4 /* Base64DownloadSession.swift */; }; + BDC234F72B27F51100D3C798 /* UniquePixel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDC234F62B27F51100D3C798 /* UniquePixel.swift */; }; C10CB5F32A1A5BDF0048E503 /* AutofillViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10CB5F22A1A5BDF0048E503 /* AutofillViews.swift */; }; C111B26927F579EF006558B1 /* BookmarkOrFolderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C111B26827F579EF006558B1 /* BookmarkOrFolderTests.swift */; }; C12726EE2A5FF88C00215B02 /* EmailSignupPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12726ED2A5FF88C00215B02 /* EmailSignupPromptView.swift */; }; @@ -2276,6 +2277,7 @@ B6BA95C428894A28004ABA20 /* BrowsingMenuViewController.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = BrowsingMenuViewController.storyboard; sourceTree = ""; }; B6BA95E728924730004ABA20 /* JSAlertController.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = JSAlertController.storyboard; sourceTree = ""; }; B6CB93E4286445AB0090FEB4 /* Base64DownloadSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Base64DownloadSession.swift; sourceTree = ""; }; + BDC234F62B27F51100D3C798 /* UniquePixel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniquePixel.swift; sourceTree = ""; }; C10CB5F22A1A5BDF0048E503 /* AutofillViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillViews.swift; sourceTree = ""; }; C111B26827F579EF006558B1 /* BookmarkOrFolderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOrFolderTests.swift; sourceTree = ""; }; C12726ED2A5FF88C00215B02 /* EmailSignupPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailSignupPromptView.swift; sourceTree = ""; }; @@ -4592,6 +4594,7 @@ F1134EAE1F40AB2300B73467 /* Parser */, F1134EA91F3E2BA700B73467 /* Store */, CB2A7EF028410DF700885F67 /* PixelEvent.swift */, + BDC234F62B27F51100D3C798 /* UniquePixel.swift */, 853A717520F62FE800FE60BC /* Pixel.swift */, 1E05D1D729C46EDA00BF9A1F /* TimedPixel.swift */, 1E05D1D529C46EBB00BF9A1F /* DailyPixel.swift */, @@ -6875,6 +6878,7 @@ 37445F972A155F7C0029F789 /* SyncDataProviders.swift in Sources */, EE9D68DE2AE2A65600B55EF4 /* UserDefaults+NetworkProtection.swift in Sources */, CB258D1F29A52B2500DEBA24 /* Configuration.swift in Sources */, + BDC234F72B27F51100D3C798 /* UniquePixel.swift in Sources */, 9847C00027A2DDBB00DB07AA /* AppPrivacyConfigurationDataProvider.swift in Sources */, F143C3281E4A9A0E00CFDE3A /* StringExtension.swift in Sources */, 85449EFB23FDA0BC00512AAF /* UserDefaultsPropertyWrapper.swift in Sources */, diff --git a/DuckDuckGo/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtectionTunnelController.swift index 2a1d1b6f8c..595b0afa13 100644 --- a/DuckDuckGo/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtectionTunnelController.swift @@ -132,6 +132,12 @@ final class NetworkProtectionTunnelController: TunnelController { do { try tunnelManager.connection.startVPNTunnel(options: options) + UniquePixel.fire(pixel: .networkProtectionNewUser) { error in + guard error != nil else { return } + VPNSettings(defaults: .networkProtectionGroupDefaults).vpnFirstEnabled = Pixel.Event.networkProtectionNewUser.lastFireDate( + uniquePixelStorage: UniquePixel.storage + ) + } } catch { Pixel.fire(pixel: .networkProtectionActivationRequestFailed, error: error) throw error diff --git a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift index 73ad4b3c74..a3ebb3330b 100644 --- a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift +++ b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift @@ -37,10 +37,33 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { private static var packetTunnelProviderEvents: EventMapping = .init { event, _, _, _ in switch event { case .userBecameActive: - DailyPixel.fire(pixel: .networkProtectionActiveUser) - case .reportLatency, .reportTunnelFailure, .reportConnectionAttempt: - // TODO: Fire these pixels - break + let settings = VPNSettings(defaults: .networkProtectionGroupDefaults) + DailyPixel.fire(pixel: .networkProtectionActiveUser, + withAdditionalParameters: ["cohort": UniquePixel.dateString(for: settings.vpnFirstEnabled)]) + case .reportConnectionAttempt(attempt: let attempt): + switch attempt { + case .connecting: + DailyPixel.fireDailyAndCount(pixel: .networkProtectionEnableAttemptConnecting) + case .success: + DailyPixel.fireDailyAndCount(pixel: .networkProtectionEnableAttemptSuccess) + case .failure: + DailyPixel.fireDailyAndCount(pixel: .networkProtectionEnableAttemptFailure) + } + case .reportTunnelFailure(result: let result): + switch result { + case .failureDetected: + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelFailureDetected) + case .failureRecovered: + DailyPixel.fireDailyAndCount(pixel: .networkProtectionTunnelFailureRecovered) + } + case .reportLatency(result: let result): + switch result { + case .error: + DailyPixel.fire(pixel: .networkProtectionLatencyError) + case .quality(let quality): + guard quality != .unknown else { return } + DailyPixel.fireDailyAndCount(pixel: .networkProtectionLatency(quality: quality)) + } case .rekeyCompleted: Pixel.fire(pixel: .networkProtectionRekeyCompleted) }