Skip to content
This repository was archived by the owner on Feb 24, 2025. It is now read-only.

return user measurement #2011

Merged
merged 7 commits into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 38 additions & 10 deletions Core/DefaultVariantManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,32 @@ public struct VariantIOS: Variant {
return false
}
}


/// This variant is used for returning users to separate them from really new users.
static let returningUser = VariantIOS(name: "ru", weight: doNotAllocate, isIncluded: When.always, features: [])

static let doNotAllocate = 0

// Note: Variants with `doNotAllocate` weight, should always be included so that previous installations are unaffected

/// The list of cohorts in active ATB experiments.
///
/// Variants set to `doNotAllocate` are active, but not adding users to a new cohort, do not change them unless you're sure the experiment is finished.
public static let defaultVariants: [Variant] = [
// SERP testing
VariantIOS(name: "sc", weight: doNotAllocate, isIncluded: When.always, features: []),
VariantIOS(name: "sd", weight: doNotAllocate, isIncluded: When.always, features: []),
VariantIOS(name: "se", weight: doNotAllocate, isIncluded: When.always, features: [])

VariantIOS(name: "se", weight: doNotAllocate, isIncluded: When.always, features: []),
returningUser
]


/// The name of the variant. Shuld be a two character string like `ma` or `mb`
public var name: String

/// The relative weight of this variant, e.g. if two variants have the same weight they will get 50% of the cohorts each.
public var weight: Int

/// Function to determine inclusion, e.g. if you want to only run an experiment on English users use `When.inEnglish`
public var isIncluded: () -> Bool

