Skip to content

Commit

Permalink
tds experiment metrics (#1172)
Browse files Browse the repository at this point in the history
**Required**:

Task/Issue URL:
https://app.asana.com/0/1204186595873227/1208547600159201/f
iOS PR: duckduckgo/iOS#3839
macOS PR:  https://github.com/duckduckgo/macos-browser/pull/3755/files
What kind of version bump will this require?: Major

**Optional**:

Tech Design URL:
https://app.asana.com/0/1204186595873227/1209001242859416/f
CC:

**Description**: Adds pixel reporting for TDS override experiment
  • Loading branch information
SabrinaTardio authored Jan 23, 2025
1 parent 12227e2 commit 4232acb
Show file tree
Hide file tree
Showing 7 changed files with 285 additions and 65 deletions.
6 changes: 4 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,8 @@ let package = Package(
name: "PixelExperimentKit",
dependencies: [
"PixelKit",
"BrowserServicesKit"
"BrowserServicesKit",
"Configuration"
],
resources: [
.process("Resources")
Expand Down Expand Up @@ -703,7 +704,8 @@ let package = Package(
.testTarget(
name: "PixelExperimentKitTests",
dependencies: [
"PixelExperimentKit"
"PixelExperimentKit",
"Configuration"
]
),
.testTarget(
Expand Down
16 changes: 8 additions & 8 deletions Sources/Configuration/TrackerDataURLOverrider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public final class TrackerDataURLOverrider: TrackerDataURLProviding {
var featureFlagger: FeatureFlagger

public enum Constants {
public static let baseTdsURLString = "https://staticcdn.duckduckgo.com/trackerblocking/"
public static let baseTDSURLString = "https://staticcdn.duckduckgo.com/trackerblocking/"
}

public init (privacyConfigurationManager: PrivacyConfigurationManaging,
Expand All @@ -40,22 +40,22 @@ public final class TrackerDataURLOverrider: TrackerDataURLProviding {
}

public var trackerDataURL: URL? {
for experimentType in TdsExperimentType.allCases {
if let cohort = featureFlagger.getCohortIfEnabled(for: experimentType.experiment) as? TdsNextExperimentFlag.Cohort,
for experimentType in TDSExperimentType.allCases {
if let cohort = featureFlagger.getCohortIfEnabled(for: experimentType.experiment) as? TDSNextExperimentFlag.Cohort,
let url = trackerDataURL(for: experimentType.subfeature, cohort: cohort) {
return url
}
}
return nil
}

private func trackerDataURL(for subfeature: any PrivacySubfeature, cohort: TdsNextExperimentFlag.Cohort) -> URL? {
private func trackerDataURL(for subfeature: any PrivacySubfeature, cohort: TDSNextExperimentFlag.Cohort) -> URL? {
guard let settings = privacyConfigurationManager.privacyConfig.settings(for: subfeature),
let jsonData = settings.data(using: .utf8) else { return nil }
do {
if let settingsDict = try JSONSerialization.jsonObject(with: jsonData) as? [String: String],
let urlString = cohort == .control ? settingsDict["controlUrl"] : settingsDict["treatmentUrl"] {
return URL(string: Constants.baseTdsURLString + urlString)
return URL(string: Constants.baseTDSURLString + urlString)
}
} catch {
Logger.config.info("privacyConfiguration: Failed to parse subfeature settings JSON: \(error)")
Expand All @@ -64,7 +64,7 @@ public final class TrackerDataURLOverrider: TrackerDataURLProviding {
}
}

public enum TdsExperimentType: Int, CaseIterable {
public enum TDSExperimentType: Int, CaseIterable {
case baseline
case feb25
case mar25
Expand All @@ -79,7 +79,7 @@ public enum TdsExperimentType: Int, CaseIterable {
case dec25

public var experiment: any FeatureFlagExperimentDescribing {
TdsNextExperimentFlag(subfeature: self.subfeature)
TDSNextExperimentFlag(subfeature: self.subfeature)
}

public var subfeature: any PrivacySubfeature {
Expand Down Expand Up @@ -113,7 +113,7 @@ public enum TdsExperimentType: Int, CaseIterable {

}

public struct TdsNextExperimentFlag: FeatureFlagExperimentDescribing {
public struct TDSNextExperimentFlag: FeatureFlagExperimentDescribing {
public var rawValue: String
public var source: FeatureFlagSource

Expand Down
45 changes: 22 additions & 23 deletions Sources/PageRefreshMonitor/PageRefreshMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,6 @@ public extension Notification.Name {

}

public protocol PageRefreshStoring {

var refreshTimestamps: [Date] { get set }

}

public protocol PageRefreshMonitoring {

func register(for url: URL, date: Date)
Expand All @@ -50,43 +44,48 @@ public extension PageRefreshMonitoring {
///
/// Triggers `onDidDetectRefreshPattern` and posts a `pageRefreshMonitorDidDetectRefreshPattern` notification
/// if three refreshes occur within a 20-second window.
/// Triggers `onDidDetectRefreshPattern`
/// if two refresh occur within a 12-second window
public final class PageRefreshMonitor: PageRefreshMonitoring {

private let onDidDetectRefreshPattern: () -> Void
private var store: PageRefreshStoring
public typealias NumberOfRefreshes = Int
private let onDidDetectRefreshPattern: (_ numberOfRefreshes: NumberOfRefreshes) -> Void
private var lastRefreshedURL: URL?

public init(onDidDetectRefreshPattern: @escaping () -> Void,
store: PageRefreshStoring) {
public init(onDidDetectRefreshPattern: @escaping (NumberOfRefreshes) -> Void) {
self.onDidDetectRefreshPattern = onDidDetectRefreshPattern
self.store = store
}

var refreshTimestamps: [Date] {
get { store.refreshTimestamps }
set { store.refreshTimestamps = newValue }
}
var refreshTimestamps2x: [Date] = []
var refreshTimestamps3x: [Date] = []

public func register(for url: URL, date: Date = Date()) {
resetIfURLChanged(to: url)

// Add the new refresh timestamp
refreshTimestamps.append(date)
refreshTimestamps2x.append(date)
refreshTimestamps3x.append(date)

// Retain only timestamps within the last 20 seconds
refreshTimestamps = refreshTimestamps.filter { date.timeIntervalSince($0) < 20.0 }
let refreshesInLast12Secs = refreshTimestamps2x.filter { date.timeIntervalSince($0) < 12.0 }
let refreshesInLast20Secs = refreshTimestamps3x.filter { date.timeIntervalSince($0) < 20.0 }

// Trigger detection if three refreshes occurred within 20 seconds, then reset timestamps
if refreshTimestamps.count > 2 {
onDidDetectRefreshPattern()
// Trigger detection if two refreshes occurred within 12 seconds
if refreshesInLast12Secs.count > 1 {
onDidDetectRefreshPattern(2)
refreshTimestamps2x.removeAll()
}
// Trigger detection if three refreshes occurred within 20 seconds
if refreshesInLast20Secs.count > 2 {
onDidDetectRefreshPattern(3)
NotificationCenter.default.post(name: .pageRefreshMonitorDidDetectRefreshPattern, object: self)
refreshTimestamps.removeAll() // Reset timestamps after detection
refreshTimestamps3x.removeAll() // Reset timestamps after detection
}
}

private func resetIfURLChanged(to newURL: URL) {
if lastRefreshedURL != newURL {
refreshTimestamps.removeAll()
refreshTimestamps2x.removeAll()
refreshTimestamps3x.removeAll()
lastRefreshedURL = newURL
}
}
Expand Down
92 changes: 92 additions & 0 deletions Sources/PixelExperimentKit/TDSOverrideExperimentMetrics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//
// TDSOverrideExperimentMetrics.swift
//
// Copyright © 2025 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 PixelKit
import Configuration
import BrowserServicesKit

public enum TDSExperimentMetricType: String {
/// Metric triggered when the privacy toggle is used.
case privacyToggleUsed = "privacyToggleUsed"
/// Metric triggered after 2 quick refreshes.
case refresh2X = "2XRefresh"
/// Metric triggered after 3 quick refreshes.
case refresh3X = "3XRefresh"
}

public struct TDSOverrideExperimentMetrics {

public typealias FirePixelExperiment = (SubfeatureID, String, ConversionWindow, String) -> Void
public typealias FireDebugExperiment = (_ parameters: [String: String]) -> Void

private struct ExperimentConfig {
static var firePixelExperiment: FirePixelExperiment = { subfeatureID, metric, conversionWindow, value in
PixelKit.fireExperimentPixel(for: subfeatureID, metric: metric, conversionWindowDays: conversionWindow, value: value)
}
}

static func configureTDSOverrideExperimentMetrics(firePixelExperiment: @escaping FirePixelExperiment) {
ExperimentConfig.firePixelExperiment = firePixelExperiment
}

public static var activeTDSExperimentNameWithCohort: String? {
guard let featureFlagger = PixelKit.ExperimentConfig.featureFlagger else { return nil }

return TDSExperimentType.allCases.compactMap { experimentType in
guard let experimentData = featureFlagger.getAllActiveExperiments()[experimentType.subfeature.rawValue] else { return nil }
return "\(experimentType.subfeature.rawValue)_\(experimentData.cohortID)"
}.first
}

public static func fireTDSExperimentMetric( metricType: TDSExperimentMetricType,
etag: String,
fireDebugExperiment: @escaping FireDebugExperiment) {
for experiment in TDSExperimentType.allCases {
for day in 0...5 {
ExperimentConfig.firePixelExperiment(experiment.subfeature.rawValue,
metricType.rawValue,
day...day,
"1"
)
fireDebugBreakageExperiment(experimentType: experiment,
etag: etag,
fire: fireDebugExperiment
)
}
}
}

private static func fireDebugBreakageExperiment(experimentType: TDSExperimentType,
etag: String,
fire: @escaping FireDebugExperiment) {
guard
let featureFlagger = PixelKit.ExperimentConfig.featureFlagger,
let experimentData = featureFlagger.getAllActiveExperiments()[experimentType.subfeature.rawValue]
else { return }

let experimentName: String = experimentType.subfeature.rawValue + experimentData.cohortID
let enrolmentDate = experimentData.enrollmentDate.toYYYYMMDDInET()
let parameters = [
"experiment": experimentName,
"enrolmentDate": enrolmentDate,
"tdsEtag": etag
]
fire(parameters)
}
}
22 changes: 11 additions & 11 deletions Tests/ConfigurationTests/TrackerDataURLOverriderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ final class TrackerDataURLOverriderTests: XCTestCase {
func testTrackerDataURL_forControlCohort_returnsControlUrl() throws {
// GIVEN
mockFeatureFlagger.mockCohorts = [
TdsExperimentType(rawValue: 0)!.subfeature.rawValue: TdsNextExperimentFlag.Cohort.control]
TDSExperimentType(rawValue: 0)!.subfeature.rawValue: TDSNextExperimentFlag.Cohort.control]
let privacyConfig = MockPrivacyConfiguration()
privacyConfig.subfeatureSettings = "{ \"controlUrl\": \"\(controlURL)\", \"treatmentUrl\": \"\(treatmentURL)\"}"
mockPrivacyConfigurationManager.privacyConfig = privacyConfig
Expand All @@ -54,13 +54,13 @@ final class TrackerDataURLOverriderTests: XCTestCase {
let url = try XCTUnwrap(urlProvider.trackerDataURL)

// THEN
XCTAssertEqual(url.absoluteString, TrackerDataURLOverrider.Constants.baseTdsURLString + controlURL)
XCTAssertEqual(url.absoluteString, TrackerDataURLOverrider.Constants.baseTDSURLString + controlURL)
}

func testTrackerDataURL_forTreatmentCohort_returnsTreatmentUrl() throws {
// GIVEN
mockFeatureFlagger.mockCohorts = [
TdsExperimentType(rawValue: 0)!.subfeature.rawValue: TdsNextExperimentFlag.Cohort.treatment]
TDSExperimentType(rawValue: 0)!.subfeature.rawValue: TDSNextExperimentFlag.Cohort.treatment]
let privacyConfig = MockPrivacyConfiguration()
privacyConfig.subfeatureSettings = "{ \"controlUrl\": \"\(controlURL)\", \"treatmentUrl\": \"\(treatmentURL)\"}"
mockPrivacyConfigurationManager.privacyConfig = privacyConfig
Expand All @@ -69,13 +69,13 @@ final class TrackerDataURLOverriderTests: XCTestCase {
let url = try XCTUnwrap(urlProvider.trackerDataURL)

// THEN
XCTAssertEqual(url.absoluteString, TrackerDataURLOverrider.Constants.baseTdsURLString + treatmentURL)
XCTAssertEqual(url.absoluteString, TrackerDataURLOverrider.Constants.baseTDSURLString + treatmentURL)
}

func testTrackerDataURL_ifNoSettings_returnsDefaultURL() throws {
// GIVEN
mockFeatureFlagger.mockCohorts = [
TdsExperimentType(rawValue: 0)!.subfeature.rawValue: TdsNextExperimentFlag.Cohort.treatment]
TDSExperimentType(rawValue: 0)!.subfeature.rawValue: TDSNextExperimentFlag.Cohort.treatment]
let privacyConfig = MockPrivacyConfiguration()
mockPrivacyConfigurationManager.privacyConfig = privacyConfig

Expand Down Expand Up @@ -106,19 +106,19 @@ final class TrackerDataURLOverriderTests: XCTestCase {
let thirdExperimentTreatmentURL = "third-treatment.json"
let privacyConfig = MockPrivacyConfiguration()
privacyConfig.mockSubfeatureSettings = [
TdsExperimentType(rawValue: 0)!.subfeature.rawValue: """
TDSExperimentType(rawValue: 0)!.subfeature.rawValue: """
{
"controlUrl": "\(firstExperimentControlURL)",
"treatmentUrl": "first-treatment.json"
}
""",
TdsExperimentType(rawValue: 1)!.subfeature.rawValue: """
TDSExperimentType(rawValue: 1)!.subfeature.rawValue: """
{
"controlUrl": "second-control.json",
"treatmentUrl": "\(secondExperimentTreatmentURL)"
}
""",
TdsExperimentType(rawValue: 2)!.subfeature.rawValue: """
TDSExperimentType(rawValue: 2)!.subfeature.rawValue: """
{
"controlUrl": "third-control.json",
"treatmentUrl": "\(thirdExperimentTreatmentURL)"
Expand All @@ -127,15 +127,15 @@ final class TrackerDataURLOverriderTests: XCTestCase {
]
mockPrivacyConfigurationManager.privacyConfig = privacyConfig
mockFeatureFlagger.mockCohorts = [
TdsExperimentType(rawValue: 1)!.subfeature.rawValue: TdsNextExperimentFlag.Cohort.treatment,
TdsExperimentType(rawValue: 2)!.subfeature.rawValue: TdsNextExperimentFlag.Cohort.treatment
TDSExperimentType(rawValue: 1)!.subfeature.rawValue: TDSNextExperimentFlag.Cohort.treatment,
TDSExperimentType(rawValue: 2)!.subfeature.rawValue: TDSNextExperimentFlag.Cohort.treatment
]

// WHEN
let url = try XCTUnwrap(urlProvider.trackerDataURL)

// THEN
XCTAssertEqual(url.absoluteString, TrackerDataURLOverrider.Constants.baseTdsURLString + secondExperimentTreatmentURL)
XCTAssertEqual(url.absoluteString, TrackerDataURLOverrider.Constants.baseTDSURLString + secondExperimentTreatmentURL)
}

}
Expand Down
Loading

0 comments on commit 4232acb

Please sign in to comment.