Skip to content

Commit

Permalink
Add support for uploading crash reports to Sentry (#777)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/72649045549333/1205862129634403/f

Description:
Add CrashReportSender (moved over from the macOS app) and update CrashCollection
to support sending crashes reported by MetricKit to the backend.
  • Loading branch information
ayoy authored Apr 18, 2024
1 parent 7eab61a commit 4ce0496
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 38 deletions.
12 changes: 10 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,10 @@ let package = Package(
"BloomFilterObjC",
]),
.target(
name: "Crashes"
),
name: "Crashes",
dependencies: [
"Common",
]),
.target(
name: "DDGSync",
dependencies: [
Expand Down Expand Up @@ -375,6 +377,12 @@ let package = Package(
.copy("Resources")
]
),
.testTarget(
name: "CrashesTests",
dependencies: [
"Crashes"
]
),
.testTarget(
name: "DDGSyncTests",
dependencies: [
Expand Down
97 changes: 61 additions & 36 deletions Sources/Crashes/CrashCollection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,61 +19,86 @@
import Foundation
import MetricKit

public enum CrashCollectionPlatform {
case iOS, macOS, macOSAppStore

var userAgent: String {
switch self {
case .iOS:
return "ddg_ios"
case .macOS:
return "ddg_mac"
case .macOSAppStore:
return "ddg_mac_appstore"
}
}
}

@available(iOSApplicationExtension, unavailable)
@available(iOS 13, macOS 12, *)
public struct CrashCollection {

// Need a strong reference
static let collector = CrashCollector()
public final class CrashCollection {

public static func collectCrashesAsync(completion: @escaping ([String: String]) -> Void) {
collector.completion = completion
MXMetricManager.shared.add(collector)
public var log: OSLog {
getLog()
}
private let getLog: () -> OSLog

final class CrashCollector: NSObject, MXMetricManagerSubscriber {
public init(platform: CrashCollectionPlatform, log: @escaping @autoclosure () -> OSLog = .disabled) {
self.getLog = log
crashHandler = CrashHandler()
crashSender = CrashReportSender(platform: platform, log: log())
}

var completion: ([String: String]) -> Void = { _ in }
public func start(_ didFindCrashReports: @escaping (_ pixelParameters: [[String: String]],
_ payloads: [MXDiagnosticPayload],
_ uploadReports: @escaping () -> Void) -> Void
) {
let first = isFirstCrash
isFirstCrash = false

func didReceive(_ payloads: [MXDiagnosticPayload]) {
payloads
.compactMap { $0.crashDiagnostics }
crashHandler.crashDiagnosticsPayloadHandler = { payloads in
let pixelParameters = payloads
.compactMap(\.crashDiagnostics)
.flatMap { $0 }
.forEach {
completion([
"appVersion": "\($0.applicationVersion).\($0.metaData.applicationBuildVersion)",
"code": "\($0.exceptionCode ?? -1)",
"type": "\($0.exceptionType ?? -1)",
"signal": "\($0.signal ?? -1)"
])
.map { diagnostic in
var params = [
"appVersion": "\(diagnostic.applicationVersion).\(diagnostic.metaData.applicationBuildVersion)",
"code": "\(diagnostic.exceptionCode ?? -1)",
"type": "\(diagnostic.exceptionType ?? -1)",
"signal": "\(diagnostic.signal ?? -1)"
]
if first {
params["first"] = "1"
}
return params
}

didFindCrashReports(pixelParameters, payloads) {
Task {
for payload in payloads {
await self.crashSender.send(payload.jsonRepresentation())
}
}
}
}

MXMetricManager.shared.add(crashHandler)
}

static let firstCrashKey = "CrashCollection.first"

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

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

public static func start(firePixel: @escaping ([String: String]) -> Void) {
let first = Self.firstCrash
CrashCollection.collectCrashesAsync { params in
var params = params
if first {
params["first"] = "1"
}
firePixel(params)
}
// Turn the flag off for next time
Self.firstCrash = false
}
let crashHandler: CrashHandler
let crashSender: CrashReportSender

enum Const {
static let firstCrashKey = "CrashCollection.first"
}
}
33 changes: 33 additions & 0 deletions Sources/Crashes/CrashHandler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// CrashHandler.swift
//
// 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 MetricKit

@available(iOSApplicationExtension, unavailable)
@available(iOS 13, macOS 12, *)
final class CrashHandler: NSObject, MXMetricManagerSubscriber {

var crashDiagnosticsPayloadHandler: ([MXDiagnosticPayload]) -> Void = { _ in }

func didReceive(_ payloads: [MXDiagnosticPayload]) {
let payloadsWithCrash = payloads.filter { !($0.crashDiagnostics?.isEmpty ?? true) }
crashDiagnosticsPayloadHandler(payloadsWithCrash)
}

}
53 changes: 53 additions & 0 deletions Sources/Crashes/CrashReportSender.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// CrashReportSender.swift
//
// 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 MetricKit

public final class CrashReportSender {

static let reportServiceUrl = URL(string: "https://duckduckgo.com/crash.js")!

public let platform: CrashCollectionPlatform

public var log: OSLog {
getLog()
}
private let getLog: () -> OSLog

public init(platform: CrashCollectionPlatform, log: @escaping @autoclosure () -> OSLog = .disabled) {
self.platform = platform
getLog = log
}

public func send(_ crashReportData: Data) async {
var request = URLRequest(url: Self.reportServiceUrl)
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
request.setValue(platform.userAgent, forHTTPHeaderField: "User-Agent")
request.httpMethod = "POST"
request.httpBody = crashReportData

do {
_ = try await session.data(for: request)
} catch {
assertionFailure("CrashReportSender: Failed to send the crash report")
}
}

private let session = URLSession(configuration: .ephemeral)
}
91 changes: 91 additions & 0 deletions Tests/CrashesTests/CrashCollectionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// CrashCollectionTests.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.
//

@testable import Crashes
import MetricKit
import XCTest

class CrashCollectionTests: XCTestCase {

override func setUp() {
super.setUp()
clearUserDefaults()
}

override func tearDown() {
super.tearDown()
clearUserDefaults()
}

func testFirstCrashFlagSent() {
let crashCollection = CrashCollection(platform: .iOS)
// 2 pixels with first = true attached
XCTAssertTrue(crashCollection.isFirstCrash)
crashCollection.start { pixelParameters, _, _ in
let firstFlags = pixelParameters.compactMap { $0["first"] }
XCTAssertFalse(firstFlags.isEmpty)
}
crashCollection.crashHandler.didReceive([
MockPayload(mockCrashes: [
MXCrashDiagnostic(),
MXCrashDiagnostic()
])
])
XCTAssertFalse(crashCollection.isFirstCrash)
}

func testSubsequentPixelsDontSendFirstFlag() {
let crashCollection = CrashCollection(platform: .iOS)
// 2 pixels with no first parameter
crashCollection.isFirstCrash = false
crashCollection.start { pixelParameters, _, _ in
let firstFlags = pixelParameters.compactMap { $0["first"] }
XCTAssertTrue(firstFlags.isEmpty)
}
crashCollection.crashHandler.didReceive([
MockPayload(mockCrashes: [
MXCrashDiagnostic(),
MXCrashDiagnostic()
])
])
XCTAssertFalse(crashCollection.isFirstCrash)
}

private func clearUserDefaults() {
UserDefaults().removeObject(forKey: CrashCollection.Const.firstCrashKey)
}
}

class MockPayload: MXDiagnosticPayload {

var mockCrashes: [MXCrashDiagnostic]?

init(mockCrashes: [MXCrashDiagnostic]?) {
self.mockCrashes = mockCrashes
super.init()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override var crashDiagnostics: [MXCrashDiagnostic]? {
return mockCrashes
}

}

0 comments on commit 4ce0496

Please sign in to comment.