├── .github └── FUNDING.yml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md └── Sources └── MeterReporter ├── DiagnosticSubscriber.swift ├── ExceptionLoggingApplication.swift ├── MeterReporter.swift └── UncaughtExceptionLogger.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [mattmassicotte] 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at support@chimehq.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Chime 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "background", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/ChimeHQ/Background", 7 | "state" : { 8 | "revision" : "11f1bc95a7ec88c275b522b12883ada9dbc062e6" 9 | } 10 | }, 11 | { 12 | "identity" : "meter", 13 | "kind" : "remoteSourceControl", 14 | "location" : "https://github.com/ChimeHQ/Meter", 15 | "state" : { 16 | "revision" : "3080bca08f99dbd9bd027caec15deb8b4b69d99a", 17 | "version" : "0.4.0" 18 | } 19 | }, 20 | { 21 | "identity" : "wells", 22 | "kind" : "remoteSourceControl", 23 | "location" : "https://github.com/ChimeHQ/Wells", 24 | "state" : { 25 | "revision" : "c31f833829083c5966adeff16832879acdcad214" 26 | } 27 | } 28 | ], 29 | "version" : 2 30 | } 31 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "MeterReporter", 7 | platforms: [ 8 | .macOS(.v11), 9 | .macCatalyst(.v13), 10 | .iOS(.v12), 11 | .tvOS(.v12), 12 | .watchOS(.v4) 13 | ], 14 | products: [ 15 | .library(name: "MeterReporter", targets: ["MeterReporter"]), 16 | ], 17 | dependencies: [ 18 | .package(url: "https://github.com/ChimeHQ/Meter", from: "0.4.0"), 19 | .package(url: "https://github.com/ChimeHQ/Wells", revision: "c31f833829083c5966adeff16832879acdcad214"), 20 | ], 21 | targets: [ 22 | .target(name: "MeterReporter", dependencies: ["Meter", "Wells"]), 23 | ] 24 | ) 25 | 26 | let swiftSettings: [SwiftSetting] = [ 27 | .enableExperimentalFeature("StrictConcurrency") 28 | ] 29 | 30 | for target in package.targets { 31 | var settings = target.swiftSettings ?? [] 32 | settings.append(contentsOf: swiftSettings) 33 | target.swiftSettings = settings 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [![Platforms][platforms badge]][platforms] 4 | [![Matrix][matrix badge]][matrix] 5 | 6 |
7 | 8 | # MeterReporter 9 | Lightweight MetricKit-based diagnostics reporting. 10 | 11 | MeterReporter will capture MetricKit payloads and relay them to a backend. It uses [Meter](https://github.com/ChimeHQ/Meter) to process and symbolicate payloads. The resulting data is very close to the MetricKit JSON structure. But, it does add some fields to support the additional features. 12 | 13 | ## Integration 14 | ```swift 15 | dependencies: [ 16 | .package(url: "https://github.com/ChimeHQ/MeterReporter") 17 | ] 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```swift 23 | let url = URL(string: "https://my.backend.com/reports")! 24 | 25 | var config = MeterReporter.Configuration(endpointURL: url) 26 | 27 | config.hostIdentifier = Bundle.main.bundleIdentifier 28 | 29 | let reporter = MeterReporter(configuration: config) 30 | 31 | reporter.start() 32 | ``` 33 | 34 | ## Background Uploads 35 | 36 | By default, MeterReporter uses `URLSession` background uploads for both reliability and performance. However, sometimes these can take **hours** for the OS to actually execute. This can be a pain if you are just testing things out. To make that easier, you can disable background uploads with another property of `MeterReporter.Configuration`. 37 | 38 | ## NSException Capture 39 | 40 | MeterReporter can capture uncaught NSExceptions on macOS. Unfortunately, AppKit interfers with the flow of runtime exceptions. If you want to get this information about uncaught exceptions, some extra work is required. 41 | 42 | The top-level `NSApplication` instance for your app must be a subclass of `ExceptionLoggingApplication`. 43 | 44 | ```swift 45 | import MeterReporter 46 | 47 | class Application: ExceptionLoggingApplication { 48 | } 49 | ``` 50 | 51 | and, you must update your Info.plist to ensure that the `NSPrincipalClass` key references this class with `.Application`. 52 | 53 | I realize this is a huge pain. If you feel so motivated, please file feedback with Apple to ask them to make AppKit behave like UIKit in this respect. 54 | 55 | I would also strongly recommend setting the `NSApplicationCrashOnExceptions` defaults key to true. The default setting will allow your application to continue executing post-exception, virtually guaranteeing state corruption and incorrect behavior. 56 | 57 | ## Submission Request 58 | 59 | The request made to the endpoint will be an HTTP `PUT`. The request will also set some headers. 60 | 61 | - `Content-Type` will be `application/vnd.chimehq-mxdiagnostic` 62 | - `MeterReporter-Report-Id` will be a unique identifier 63 | - `MeterReporter-Platform` 64 | - `MeterReporter-Host-Id` if `configuration.hostIdentifier` is non-nil 65 | 66 | The data itself is the result of Meter's `DiagnosticPayload.jsonRepresentation()`. 67 | 68 | ## Suggestions or Feedback 69 | 70 | I would love to hear from you! Issues or pull requests work great. A [Matrix space][matrix] is also available for live help, but I have a strong bias towards answering in the form of documentation. 71 | 72 | I prefer collaboration, and would love to find ways to work together if you have a similar project. 73 | 74 | I prefer indentation with tabs for improved accessibility. But, I'd rather you use the system you want and make a PR than hesitate because of whitespace. 75 | 76 | By participating in this project you agree to abide by the [Contributor Code of Conduct](CODE_OF_CONDUCT.md). 77 | 78 | [platforms]: https://swiftpackageindex.com/ChimeHQ/MeterReporter 79 | [platforms badge]: https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FChimeHQ%2FMeterReporter%2Fbadge%3Ftype%3Dplatforms 80 | [matrix]: https://matrix.to/#/%23chimehq%3Amatrix.org 81 | [matrix badge]: https://img.shields.io/matrix/chimehq%3Amatrix.org?label=Matrix 82 | -------------------------------------------------------------------------------- /Sources/MeterReporter/DiagnosticSubscriber.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(MetricKit) 3 | import MetricKit 4 | #endif 5 | 6 | class DiagnosticSubscriber: NSObject { 7 | var onReceive: (([Data]) -> Void)? 8 | 9 | override init() { 10 | super.init() 11 | } 12 | 13 | static var metricKitAvailable: Bool { 14 | #if (os(iOS) || os(macOS)) && compiler(>=5.5.1) 15 | if #available(iOS 14.0, macOS 12.0, *) { 16 | return true 17 | } 18 | #endif 19 | 20 | return false 21 | } 22 | 23 | func start() { 24 | #if (os(iOS) || os(macOS)) && compiler(>=5.5.1) 25 | if #available(iOS 14.0, macOS 12.0, *) { 26 | MXMetricManager.shared.add(self) 27 | } 28 | #endif 29 | } 30 | } 31 | 32 | #if (os(iOS) || os(macOS)) && compiler(>=5.5.1) 33 | @available(iOS 14.0, macOS 12.0, *) 34 | extension DiagnosticSubscriber: MXMetricManagerSubscriber { 35 | func didReceive(_ payloads: [MXDiagnosticPayload]) { 36 | guard payloads.isEmpty == false else { return } 37 | 38 | onReceive?(payloads.map({ $0.jsonRepresentation() })) 39 | } 40 | } 41 | #endif 42 | -------------------------------------------------------------------------------- /Sources/MeterReporter/ExceptionLoggingApplication.swift: -------------------------------------------------------------------------------- 1 | #if os(macOS) 2 | import Cocoa 3 | import Meter 4 | 5 | open class ExceptionLoggingApplication: NSApplication { 6 | public var exceptionInfoURL: URL? 7 | 8 | public override func reportException(_ exception: NSException) { 9 | writeExceptionInfo(with: exception) 10 | super.reportException(exception) 11 | } 12 | 13 | private func writeExceptionInfo(with exception: NSException) { 14 | guard let url = exceptionInfoURL else { 15 | return 16 | } 17 | 18 | let info = ExceptionInfo(exception: exception) 19 | 20 | try? info.write(to: url) 21 | } 22 | } 23 | 24 | #endif 25 | -------------------------------------------------------------------------------- /Sources/MeterReporter/MeterReporter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Wells 3 | import Meter 4 | @preconcurrency import os.log 5 | #if os(macOS) 6 | import AppKit 7 | #endif 8 | 9 | extension UUID { 10 | var lowerAlphaOnly: String { 11 | return uuidString.replacingOccurrences(of: "-", with: "").lowercased() 12 | } 13 | } 14 | 15 | /// Collect and relay MetricKit payloads. 16 | /// 17 | /// This class will accept MetricKit data and relay it to a remote endpoint, per its configuration. 18 | /// 19 | /// - Important: You must hold a reference to an instance of this class to keep it active. 20 | public actor MeterReporter { 21 | private let wellsReporter: WellsReporter 22 | public let configuration: Configuration 23 | private let subscriber: DiagnosticSubscriber 24 | private let log: OSLog 25 | 26 | public init(configuration: Configuration) { 27 | self.configuration = configuration 28 | self.subscriber = DiagnosticSubscriber() 29 | self.log = OSLog(subsystem: "com.chimehq.MeterReporter", category: "MeterReporter") 30 | self.wellsReporter = WellsReporter(baseURL: configuration.reportsURL, 31 | backgroundIdentifier: configuration.backgroundIdentifier) 32 | 33 | let baseURL = configuration.reportsURL 34 | 35 | Task { [wellsReporter] in 36 | await wellsReporter.setLocationProvider { 37 | baseURL.appendingPathComponent($0).appendingPathExtension("mxdiagnostic") 38 | } 39 | 40 | await wellsReporter.setExistingLogHandler {logUrl, date in 41 | Task { [weak self] in 42 | await self?.handleExistingLog(at: logUrl, date: date) 43 | } 44 | } 45 | } 46 | } 47 | 48 | public init(endpointURL: URL) { 49 | self.init(configuration: Configuration(endpointURL: endpointURL)) 50 | } 51 | 52 | public func start() { 53 | os_log("starting", log: log, type: .debug) 54 | 55 | Task { [log, wellsReporter] in 56 | do { 57 | try await wellsReporter.createReportDirectoryIfNeeded() 58 | } catch { 59 | os_log("failed to create reporting directory %{public}@", log: log, type: .error, String(describing: error)) 60 | return 61 | } 62 | 63 | await configureExceptionLogging() 64 | 65 | subscriber.onReceive = { payloads in 66 | Task { [weak self] in 67 | await self?.receivedPayloads(payloads) 68 | } 69 | } 70 | subscriber.start() 71 | } 72 | } 73 | 74 | private nonisolated var reportDirectoryURL: URL { 75 | return wellsReporter.baseURL 76 | } 77 | } 78 | 79 | extension MeterReporter { 80 | public struct Configuration: Sendable { 81 | public var endpointURL: URL 82 | public var hostIdentifier: String? 83 | 84 | /// The NSURLSession background indentifier 85 | /// 86 | /// This has a default value, but can be customized if needed. Setting the value to `nil` will disable background uploading. 87 | public var backgroundIdentifier: String? = WellsReporter.defaultBackgroundIdentifier 88 | public var reportsURL: URL = WellsReporter.defaultDirectory 89 | public var log: OSLog = OSLog(subsystem: "com.chimehq.MeterReporter", category: "MeterReporter") 90 | public var filterSimulatedPayloads = true 91 | 92 | public init(endpointURL: URL) { 93 | self.endpointURL = endpointURL 94 | } 95 | } 96 | } 97 | 98 | extension MeterReporter { 99 | func receivedPayloads(_ payloads: [Data]) { 100 | os_log("received payloads %{public}d", log: log, type: .info, payloads.count) 101 | 102 | let symbolicator = DlfcnSymbolicator() 103 | let exceptionInfo = existingExceptionInfo() 104 | 105 | removeExistingExceptionInfo() 106 | 107 | for rawData in payloads { 108 | let data: Data 109 | 110 | do { 111 | let payload = try DiagnosticPayload.from(data: rawData) 112 | 113 | if payload.isSimulated && configuration.filterSimulatedPayloads { 114 | os_log("skipping simulated payload", log: log, type: .error) 115 | continue 116 | } 117 | 118 | data = processPayload(payload, with: symbolicator, exceptionInfo: exceptionInfo) 119 | } catch { 120 | data = rawData 121 | os_log("failed to decode payload %{public}@", log: log, type: .error, String(describing: error)) 122 | } 123 | 124 | do { 125 | try submit(data) 126 | } catch { 127 | os_log("failed to submit payload %{public}@", log: log, type: .error, String(describing: error)) 128 | } 129 | } 130 | } 131 | } 132 | 133 | extension MeterReporter { 134 | @MainActor 135 | private func configureExceptionLogging() { 136 | #if os(macOS) 137 | if let app = NSApp as? ExceptionLoggingApplication { 138 | app.exceptionInfoURL = exceptionInfoURL 139 | } 140 | #endif 141 | 142 | UncaughtExceptionLogger.logger.exceptionInfoURL = exceptionInfoURL 143 | } 144 | 145 | private nonisolated var exceptionInfoURL: URL { 146 | return reportDirectoryURL.appendingPathComponent("exception_info.json") 147 | } 148 | 149 | private func existingExceptionInfo() -> ExceptionInfo? { 150 | let url = exceptionInfoURL 151 | 152 | guard FileManager.default.isReadableFile(atPath: url.path) else { 153 | return nil 154 | } 155 | 156 | let info: ExceptionInfo? 157 | 158 | do { 159 | let data = try Data(contentsOf: url) 160 | info = try JSONDecoder().decode(ExceptionInfo.self, from: data) 161 | } catch { 162 | os_log("failed to decode exception_info.json %{public}@", log: log, type: .error, String(describing: error)) 163 | info = nil 164 | } 165 | 166 | return info 167 | } 168 | 169 | private func removeExistingExceptionInfo() { 170 | let url = exceptionInfoURL 171 | 172 | if FileManager.default.fileExists(atPath: url.path) == false { 173 | return 174 | } 175 | 176 | removeItem(at: url) 177 | } 178 | 179 | func processPayload(_ payload: DiagnosticPayload, with symbolicator: Symbolicator, exceptionInfo: ExceptionInfo?) -> Data { 180 | let symPayload = symbolicator.symbolicate(payload: payload) 181 | let lastCrash = symPayload.crashDiagnostics?.last 182 | 183 | if let lastCrash = lastCrash, let info = exceptionInfo { 184 | if info.matchesCrashDiagnostic(lastCrash) { 185 | lastCrash.exceptionInfo = info 186 | } 187 | } 188 | 189 | return symPayload.jsonRepresentation() 190 | } 191 | 192 | func submit(_ data: Data) throws { 193 | let id = UUID().lowerAlphaOnly 194 | let url = reportDirectoryURL.appendingPathComponent(id).appendingPathExtension("mxdiagnostic") 195 | 196 | try data.write(to: url) 197 | 198 | submit(url, identifier: id) 199 | } 200 | 201 | func submit(_ url: URL, identifier: String? = nil) { 202 | let id = identifier ?? url.deletingPathExtension().lastPathComponent 203 | 204 | os_log("submitting %{public}@", log: log, type: .info, url.path) 205 | 206 | let request = makeURLRequest(for: id) 207 | 208 | Task { [wellsReporter] in 209 | await wellsReporter.submit(fileURL: url, identifier: id, uploadRequest: request) 210 | } 211 | } 212 | 213 | func removeItem(at url: URL) { 214 | do { 215 | try FileManager.default.removeItem(at: url) 216 | } catch { 217 | os_log("failed to remove item at %{public}@ %{public}@", log: log, type: .error, url.path, String(describing: error)) 218 | } 219 | } 220 | 221 | func handleExistingLog(at url: URL, date: Date) { 222 | if url == exceptionInfoURL { 223 | os_log("removing existing exception_info.json", log: log, type: .info) 224 | removeItem(at: url) 225 | return 226 | } 227 | 228 | // ~ 7 days 229 | let oldDate = Date().addingTimeInterval(-7.0 * 24.0 * 60.0 * 60.0) 230 | 231 | if date < oldDate { 232 | os_log("removing old log %{public}@", log: log, type: .info, url.path) 233 | removeItem(at: url) 234 | return 235 | } 236 | 237 | os_log("resubmitting %{public}@", log: log, type: .info, url.path) 238 | 239 | submit(url) 240 | } 241 | } 242 | 243 | extension MeterReporter { 244 | private var platformName: String { 245 | #if os(macOS) 246 | return "macOS" 247 | #elseif os(iOS) 248 | return "iOS" 249 | #elseif os(tvOS) 250 | return "tvOS" 251 | #elseif os(watchOS) 252 | return "watchOS" 253 | #else 254 | return "unknown" 255 | #endif 256 | } 257 | 258 | private func makeURLRequest(for reportID: String) -> URLRequest { 259 | let url = configuration.endpointURL 260 | 261 | var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 10.0) 262 | 263 | request.httpMethod = "PUT" 264 | 265 | request.addValue(reportID, forHTTPHeaderField: "MeterReporter-Report-Id") 266 | request.addValue(platformName, forHTTPHeaderField: "MeterReporter-Platform") 267 | 268 | if let host = configuration.hostIdentifier { 269 | request.addValue(host, forHTTPHeaderField: "MeterReporter-Host-Id") 270 | } 271 | 272 | request.addValue("application/vnd.chimehq-mxdiagnostic", forHTTPHeaderField: "Content-Type") 273 | 274 | return request 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /Sources/MeterReporter/UncaughtExceptionLogger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Meter 3 | 4 | final class UncaughtExceptionLogger { 5 | public var exceptionInfoURL: URL? 6 | fileprivate let existingHandler: NSUncaughtExceptionHandler? 7 | 8 | nonisolated(unsafe) static let logger = UncaughtExceptionLogger() 9 | 10 | private init() { 11 | self.existingHandler = NSGetUncaughtExceptionHandler() 12 | 13 | NSSetUncaughtExceptionHandler(writeException) 14 | } 15 | 16 | fileprivate func writeExceptionInfo(exception: NSException) { 17 | guard let url = exceptionInfoURL else { 18 | return 19 | } 20 | 21 | let info = ExceptionInfo(exception: exception) 22 | 23 | try? info.write(to: url) 24 | } 25 | } 26 | 27 | private func writeException(_ exception: NSException) { 28 | let logger = UncaughtExceptionLogger.logger 29 | 30 | logger.writeExceptionInfo(exception: exception) 31 | 32 | logger.existingHandler?(exception) 33 | } 34 | --------------------------------------------------------------------------------