├── .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 |
--------------------------------------------------------------------------------