/// The experimental feature(s) being tested.
public var features: [FeatureName]

}
Expand All @@ -81,13 +92,26 @@ public class DefaultVariantManager: VariantManager {
private let variants: [Variant]
private let storage: StatisticsStore
private let rng: VariantRNG
private let returningUserMeasurement: ReturnUserMeasurement

public init(variants: [Variant] = VariantIOS.defaultVariants,
storage: StatisticsStore = StatisticsUserDefaults(),
rng: VariantRNG = Arc4RandomUniformVariantRNG()) {
init(variants: [Variant],
storage: StatisticsStore,
rng: VariantRNG,
returningUserMeasurement: ReturnUserMeasurement) {

self.variants = variants
self.storage = storage
self.rng = rng
self.returningUserMeasurement = returningUserMeasurement
}

public convenience init() {
self.init(
variants: VariantIOS.defaultVariants,
storage: StatisticsUserDefaults(),
rng: Arc4RandomUniformVariantRNG(),
returningUserMeasurement: KeychainReturnUserMeasurement()
)
}

public func isSupported(feature: FeatureName) -> Bool {
Expand Down Expand Up @@ -118,6 +142,10 @@ public class DefaultVariantManager: VariantManager {
}

private func selectVariant() -> Variant? {
if returningUserMeasurement.isReturningUser {
return VariantIOS.returningUser
}

let totalWeight = variants.reduce(0, { $0 + $1.weight })
let randomPercent = rng.nextInt(upperBound: totalWeight)

Expand Down
5 changes: 5 additions & 0 deletions Core/Pixel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ public struct PixelParameters {
public static let sheetResult = "success"

public static let defaultBrowser = "default_browser"

// Return user
public static let returnUserErrorCode = "error_code"
public static let returnUserOldATB = "old_atb"
public static let returnUserNewATB = "new_atb"
}

public struct PixelValues {
Expand Down
15 changes: 15 additions & 0 deletions Core/PixelEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@ extension Pixel {
case onboardingSetDefaultOpened
case onboardingSetDefaultSkipped

// MARK: Return user measurement
case returnUser

// MARK: debug pixels
case dbCrashDetected

Expand Down Expand Up @@ -392,6 +395,11 @@ extension Pixel {
case debugCantSaveBookmarkFix

case debugCannotClearObservationsDatabase

// Return user measurement
case debugReturnUserReadATB
case debugReturnUserAddATB
case debugReturnUserUpdateATB

// Errors from Bookmarks Module
case bookmarkFolderExpected
Expand Down Expand Up @@ -881,6 +889,13 @@ extension Pixel.Event {
case .emailIncontextModalDismissed: return "m_email_incontext_modal_dismissed"
case .emailIncontextModalExitEarly: return "m_email_incontext_modal_exit_early"
case .emailIncontextModalExitEarlyContinue: return "m_email_incontext_modal_exit_early_continue"

// MARK: - Return user measurement
case .returnUser: return "m_return_user"
case .debugReturnUserAddATB: return "m_debug_return_user_add_atb"
case .debugReturnUserReadATB: return "m_debug_return_user_read_atb"
case .debugReturnUserUpdateATB: return "m_debug_return_user_update_atb"

}

}
Expand Down
150 changes: 150 additions & 0 deletions Core/ReturnUserMeasurement.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//
// ReturnUserMeasurement.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
import BrowserServicesKit

protocol ReturnUserMeasurement {

var isReturningUser: Bool { get }
func installCompletedWithATB(_ atb: Atb)
func updateStoredATB(_ atb: Atb)

}

class KeychainReturnUserMeasurement: ReturnUserMeasurement {

static let SecureATBKeychainName = "returning-user-atb"

struct Measurement {

let oldATB: String?
let newATB: String

}

/// Called from the `VariantManager` to determine which variant to use
var isReturningUser: Bool {
return hasAnyKeychainItems()
}

func installCompletedWithATB(_ atb: Atb) {
if let oldATB = readSecureATB() {
sendReturnUserMeasurement(oldATB, atb.version)
}
writeSecureATB(atb.version)
}

/// Update the stored ATB with an even more generalised version of the ATB, if present.
func updateStoredATB(_ atb: Atb) {
guard let atb = atb.updateVersion else { return }
writeSecureATB(atb)
}

private func writeSecureATB(_ atb: String) {
let data = atb.data(using: .utf8)!

var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: Self.SecureATBKeychainName,
kSecValueData as String: data,

// We expect to only need access when the app is in the foreground and we want it to be migrated to new devices.
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,

// Just to be explicit that we don't want this stored in the cloud
kSecAttrSynchronizable as String: false
]

var status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
let attributesToUpdate: [String: Any] = [
kSecValueData as String: data
]
query.removeValue(forKey: kSecValueData as String)
status = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary)
if status != errSecSuccess {
fireDebugPixel(.debugReturnUserUpdateATB, errorCode: status)
}
} else if status != errSecSuccess {
fireDebugPixel(.debugReturnUserAddATB, errorCode: status)
}

}

private func readSecureATB() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: Self.SecureATBKeychainName,
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne
]

var dataTypeRef: AnyObject?
let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
if ![errSecSuccess, errSecItemNotFound].contains(status) {
fireDebugPixel(.debugReturnUserReadATB, errorCode: status)
}

if let data = dataTypeRef as? Data {
return String(data: data, encoding: .utf8)
}

return nil
}

private func sendReturnUserMeasurement(_ oldATB: String, _ newATB: String) {
Pixel.fire(pixel: .returnUser, withAdditionalParameters: [
PixelParameters.returnUserOldATB: oldATB,
PixelParameters.returnUserNewATB: newATB
])
}

private func fireDebugPixel(_ event: Pixel.Event, errorCode: OSStatus) {
Pixel.fire(pixel: event, withAdditionalParameters: [
PixelParameters.returnUserErrorCode: "\(errorCode)"
])
}

/// Only check for keychain items created by *this* app.
private func hasAnyKeychainItems() -> Bool {
let possibleStorageClasses = [
kSecClassGenericPassword,
kSecClassKey
]
return possibleStorageClasses.first(where: hasKeychainItemsInClass(_:)) != nil
}

private func hasKeychainItemsInClass(_ secClassCFString: CFString) -> Bool {
let query: [String: Any] = [
kSecClass as String: secClassCFString,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true, // Needs to be true or returns nothing.
kSecReturnRef as String: true,
]
var returnArrayRef: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &returnArrayRef)
guard status == errSecSuccess,
let returnArray = returnArrayRef as? [String: Any] else {
return false
}
return returnArray.count > 0
}

}
7 changes: 6 additions & 1 deletion Core/StatisticsLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ public class StatisticsLoader {
public static let shared = StatisticsLoader()

private let statisticsStore: StatisticsStore
private let returnUserMeasurement: ReturnUserMeasurement
private let parser = AtbParser()

init(statisticsStore: StatisticsStore = StatisticsUserDefaults()) {
init(statisticsStore: StatisticsStore = StatisticsUserDefaults(),
returnUserMeasurement: ReturnUserMeasurement = KeychainReturnUserMeasurement()) {
self.statisticsStore = statisticsStore
self.returnUserMeasurement = returnUserMeasurement
}

public func load(completion: @escaping Completion = {}) {
Expand Down Expand Up @@ -77,6 +80,7 @@ public class StatisticsLoader {
}
self.statisticsStore.installDate = Date()
self.statisticsStore.atb = atb.version
self.returnUserMeasurement.installCompletedWithATB(atb)
completion()
}
}
Expand Down Expand Up @@ -134,6 +138,7 @@ public class StatisticsLoader {
public func storeUpdateVersionIfPresent(_ atb: Atb) {
if let updateVersion = atb.updateVersion {
statisticsStore.atb = updateVersion
returnUserMeasurement.updateStoredATB(atb)
}
}
}
4 changes: 4 additions & 0 deletions DuckDuckGo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@
85DFEDF124C7EEA400973FE7 /* LargeOmniBarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DFEDF024C7EEA400973FE7 /* LargeOmniBarState.swift */; };
85DFEDF724CB1CAB00973FE7 /* ShareSheet.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 85DFEDF624CB1CAB00973FE7 /* ShareSheet.xcassets */; };
85DFEDF924CF3D0E00973FE7 /* TabsBarCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DFEDF824CF3D0E00973FE7 /* TabsBarCell.swift */; };
85E242172AB1B54D000F3E28 /* ReturnUserMeasurement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E242162AB1B54D000F3E28 /* ReturnUserMeasurement.swift */; };
85E5603026541D9E00F4DC44 /* AutocompleteRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E5602E26541D1D00F4DC44 /* AutocompleteRequestTests.swift */; };
85E58C2C28FDA94F006A801A /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E58C2B28FDA94F006A801A /* FavoritesViewController.swift */; };
85EE7F55224667DD000FE757 /* WebContainer.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85EE7F54224667DD000FE757 /* WebContainer.storyboard */; };
Expand Down Expand Up @@ -1460,6 +1461,7 @@
85DFEDF024C7EEA400973FE7 /* LargeOmniBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeOmniBarState.swift; sourceTree = "<group>"; };
85DFEDF624CB1CAB00973FE7 /* ShareSheet.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = ShareSheet.xcassets; sourceTree = "<group>"; };
85DFEDF824CF3D0E00973FE7 /* TabsBarCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsBarCell.swift; sourceTree = "<group>"; };
85E242162AB1B54D000F3E28 /* ReturnUserMeasurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReturnUserMeasurement.swift; sourceTree = "<group>"; };
85E5602E26541D1D00F4DC44 /* AutocompleteRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteRequestTests.swift; sourceTree = "<group>"; };
85E58C2B28FDA94F006A801A /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; };
85EE7F54224667DD000FE757 /* WebContainer.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = WebContainer.storyboard; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4393,6 +4395,7 @@
1E05D1D729C46EDA00BF9A1F /* TimedPixel.swift */,
1E05D1D529C46EBB00BF9A1F /* DailyPixel.swift */,
8577C65F2A964BAC00788B3A /* SetAsDefaultStatistics.swift */,
85E242162AB1B54D000F3E28 /* ReturnUserMeasurement.swift */,
);
name = Statistics;
sourceTree = "<group>";
Expand Down Expand Up @@ -6619,6 +6622,7 @@
B652DF10287C2C1600C12A9C /* ContentBlocking.swift in Sources */,
4BE2756827304F57006B20B0 /* URLRequestExtension.swift in Sources */,
85BA79911F6FF75000F59015 /* ContentBlockerStoreConstants.swift in Sources */,
85E242172AB1B54D000F3E28 /* ReturnUserMeasurement.swift in Sources */,
85BDC3142434D8F80053DB07 /* DebugUserScript.swift in Sources */,
85011867290028C400BDEE27 /* BookmarksDatabase.swift in Sources */,
85D2187B24BF9F85004373D2 /* FaviconUserScript.swift in Sources */,
Expand Down
Loading