Skip to content

Commit

Permalink
Add Privacy Stats module for collecting stats about blocked trackers (#…
Browse files Browse the repository at this point in the history
…1097)

Task/Issue URL: https://app.asana.com/0/72649045549333/1208246350498754/f
Tech Design URL: https://app.asana.com/0/481882893211075/1208848285586302

Description:
This change adds PrivacyStats module that will be used in macOS HTML New Tab Page.
Privacy Stats uses Core Data for storage where it keeps blocked trackers counts per tracker
company name per day, for past 7 days.
PrivacyStats class is the main (and only) public interface. It provides simple API to fetch current
stats, clear them (for Fire button use) and record blocked trackers, as well as a publisher
that notifies about changes to stats.
  • Loading branch information
ayoy authored Nov 29, 2024
1 parent 09fd124 commit dfd266a
Show file tree
Hide file tree
Showing 17 changed files with 1,593 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,20 @@
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PrivacyStats"
BuildableName = "PrivacyStats"
BlueprintName = "PrivacyStats"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
Expand Down Expand Up @@ -818,6 +832,16 @@
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PrivacyStatsTests"
BuildableName = "PrivacyStatsTests"
BlueprintName = "PrivacyStatsTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
Expand Down
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/duckduckgo/content-scope-scripts",
"state" : {
"revision" : "dfef00ef77f5181d1d8a4f7cc88f7b7c0514dd34",
"version" : "6.39.0"
"revision" : "c4bb146afdf0c7a93fb9a7d95b1cb255708a470d",
"version" : "6.41.0"
}
},
{
Expand Down
24 changes: 23 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,15 @@ let package = Package(
.library(name: "Onboarding", targets: ["Onboarding"]),
.library(name: "BrokenSitePrompt", targets: ["BrokenSitePrompt"]),
.library(name: "PageRefreshMonitor", targets: ["PageRefreshMonitor"]),
.library(name: "PrivacyStats", targets: ["PrivacyStats"]),
],
dependencies: [
.package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "15.1.0"),
.package(url: "https://github.com/duckduckgo/GRDB.swift.git", exact: "2.4.2"),
.package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "3.0.0"),
.package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.3.0"),
.package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "3.0.0"),
.package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "6.39.0"),
.package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "6.41.0"),
.package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "7.2.1"),
.package(url: "https://github.com/httpswift/swifter.git", exact: "1.5.0"),
.package(url: "https://github.com/duckduckgo/bloom_cpp.git", exact: "3.0.0"),
Expand Down Expand Up @@ -445,6 +446,20 @@ let package = Package(
.define("DEBUG", .when(configuration: .debug))
]
),
.target(
name: "PrivacyStats",
dependencies: [
"Common",
"Persistence",
"TrackerRadarKit"
],
resources: [
.process("PrivacyStats.xcdatamodeld")
],
swiftSettings: [
.define("DEBUG", .when(configuration: .debug))
]
),

// MARK: - Test Targets
.testTarget(
Expand Down Expand Up @@ -679,6 +694,13 @@ let package = Package(
"PageRefreshMonitor"
]
),
.testTarget(
name: "PrivacyStatsTests",
dependencies: [
"PrivacyStats",
"TestUtils",
]
),
],
cxxLanguageStandard: .cxx11
)
6 changes: 5 additions & 1 deletion Sources/Common/Extensions/DateExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ public extension Date {
}

var startOfDay: Date {
return Calendar.current.startOfDay(for: self)
return Calendar.current.startOfDay(for: self)
}

func daysAgo(_ days: Int) -> Date {
Calendar.current.date(byAdding: .day, value: -days, to: self)!
}

