Skip to content

Commit

Permalink
usage segmentation (#3263)
Browse files Browse the repository at this point in the history
<!--
Note: This checklist is a reminder of our shared engineering
expectations. Feel free to change it, although assigning a GitHub
reviewer and the items in bold are required.

⚠️ If you're an external contributor, please file an issue first before
working on a PR, as we can't guarantee that we will accept your changes
if they haven't been discussed ahead of time. Thanks!
-->

Task/Issue URL:
https://app.asana.com/0/392891325557410/1206955287217877/f
Tech Design URL:
https://app.asana.com/0/392891325557410/1208090005627335/f
CC:

**Description**:
This PR ads usage segmentation calculations to the app so that we can
monitor usage across segments in a private way, without having to send
granular usage information to the server.

Deviation from tech design:
* Added "Calculation" class / protocol in order to encapsulate the
calculation logic exclusively and code it so that it's similar to the
python implementation.

**Steps to test this PR**:

***First run, new user***:
1. Reset the simulator (so that the keychain is cleared)
1. Run the app
2. No `m_retention_segments` pixel should be fired

***Subsequent run a few days later***
1. Reset the simulator (so that the keychain is cleared)
1. Modify StatisticsLoader add the following line at the start of the
`load` function:

            self.statisticsStore.atb = "445-1"

4. Run the app
5. An assertion failure should crash the app saying this is not a valid
ATB.
6. Update the above line as follows and reset the simulator again:

            self.statisticsStore.atb = "v445-1"

8. Ensure that `m_retention_segments` pixel is fired. There will be
various parameters but one should indicate "app_use" activity
9. Re-launch the app
10. No additional pixel should be fired
11. Perform a search
12. The pixel will be fired again and there should be a parameter
indicating search activity.
13. Perform another search
14. No additional pixel should be fired

***Return user test***
1. Repeat the above test bit with the following atb. The pixels sent
should include `reinstaller` in the segments parameter.

            self.statisticsStore.atb = "v445-1ru"


**Definition of Done (Internal Only)**:

* [x] Does this PR satisfy our [Definition of
Done](https://app.asana.com/0/1202500774821704/1207634633537039/f)?
  • Loading branch information
brindy authored Sep 3, 2024
1 parent defd5a1 commit 933e11e
Show file tree
Hide file tree
Showing 15 changed files with 4,140 additions and 7 deletions.
98 changes: 97 additions & 1 deletion Core/Atb.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,105 @@

import Foundation

public struct Atb: Decodable {
public struct Atb: Decodable, Equatable {

/// Format is v&lt;week&gt;-&lt;day&gt;
/// * day is `1...7` with 1 being Wednesday
/// * note that week is NOT padded but ATBs older than week 100 should never be seen by the apps, ie no one has this installed before Feb 2018 and week 99 is Jan 2018
/// * ATBs > 999 would be about 10 years in the future (Apr 2035), we can fix it nearer the time
static let template = "v100-1"

/// Same as `template` two characters on the end, e.g. `ma`
static let templateWithVariant = template + "xx"

let version: String
let updateVersion: String?
let numeric: AtbNumeric?

init(version: String, updateVersion: String?) {
self.version = version
self.updateVersion = updateVersion
self.numeric = AtbNumeric.makeFromVersion(version)
}

enum CodingKeys: CodingKey {
case version
case updateVersion
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.version = try container.decode(String.self, forKey: .version)
self.updateVersion = try container.decodeIfPresent(String.self, forKey: .updateVersion)
self.numeric = AtbNumeric.makeFromVersion(version)
}

/// Equality is about the version without any variants. e.g. v100-1 == v100-1ma. `updateVersion` is ignored because that's a signal from the server to update the locally stored Atb so not relevant to any calculation
public static func == (lhs: Atb, rhs: Atb) -> Bool {
return lhs.droppingVariant == rhs.droppingVariant
}

/// Subtracts one ATB from the other.
/// @return difference in days
public static func - (lhs: Atb, rhs: Atb) -> Int {
return lhs.ageInDays - rhs.ageInDays
}

/// Gives age in days since first ATB. If badly formatted returns -1. Only the server should be giving us ATB values, so if it is giving us something wrong there are bigger problems in the world.
var ageInDays: Int {
numeric?.ageInDays ?? -1
}

/// Gives the current week or -1 if badly formatted
var week: Int {
numeric?.week ?? -1
}

var isReturningUser: Bool {
version.count == Self.templateWithVariant.count && version.hasSuffix("ru")
}

struct AtbNumeric {

let week: Int
let day: Int
let ageInDays: Int

static func makeFromVersion(_ version: String) -> AtbNumeric? {
let version = String(version.prefix(Atb.template.count))
guard version.count == Atb.template.count,
let week = Int(version.substring(1...3)),
let day = Int(version.substring(5...5)),
(1...7).contains(day) else {

if !ProcessInfo().arguments.contains("testing") {
assertionFailure("bad atb")
}
return nil
}

return AtbNumeric(week: week, day: day, ageInDays: (week * 7) + (day - 1))
}

}

}

extension Atb {

var droppingVariant: String {
return String(version.prefix(Atb.template.count))
}

}

private extension String {

func substring(_ range: ClosedRange<Int>) -> String {
let startIndex = self.index(self.startIndex, offsetBy: range.lowerBound)
let endIndex = self.index(self.startIndex, offsetBy: min(self.count, range.upperBound + 1))
let substring = self[startIndex..<endIndex]
return String(substring)
}

}
6 changes: 6 additions & 0 deletions Core/PixelEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,9 @@ extension Pixel {
case duckPlayerContingencySettingsDisplayed
case duckPlayerContingencyLearnMoreClicked

// MARK: enhanced statistics
case usageSegments

// MARK: Certificate warnings
case certificateWarningDisplayed(_ errorType: String)
case certificateWarningLeaveClicked
Expand Down Expand Up @@ -1583,6 +1586,9 @@ extension Pixel.Event {
case .duckPlayerContingencySettingsDisplayed: return "duckplayer_ios_contingency_settings-displayed"
case .duckPlayerContingencyLearnMoreClicked: return "duckplayer_ios_contingency_learn-more-clicked"

// MARK: Enhanced statistics
case .usageSegments: return "m_retention_segments"

// MARK: Certificate warnings
case .certificateWarningDisplayed(let errorType):
return "m_certificate_warning_displayed_\(errorType)"
Expand Down
32 changes: 29 additions & 3 deletions Core/StatisticsLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,15 @@ public class StatisticsLoader {

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

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

public func load(completion: @escaping Completion = {}) {
Expand Down Expand Up @@ -88,7 +91,10 @@ public class StatisticsLoader {

public func refreshSearchRetentionAtb(completion: @escaping Completion = {}) {
guard let url = StatisticsDependentURLFactory(statisticsStore: statisticsStore).makeSearchAtbURL() else {
requestInstallStatistics(completion: completion)
requestInstallStatistics {
self.updateUsageSegmentationAfterInstall(activityType: .search)
completion()
}
return
}

Expand All @@ -104,6 +110,7 @@ public class StatisticsLoader {
if let data = response?.data, let atb = try? self.parser.convert(fromJsonData: data) {
self.statisticsStore.searchRetentionAtb = atb.version
self.storeUpdateVersionIfPresent(atb)
self.updateUsageSegmentationWithAtb(atb, activityType: .search)
NotificationCenter.default.post(name: .searchDAU,
object: nil, userInfo: nil)
}
Expand All @@ -113,7 +120,10 @@ public class StatisticsLoader {

public func refreshAppRetentionAtb(completion: @escaping Completion = {}) {
guard let url = StatisticsDependentURLFactory(statisticsStore: statisticsStore).makeAppAtbURL() else {
requestInstallStatistics(completion: completion)
requestInstallStatistics {
self.updateUsageSegmentationAfterInstall(activityType: .appUse)
completion()
}
return
}

Expand All @@ -129,6 +139,7 @@ public class StatisticsLoader {
if let data = response?.data, let atb = try? self.parser.convert(fromJsonData: data) {
self.statisticsStore.appRetentionAtb = atb.version
self.storeUpdateVersionIfPresent(atb)
self.updateUsageSegmentationWithAtb(atb, activityType: .appUse)
}
completion()
}
Expand All @@ -141,4 +152,19 @@ public class StatisticsLoader {
returnUserMeasurement.updateStoredATB(atb)
}
}

private func processUsageSegmentation(atb: Atb?, activityType: UsageActivityType) {
guard let installAtbValue = statisticsStore.atb else { return }
let installAtb = Atb(version: installAtbValue, updateVersion: nil)
let actualAtb = atb ?? installAtb
self.usageSegmentation.processATB(actualAtb, withInstallAtb: installAtb, andActivityType: activityType)
}

private func updateUsageSegmentationWithAtb(_ atb: Atb, activityType: UsageActivityType) {
processUsageSegmentation(atb: atb, activityType: activityType)
}

private func updateUsageSegmentationAfterInstall(activityType: UsageActivityType) {
processUsageSegmentation(atb: nil, activityType: activityType)
}
}
104 changes: 104 additions & 0 deletions Core/UsageSegmentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//
// UsageSegmentation.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

enum UsageActivityType: String {

case search
case appUse = "app_use"

}

protocol UsageSegmenting {

func processATB(_ atb: Atb, withInstallAtb installAtb: Atb, andActivityType activityType: UsageActivityType)

}

final class UsageSegmentation: UsageSegmenting {

private let pixelFiring: PixelFiring.Type
private let storage: UsageSegmentationStoring
private let calculatorFactory: UsageSegmentationCalculatorMaking

init(pixelFiring: PixelFiring.Type = Pixel.self,
storage: UsageSegmentationStoring = UsageSegmentationStorage(),
calculatorFactory: UsageSegmentationCalculatorMaking = DefaultCalculatorFactory()) {
self.pixelFiring = pixelFiring
self.storage = storage
self.calculatorFactory = calculatorFactory
}

func processATB(_ atb: Atb, withInstallAtb installAtb: Atb, andActivityType activityType: UsageActivityType) {
var atbs = activityType.atbsFromStorage(storage)

guard !atbs.contains(where: { $0 == atb }) else { return }

defer {
activityType.updateStorage(storage, withAtbs: atbs)
}

if atbs.isEmpty {
atbs.append(installAtb)
}

if installAtb != atb {
atbs.append(atb)
}

var pixelInfo: [String: String]?
let calculator = calculatorFactory.make(installAtb: installAtb)

// The calculator updates its internal state starting from the first atb, so iterate over them all and take
// the last result.
//
// This is pretty fast (see performance test) and consider that we'll have max 1 atb per day so over a few years it's up
// to the mid thousands so hardly taxing.
for atb in atbs {
pixelInfo = calculator.processAtb(atb, forActivityType: activityType)
}

if let pixelInfo {
pixelFiring.fire(.usageSegments, withAdditionalParameters: pixelInfo)
}
}

}

private extension UsageActivityType {

func atbsFromStorage(_ storage: UsageSegmentationStoring) -> [Atb] {
switch self {
case .appUse: return storage.appUseAtbs
case .search: return storage.searchAtbs
}
}

func updateStorage(_ storage: UsageSegmentationStoring, withAtbs atbs: [Atb]) {
var storage = storage
switch self {
case .appUse:
storage.appUseAtbs = atbs
case .search:
storage.searchAtbs = atbs
}
}

}
Loading

0 comments on commit 933e11e

Please sign in to comment.