Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apple client send and receive support for crash report cohort IDs (CRCIDs) #1116

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
88ae14a
Adopt dependency injection support for CrashCollection
jdjackson Dec 1, 2024
8583c2d
CrashCollectionTests: First test and supporting implementation nearly…
jdjackson Dec 3, 2024
24cb701
Support for CRCID send/receive
jdjackson Dec 4, 2024
418c64c
CrashCollection supports CRCID store and retrieve
jdjackson Dec 4, 2024
3b5d42a
Indentation fix
jdjackson Dec 4, 2024
1f6b78a
2 more tests implemented
jdjackson Dec 4, 2024
8440d72
Clarified remaining potential future tests
jdjackson Dec 4, 2024
4dbf3f0
Cleanup pre-code-review
jdjackson Dec 5, 2024
34ddc9c
Add support for clearing CRCID from client apps
jdjackson Dec 6, 2024
28595e2
Removing debug-only code
jdjackson Dec 6, 2024
acc46b7
Refactor CRCID storage management into new CRCIDManager class
jdjackson Dec 6, 2024
4cb1360
Linting fixes
jdjackson Dec 9, 2024
1d3a373
More linting fixes
jdjackson Dec 9, 2024
3d944d3
More linting fixes, and removing unnecessary TODOs
jdjackson Dec 9, 2024
c56f50f
More linting fixes
jdjackson Dec 9, 2024
2f921b1
Prep for pixels to detect crash reporting failures
jdjackson Dec 9, 2024
4683fb9
Getting close to a good solution for pixel tracking of failed crash s…
jdjackson Dec 12, 2024
f920d03
Removing irrelevant or infisible tests
jdjackson Dec 12, 2024
d949750
Change to submit CRCID header even when not present locally
jdjackson Dec 12, 2024
57f8280
Clean up naming of CrashReportSenderError.crcidMissing
jdjackson Dec 12, 2024
073fff9
Fixing sending of empty CRCID header when no value is present
jdjackson Dec 16, 2024
8053808
Clearing unneeded TODO
jdjackson Dec 16, 2024
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
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,8 @@ let package = Package(
.testTarget(
name: "CrashesTests",
dependencies: [
"Crashes"
"Crashes",
"TestUtils"
]
),
.testTarget(
Expand Down
80 changes: 72 additions & 8 deletions Sources/Crashes/CrashCollection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@

import Foundation
import MetricKit
import Persistence
import os.log
import Common

public enum CrashCollectionPlatform {
case iOS, macOS, macOSAppStore
Expand All @@ -38,9 +41,12 @@
@available(iOS 13, macOS 12, *)
public final class CrashCollection {

public init(platform: CrashCollectionPlatform) {
crashHandler = CrashHandler()
crashSender = CrashReportSender(platform: platform)
public init(crashReportSender: CrashReportSending,
crashCollectionStorage: KeyValueStoring = UserDefaults()) {

Check failure on line 45 in Sources/Crashes/CrashCollection.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

Function parameters should be aligned vertically if they're in multiple lines in a declaration (vertical_parameter_alignment)
self.crashHandler = CrashHandler()
self.crashSender = crashReportSender
self.crashCollectionStorage = crashCollectionStorage
self.crcidManager = CRCIDManager(store: crashCollectionStorage)
}

public func start(didFindCrashReports: @escaping (_ pixelParameters: [[String: String]], _ payloads: [Data], _ uploadReports: @escaping () -> Void) -> Void) {
Expand All @@ -55,7 +61,11 @@
/// - didFindCrashReports: callback called after payload preprocessing is finished.
/// Provides processed JSON data to be presented to the user and Pixel parameters to fire a crash Pixel.
/// `uploadReports` callback is used when the user accepts uploading the crash report and starts crash upload to the server.
public func start(process: @escaping ([MXDiagnosticPayload]) -> [Data], didFindCrashReports: @escaping (_ pixelParameters: [[String: String]], _ payloads: [Data], _ uploadReports: @escaping () -> Void) -> Void) {
public func start(process: @escaping ([MXDiagnosticPayload]) -> [Data],
didFindCrashReports: @escaping (_ pixelParameters: [[String: String]],
_ payloads: [Data],
_ uploadReports: @escaping () -> Void) -> Void,
didFinishHandlingResponse: @escaping (() -> Void) = {}) {
let first = isFirstCrash
isFirstCrash = false

Expand All @@ -80,8 +90,10 @@
didFindCrashReports(pixelParameters, processedData) {
Task {
for payload in processedData {
await self.crashSender.send(payload)
let result = await self.crashSender.send(payload, crcid: self.crcidManager.crcid)
self.crcidManager.handleCrashSenderResult(result: result.result, response: result.response)
}
didFinishHandlingResponse()
}
}
}
Expand Down Expand Up @@ -171,20 +183,72 @@
}, didFindCrashReports: didFindCrashReports)
}

public func clearCRCID() {
self.crcidManager.crcid = ""
}

var isFirstCrash: Bool {
get {
UserDefaults().object(forKey: Const.firstCrashKey) as? Bool ?? true
crashCollectionStorage.object(forKey: Const.firstCrashKey) as? Bool ?? true
}

set {
UserDefaults().set(newValue, forKey: Const.firstCrashKey)
crashCollectionStorage.set(newValue, forKey: Const.firstCrashKey)
}
}

let crashHandler: CrashHandler
let crashSender: CrashReportSender
let crashSender: CrashReportSending
let crashCollectionStorage: KeyValueStoring
let crcidManager: CRCIDManager

enum Const {
static let firstCrashKey = "CrashCollection.first"
}
}

// TODO: This should really be its own file, but adding a new file to BSK and propagating it to iOS and macOS projects is hard. This can be done as a separate PR once the main changes land across all 3 repos

Check failure on line 210 in Sources/Crashes/CrashCollection.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

TODOs should be resolved (This should really be its own ...) (todo)
// import Foundation
// import Persistence
// import os.log

// Cohort identifier used exclusively to distinguish systemic crashes, only after the user opts in to send them.
// Its purpose is strictly limited to improving the reliability of crash reporting and is never used elsewhere.
public class CRCIDManager {
static let crcidKey = "CRCIDManager.crcidKey"
var store: KeyValueStoring

public init(store: KeyValueStoring = UserDefaults()) {
self.store = store
}

public func handleCrashSenderResult(result: Result<Data?, Error>, response: HTTPURLResponse?) {
switch result {
case .success:
Logger.general.debug("Crash Collection - Sending Crash Report: succeeded")
if let receivedCRCID = response?.allHeaderFields[CrashReportSender.httpHeaderCRCID] as? String {
if crcid != receivedCRCID {
Logger.general.debug("Crash Collection - Received new value for CRCID: \(receivedCRCID), setting local crcid value")
crcid = receivedCRCID
} else {
Logger.general.debug("Crash Collection - Received matching value for CRCID: \(receivedCRCID), no update necessary")
}
} else {
Logger.general.debug("Crash Collection - No value for CRCID header: \(CRCIDManager.crcidKey), clearing local crcid value if present")
crcid = ""
}
case .failure(let failure):
Logger.general.debug("Crash Collection - Sending Crash Report: failed (\(failure))")
}
}

public var crcid: String {
get {
return self.store.object(forKey: CRCIDManager.crcidKey) as? String ?? ""
}

set {
store.set(newValue, forKey: CRCIDManager.crcidKey)
}
}
}
77 changes: 68 additions & 9 deletions Sources/Crashes/CrashReportSender.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,88 @@

import Foundation
import MetricKit
import Common

public final class CrashReportSender {
public protocol CrashReportSending {
var pixelEvents: EventMapping<CrashReportSenderError>? { get }

init(platform: CrashCollectionPlatform, pixelEvents: EventMapping<CrashReportSenderError>?)

func send(_ crashReportData: Data, crcid: String?) async -> (result: Result<Data?, Error>, response: HTTPURLResponse?)
func send(_ crashReportData: Data, crcid: String?, completion: @escaping (_ result: Result<Data?, Error>, _ response: HTTPURLResponse?) -> Void)
}

public enum CrashReportSenderError: Error {
case crcidMissing
case submissionFailed(HTTPURLResponse?)
}

// By conforming to a protocol, we can sub in mocks more easily
public final class CrashReportSender: CrashReportSending {
static let reportServiceUrl = URL(string: "https://9e3c-20-75-144-152.ngrok-free.app/crash.js")!

static let httpHeaderCRCID = "crcid"

static let reportServiceUrl = URL(string: "https://duckduckgo.com/crash.js")!
public let platform: CrashCollectionPlatform
public var pixelEvents: EventMapping<CrashReportSenderError>?

public init(platform: CrashCollectionPlatform) {
private let session = URLSession(configuration: .ephemeral)

public init(platform: CrashCollectionPlatform, pixelEvents: EventMapping<CrashReportSenderError>?) {
self.platform = platform
self.pixelEvents = pixelEvents
}

public func send(_ crashReportData: Data) async {
public func send(_ crashReportData: Data, crcid: String?, completion: @escaping (_ result: Result<Data?, Error>, _ response: HTTPURLResponse?) -> Void) {
var request = URLRequest(url: Self.reportServiceUrl)
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
request.setValue(platform.userAgent, forHTTPHeaderField: "User-Agent")

request.setValue(crcid, forHTTPHeaderField: CrashReportSender.httpHeaderCRCID)
Logger.general.debug("Configured crash report HTTP request with crcid: \(crcid ?? "nil")")

request.httpMethod = "POST"
request.httpBody = crashReportData

do {
_ = try await session.data(for: request)
} catch {
assertionFailure("CrashReportSender: Failed to send the crash report")
Logger.general.debug("CrashReportSender: Awaiting session data")
let task = session.dataTask(with: request) { data, response, error in
if let response = response as? HTTPURLResponse {
Logger.general.debug("CrashReportSender: Received HTTP response code: \(response.statusCode)")
if response.statusCode == 200 {
response.allHeaderFields.forEach { print("\($0.key): \($0.value)") } // TODO: Why do we straight-up print these, rather than debug logging?

Check failure on line 69 in Sources/Crashes/CrashReportSender.swift

View workflow job for this annotation

GitHub Actions / Run SwiftLint

TODOs should be resolved (Why do we straight-up print th...) (todo)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@samsymons @loremattei @ayoy Does it make sense for me to convert this to a Logger.general.debug call instead of a simple print?

if response.allHeaderFields[CrashReportSender.httpHeaderCRCID] == nil {
let crashReportError = CrashReportSenderError.crcidMissing
self.pixelEvents?.fire(crashReportError)
}
} else {
assertionFailure("CrashReportSender: Failed to send the crash report: \(response.statusCode)")
}

if let data {
completion(.success(data), response)
} else if let error {
let crashReportError = CrashReportSenderError.submissionFailed(response)
self.pixelEvents?.fire(crashReportError)
completion(.failure(error), response)
} else {
let crashReportError = CrashReportSenderError.submissionFailed(response)
self.pixelEvents?.fire(crashReportError)
completion(.failure(crashReportError), response)
}
} else {
let crashReportError = CrashReportSenderError.submissionFailed(nil)
self.pixelEvents?.fire(crashReportError)
completion(.failure(crashReportError), nil)
}
}
task.resume()
}

private let session = URLSession(configuration: .ephemeral)
public func send(_ crashReportData: Data, crcid: String?) async -> (result: Result<Data?, Error>, response: HTTPURLResponse?) {
await withCheckedContinuation { continuation in
send(crashReportData, crcid: crcid) { result, response in
continuation.resume(returning: (result, response))
}
}
}
}
4 changes: 2 additions & 2 deletions Sources/TestUtils/MockKeyValueStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
import Foundation
import Persistence

public class MockKeyValueStore: KeyValueStoring {
public class MockKeyValueStore: NSObject, KeyValueStoring {

public var store = [String: Any?]()

public init() { }
public override init() { }

public func object(forKey defaultName: String) -> Any? {
return store[defaultName] as Any?
Expand Down
Loading
Loading