static var startOfMinuteNow: Date {
Expand Down
24 changes: 24 additions & 0 deletions Sources/PrivacyStats/Logger+PrivacyStats.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// Logger+PrivacyStats.swift
//
// 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

public extension Logger {
static var privacyStats = { Logger(subsystem: "Privacy Stats", category: "") }()
}
249 changes: 249 additions & 0 deletions Sources/PrivacyStats/PrivacyStats.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
//
// PrivacyStats.swift
//
// 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 Combine
import Common
import CoreData
import Foundation
import os.log
import Persistence
import TrackerRadarKit

/**
* Errors that may be reported by `PrivacyStats`.
*/
public enum PrivacyStatsError: CustomNSError {
case failedToFetchPrivacyStatsSummary(Error)
case failedToStorePrivacyStats(Error)
case failedToLoadCurrentPrivacyStats(Error)

public static let errorDomain: String = "PrivacyStatsError"

public var errorCode: Int {
switch self {
case .failedToFetchPrivacyStatsSummary:
return 1
case .failedToStorePrivacyStats:
return 2
case .failedToLoadCurrentPrivacyStats:
return 3
}
}

public var underlyingError: Error {
switch self {
case .failedToFetchPrivacyStatsSummary(let error),
.failedToStorePrivacyStats(let error),
.failedToLoadCurrentPrivacyStats(let error):
return error
}
}
}

/**
* This protocol describes database provider consumed by `PrivacyStats`.
*/
public protocol PrivacyStatsDatabaseProviding {
func initializeDatabase() -> CoreDataDatabase
}

/**
* This protocol describes `PrivacyStats` interface.
*/
public protocol PrivacyStatsCollecting {

/**
* Record a tracker for a given `companyName`.
*
* `PrivacyStats` implementation calls the `CurrentPack` actor under the hood,
* and as such it can safely be called on multiple threads concurrently.
*/
func recordBlockedTracker(_ name: String) async

/**
* Publisher emitting values whenever updated privacy stats were persisted to disk.
*/
var statsUpdatePublisher: AnyPublisher<Void, Never> { get }

/**
* This function fetches privacy stats in a dictionary format
* with keys being company names and values being total number
* of tracking attempts blocked in past 7 days.
*/
func fetchPrivacyStats() async -> [String: Int64]

/**
* This function clears all blocked tracker stats from the database.
*/
func clearPrivacyStats() async

/**
* This function saves all pending changes to the persistent storage.
*
* It should only be used in response to app termination because otherwise
* the `PrivacyStats` object schedules persisting internally.
*/
func handleAppTermination() async
}

public final class PrivacyStats: PrivacyStatsCollecting {

public static let bundle = Bundle.module

public let statsUpdatePublisher: AnyPublisher<Void, Never>

private let db: CoreDataDatabase
private let context: NSManagedObjectContext
private let currentPack: CurrentPack
private let statsUpdateSubject = PassthroughSubject<Void, Never>()
private var cancellables: Set<AnyCancellable> = []

private let errorEvents: EventMapping<PrivacyStatsError>?

public init(databaseProvider: PrivacyStatsDatabaseProviding, errorEvents: EventMapping<PrivacyStatsError>? = nil) {
self.db = databaseProvider.initializeDatabase()
self.context = db.makeContext(concurrencyType: .privateQueueConcurrencyType, name: "PrivacyStats")
self.errorEvents = errorEvents
self.currentPack = .init(pack: Self.initializeCurrentPack(in: context, errorEvents: errorEvents))
statsUpdatePublisher = statsUpdateSubject.eraseToAnyPublisher()

currentPack.commitChangesPublisher
.sink { [weak self] pack in
Task {
await self?.commitChanges(pack)
}
}
.store(in: &cancellables)
}

public func recordBlockedTracker(_ companyName: String) async {
await currentPack.recordBlockedTracker(companyName)
}

public func fetchPrivacyStats() async -> [String: Int64] {
return await withCheckedContinuation { continuation in
context.perform { [weak self] in
guard let self else {
continuation.resume(returning: [:])
return
}
do {
let stats = try PrivacyStatsUtils.load7DayStats(in: context)
continuation.resume(returning: stats)
} catch {
errorEvents?.fire(.failedToFetchPrivacyStatsSummary(error))
continuation.resume(returning: [:])
}
}
}
}

public func clearPrivacyStats() async {
await withCheckedContinuation { continuation in
context.perform { [weak self] in
guard let self else {
continuation.resume()
return
}
do {
try PrivacyStatsUtils.deleteAllStats(in: context)
Logger.privacyStats.debug("Deleted outdated entries")
} catch {
Logger.privacyStats.error("Save error: \(error)")
errorEvents?.fire(.failedToFetchPrivacyStatsSummary(error))
}
continuation.resume()
}
}
await currentPack.resetPack()
statsUpdateSubject.send()
}

public func handleAppTermination() async {
await commitChanges(currentPack.pack)
}

// MARK: - Private

private func commitChanges(_ pack: PrivacyStatsPack) async {
await withCheckedContinuation { continuation in
context.perform { [weak self] in
guard let self else {
continuation.resume()
return
}

// Check if the pack we're currently storing is from a previous day.
let isCurrentDayPack = pack.timestamp == Date.currentPrivacyStatsPackTimestamp

do {
let statsObjects = try PrivacyStatsUtils.fetchOrInsertCurrentStats(for: Set(pack.trackers.keys), in: context)
statsObjects.forEach { stats in
if let count = pack.trackers[stats.companyName] {
stats.count = count
}
}

guard context.hasChanges else {
continuation.resume()
return
}

try context.save()
Logger.privacyStats.debug("Saved stats \(pack.timestamp) \(pack.trackers)")

if isCurrentDayPack {
// Only emit update event when saving current-day pack. For previous-day pack,
// a follow-up commit event will come and we'll emit the update then.
statsUpdateSubject.send()
} else {
// When storing a pack from a previous day, we may have outdated packs, so delete them as needed.
try PrivacyStatsUtils.deleteOutdatedPacks(in: context)
}
} catch {
Logger.privacyStats.error("Save error: \(error)")
errorEvents?.fire(.failedToStorePrivacyStats(error))
}
continuation.resume()
}
}
}

/**
* This function is only called in the initializer. It performs a blocking call to the database
* to spare us the hassle of declaring the initializer async or spawning tasks from within the
* initializer without being able to await them, thus making testing trickier.
*/
private static func initializeCurrentPack(in context: NSManagedObjectContext, errorEvents: EventMapping<PrivacyStatsError>?) -> PrivacyStatsPack {
var pack: PrivacyStatsPack?
context.performAndWait {
let timestamp = Date.currentPrivacyStatsPackTimestamp
do {
let currentDayStats = try PrivacyStatsUtils.loadCurrentDayStats(in: context)
Logger.privacyStats.debug("Loaded stats \(timestamp) \(currentDayStats)")
pack = PrivacyStatsPack(timestamp: timestamp, trackers: currentDayStats)

try PrivacyStatsUtils.deleteOutdatedPacks(in: context)
} catch {
Logger.privacyStats.error("Failed to load current stats: \(error)")
errorEvents?.fire(.failedToLoadCurrentPrivacyStats(error))
}
}
return pack ?? PrivacyStatsPack(timestamp: Date.currentPrivacyStatsPackTimestamp)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>PrivacyStats.xcdatamodel</string>
</dict>
</plist>
Loading

0 comments on commit dfd266a

Please sign in to comment.