├── .github
└── FUNDING.yml
├── .spi.yml
├── .gitignore
├── .editorconfig
├── Sources
└── Wells
│ ├── Protocol+Helpers.swift
│ ├── Data+compression.swift
│ ├── FileManager+Helpers.swift
│ └── WellsReporter.swift
├── Package.swift
├── Tests
└── WellsTests
│ └── HTTPHeaderTests.swift
├── LICENSE
├── CODE_OF_CONDUCT.md
└── README.md
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [mattmassicotte]
2 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: [Wells]
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /Carthage
2 | /Build
3 | .DS_Store
4 | /.build
5 | /Packages
6 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | indent_size = 4
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
--------------------------------------------------------------------------------
/Sources/Wells/Protocol+Helpers.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension URLRequest {
4 | var attemptCount: Int {
5 | get {
6 | return allHTTPHeaderFields?["Wells-Attempt"]
7 | .flatMap({ Int($0) }) ?? 0
8 | }
9 | mutating set {
10 | setValue(String(newValue), forHTTPHeaderField: "Wells-Attempt")
11 | }
12 | }
13 |
14 | var uploadIdentifier: String? {
15 | get {
16 | return allHTTPHeaderFields?["Wells-Upload-Identifier"]
17 | }
18 | mutating set {
19 | setValue(newValue, forHTTPHeaderField: "Wells-Upload-Identifier")
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/Wells/Data+compression.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if canImport(zlib)
3 | import zlib
4 |
5 | public extension Data {
6 | func writeGZCompressedData(to url: URL) throws {
7 | let file = url.path.withCString { (pathPtr) in
8 | "w".withCString { (modePtr) -> gzFile in
9 | return gzopen(pathPtr, modePtr)
10 | }
11 | }
12 |
13 | let success = withUnsafeBytes { (buffer) -> Bool in
14 | let size = buffer.count
15 | let result = gzwrite(file, buffer.baseAddress, UInt32(size))
16 |
17 | return result == size
18 | }
19 |
20 | gzclose(file)
21 |
22 | if !success {
23 | throw NSError(domain: "libz compression failure", code: 0, userInfo: nil)
24 | }
25 | }
26 | }
27 | #endif
28 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.8
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "Wells",
7 | platforms: [
8 | .macOS(.v11),
9 | .iOS(.v14),
10 | .tvOS(.v14),
11 | .watchOS(.v7),
12 | .macCatalyst(.v14),
13 | ],
14 | products: [
15 | .library(name: "Wells", targets: ["Wells"]),
16 | ],
17 | dependencies: [
18 | .package(url: "https://github.com/ChimeHQ/Background", revision: "11f1bc95a7ec88c275b522b12883ada9dbc062e6")
19 | ],
20 | targets: [
21 | .target(name: "Wells", dependencies: ["Background"]),
22 | .testTarget(name: "WellsTests", dependencies: ["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 |
--------------------------------------------------------------------------------
/Tests/WellsTests/HTTPHeaderTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import Wells
3 |
4 | final class HTTPHeaderTests: XCTestCase {
5 | func testAttemptCount() throws {
6 | var request = URLRequest(url: URL(string: "http://example.com")!)
7 |
8 | XCTAssertEqual(request.attemptCount, 0)
9 |
10 | request.addValue("5", forHTTPHeaderField: "Wells-Attempt")
11 |
12 | XCTAssertEqual(request.attemptCount, 5)
13 |
14 | request.attemptCount = 2
15 |
16 | XCTAssertEqual(request.attemptCount, 2)
17 | XCTAssertEqual(request.value(forHTTPHeaderField: "Wells-Attempt"), "2")
18 | }
19 |
20 | func testBackgroundIdentifier() throws {
21 | var request = URLRequest(url: URL(string: "http://example.com")!)
22 |
23 | XCTAssertEqual(request.uploadIdentifier, nil)
24 |
25 | request.addValue("abc", forHTTPHeaderField: "Wells-Upload-Identifier")
26 |
27 | XCTAssertEqual(request.uploadIdentifier, "abc")
28 |
29 | request.uploadIdentifier = "def"
30 |
31 | XCTAssertEqual(request.uploadIdentifier, "def")
32 | XCTAssertEqual(request.value(forHTTPHeaderField: "Wells-Upload-Identifier"), "def")
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Sources/Wells/FileManager+Helpers.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FileManager+ScopedURLs.swift
3 | // Wells
4 | //
5 | // Created by Matt Massicotte on 2020-10-09.
6 | //
7 |
8 | import Foundation
9 |
10 | extension FileManager {
11 | func subdirectoryURL(named name: String, in dir: FileManager.SearchPathDirectory) -> URL? {
12 | guard let url = FileManager.default.urls(for: dir, in: .userDomainMask).first else {
13 | return nil
14 | }
15 |
16 | let scopedURL = url.appendingPathComponent(name)
17 |
18 | try? FileManager.default.createDirectory(at: scopedURL, withIntermediateDirectories: true, attributes: nil)
19 |
20 | return scopedURL
21 | }
22 |
23 | func bundleIdSubdirectoryURL(for dir: FileManager.SearchPathDirectory) -> URL? {
24 | guard let bundleId = Bundle.main.bundleIdentifier else {
25 | return nil
26 | }
27 |
28 | return subdirectoryURL(named: bundleId, in: dir)
29 | }
30 | }
31 |
32 | extension FileManager {
33 | func directoryExists(at url: URL) -> Bool {
34 | var isDir: ObjCBool = false
35 |
36 | guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) else {
37 | return false
38 | }
39 |
40 | return isDir.boolValue
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2020, 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 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 |
2 | # Contributor Covenant Code of Conduct
3 |
4 | ## Our Pledge
5 |
6 | We as members, contributors, and leaders pledge to make participation in our
7 | community a harassment-free experience for everyone, regardless of age, body
8 | size, visible or invisible disability, ethnicity, sex characteristics, gender
9 | identity and expression, level of experience, education, socio-economic status,
10 | nationality, personal appearance, race, caste, color, religion, or sexual
11 | identity and orientation.
12 |
13 | We pledge to act and interact in ways that contribute to an open, welcoming,
14 | diverse, inclusive, and healthy community.
15 |
16 | ## Our Standards
17 |
18 | Examples of behavior that contributes to a positive environment for our
19 | community include:
20 |
21 | * Demonstrating empathy and kindness toward other people
22 | * Being respectful of differing opinions, viewpoints, and experiences
23 | * Giving and gracefully accepting constructive feedback
24 | * Accepting responsibility and apologizing to those affected by our mistakes,
25 | and learning from the experience
26 | * Focusing on what is best not just for us as individuals, but for the overall
27 | community
28 |
29 | Examples of unacceptable behavior include:
30 |
31 | * The use of sexualized language or imagery, and sexual attention or advances of
32 | any kind
33 | * Trolling, insulting or derogatory comments, and personal or political attacks
34 | * Public or private harassment
35 | * Publishing others' private information, such as a physical or email address,
36 | without their explicit permission
37 | * Other conduct which could reasonably be considered inappropriate in a
38 | professional setting
39 |
40 | ## Enforcement Responsibilities
41 |
42 | Community leaders are responsible for clarifying and enforcing our standards of
43 | acceptable behavior and will take appropriate and fair corrective action in
44 | response to any behavior that they deem inappropriate, threatening, offensive,
45 | or harmful.
46 |
47 | Community leaders have the right and responsibility to remove, edit, or reject
48 | comments, commits, code, wiki edits, issues, and other contributions that are
49 | not aligned to this Code of Conduct, and will communicate reasons for moderation
50 | decisions when appropriate.
51 |
52 | ## Scope
53 |
54 | This Code of Conduct applies within all community spaces, and also applies when
55 | an individual is officially representing the community in public spaces.
56 | Examples of representing our community include using an official e-mail address,
57 | posting via an official social media account, or acting as an appointed
58 | representative at an online or offline event.
59 |
60 | ## Enforcement
61 |
62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
63 | reported to the community leaders responsible for enforcement at
64 | support@chimehq.com.
65 | All complaints will be reviewed and investigated promptly and fairly.
66 |
67 | All community leaders are obligated to respect the privacy and security of the
68 | reporter of any incident.
69 |
70 | ## Enforcement Guidelines
71 |
72 | Community leaders will follow these Community Impact Guidelines in determining
73 | the consequences for any action they deem in violation of this Code of Conduct:
74 |
75 | ### 1. Correction
76 |
77 | **Community Impact**: Use of inappropriate language or other behavior deemed
78 | unprofessional or unwelcome in the community.
79 |
80 | **Consequence**: A private, written warning from community leaders, providing
81 | clarity around the nature of the violation and an explanation of why the
82 | behavior was inappropriate. A public apology may be requested.
83 |
84 | ### 2. Warning
85 |
86 | **Community Impact**: A violation through a single incident or series of
87 | actions.
88 |
89 | **Consequence**: A warning with consequences for continued behavior. No
90 | interaction with the people involved, including unsolicited interaction with
91 | those enforcing the Code of Conduct, for a specified period of time. This
92 | includes avoiding interactions in community spaces as well as external channels
93 | like social media. Violating these terms may lead to a temporary or permanent
94 | ban.
95 |
96 | ### 3. Temporary Ban
97 |
98 | **Community Impact**: A serious violation of community standards, including
99 | sustained inappropriate behavior.
100 |
101 | **Consequence**: A temporary ban from any sort of interaction or public
102 | communication with the community for a specified period of time. No public or
103 | private interaction with the people involved, including unsolicited interaction
104 | with those enforcing the Code of Conduct, is allowed during this period.
105 | Violating these terms may lead to a permanent ban.
106 |
107 | ### 4. Permanent Ban
108 |
109 | **Community Impact**: Demonstrating a pattern of violation of community
110 | standards, including sustained inappropriate behavior, harassment of an
111 | individual, or aggression toward or disparagement of classes of individuals.
112 |
113 | **Consequence**: A permanent ban from any sort of public interaction within the
114 | community.
115 |
116 | ## Attribution
117 |
118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
119 | version 2.1, available at
120 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
121 |
122 | Community Impact Guidelines were inspired by
123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
124 |
125 | For answers to common questions about this code of conduct, see the FAQ at
126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
127 | [https://www.contributor-covenant.org/translations][translations].
128 |
129 | [homepage]: https://www.contributor-covenant.org
130 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
131 | [Mozilla CoC]: https://github.com/mozilla/diversity
132 | [FAQ]: https://www.contributor-covenant.org/faq
133 | [translations]: https://www.contributor-covenant.org/translations
134 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [![Platforms][platforms badge]][platforms]
4 | [![Discord][discord badge]][discord]
5 |
6 |
7 |
8 | # Wells
9 | A lightweight diagnostics report submission system.
10 |
11 | ## Integration
12 |
13 | ```swift
14 | dependencies: [
15 | .package(url: "https://github.com/ChimeHQ/Wells")
16 | ]
17 | ```
18 |
19 | ## Getting Started
20 |
21 | Wells is just a submission system, and tries not to make any assumptions about the source or contents of the reports it transmits. It contains two main components: `WellsReporter` and `WellsUploader`. By default, these work together. But, `WellsUploader` can be used separately if you need more control over the process.
22 |
23 | Because of its flexibility, Wells requires you to do a little more work to wire it up to your source of diagnostic data. Here's what an simple setup could look like. Keep in mind that Wells uploads data using `NSURLSession` background uploads. This means that the start and end of an upload may not occur during the same application launch.
24 |
25 | If you use `WellsReporter` to submit data, it will manage the cross-launch details itself. But, if you need more control, or want to manage the on-disk files yourself, you'll need to provide it with a `ReportLocationProvider` that can map identifiers back to file URLs.
26 |
27 | ```swift
28 | import Foundation
29 | import Wells
30 |
31 | class MyDiagnosticReporter {
32 | private let reporter: WellsReporter
33 |
34 | init() {
35 | self.reporter = WellsReporter()
36 |
37 | reporter.existingLogHandler = { url, date in
38 | // might want to examine date to see how old
39 | // the date is (and handle errors more gracefully)
40 | try? submit(url: url)
41 | }
42 | }
43 |
44 | func start() throws {
45 | // submit files, including an identifier unique to each file
46 | let logURLs = getExistingLogs()
47 |
48 | for url in logURLs {
49 | try submit(url: url)
50 | }
51 |
52 | // or, just submit bytes
53 | let dataList = getExistingData()
54 |
55 | for data in dataList {
56 | let request = makeURLRequest()
57 | reporter.submit(data, uploadRequest: request)
58 | }
59 |
60 | }
61 |
62 | func submit(url: URL) throws {
63 | let logIdentifier = computeUniqueIdentifier(for: url)
64 | let request = makeURLRequest()
65 |
66 | try reporter.submit(fileURL: url, identifier: logIdentifier, uploadRequest: request)
67 | }
68 |
69 | func computeUniqueIdentifier(for url: URL) -> String {
70 | // this works, but a more robust solution would be based on the content of the data. Note that
71 | // the url itself *may not* be consistent from launch to launch.
72 | return UUID().uuidString
73 | }
74 |
75 | // Finding logs/data is up to you
76 | func getExistingLogs() -> [URL] {
77 | return []
78 | }
79 |
80 | func getExistingData() -> [Data] {
81 | return []
82 | }
83 |
84 | func makeURLRequest() -> URLRequest {
85 | // You have control over the URLRequest that Wells uses. However,
86 | // some additional metadata will be added to enablee cross-launch tracking.
87 | let endpoint = URL(string: "https://mydiagnosticservice.com")!
88 |
89 | var request = URLRequest(url: endpoint)
90 |
91 | request.httpMethod = "PUT"
92 | request.addValue("hiya", forHTTPHeaderField: "custom-header")
93 |
94 | return request
95 | }
96 | }
97 | ```
98 |
99 | ## Retries
100 |
101 | Because that Wells manages submissions *across* app launches, retry logic can be complex. Wells will do its best to retry unsuccesful submissions. It respects the `Retry-After` HTTP header and has backoff. But, it is possible that the hosting app is terminated while a backoff delay is pending. In this situation, `WellsReporter` relies on its `existingLogHandler` property to avoid needing persistent storage.
102 |
103 | By default, if there are files found within the `baseURL` directory that are older than 2 days, Wells will give up and delete them.
104 |
105 | Bottom line: Wells submissions are best effort. Robust retry support means you have to make use of `existingLogHandler`. There are pathological, if improbable situations that could prevent the submission and retry system from working in a predictable way.
106 |
107 | ## Using With MetricKit
108 |
109 | Wells works great for submitting data gathered from MetricKit. In fact, [MeterReporter](https://github.com/ChimeHQ/MeterReporter) uses it for a full MetricKit-based reporting system.
110 |
111 | But, you can also do it yourself. Here's a simple example.
112 |
113 | ```swift
114 | import Foundation
115 | import MetricKit
116 | import Wells
117 |
118 | class MetricKitOnlyReporter: NSObject {
119 | private let reporter: WellsReporter
120 | private let endpoint = URL(string: "https://mydiagnosticservice.com")!
121 |
122 | override init() {
123 | self.reporter = WellsReporter()
124 |
125 | super.init()
126 |
127 | MXMetricManager.shared.add(self)
128 | }
129 |
130 | private func submitData(_ data: Data) {
131 | var request = URLRequest(url: endpoint)
132 |
133 | request.httpMethod = "PUT"
134 |
135 | // ok, yes, I have glossed over error handling
136 | try? reporter.submit(data, uploadRequest: request)
137 | }
138 | }
139 |
140 | extension MetricKitOnlyReporter: MXMetricManagerSubscriber {
141 | func didReceive(_ payloads: [MXMetricPayload]) {
142 | }
143 |
144 | func didReceive(_ payloads: [MXDiagnosticPayload]) {
145 | payloads.map({ $0.jsonRepresentation() }).forEach({ submitData($0) })
146 | }
147 | }
148 | ```
149 |
150 | ## Namesake
151 |
152 | Wells is all about reporting, so it seemed logical to name it after a [notable journalist](https://en.wikipedia.org/wiki/Ida_B._Wells).
153 |
154 | ## Suggestions or Feedback
155 |
156 | I would love to hear from you! Issues or pull requests work great. A [Discord server][discord] is also available for live help, but I have a strong bias towards answering in the form of documentation.
157 |
158 | I prefer collaboration, and would love to find ways to work together if you have a similar project.
159 |
160 | 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.
161 |
162 | By participating in this project you agree to abide by the [Contributor Code of Conduct](CODE_OF_CONDUCT.md).
163 |
164 | [platforms]: https://swiftpackageindex.com/ChimeHQ/Wells
165 | [platforms badge]: https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FChimeHQ%2FWells%2Fbadge%3Ftype%3Dplatforms
166 | [discord]: https://discord.gg/esFpX6sErJ
167 | [discord badge]: https://img.shields.io/badge/Discord-purple?logo=Discord&label=Chat&color=%235A64EC
168 |
--------------------------------------------------------------------------------
/Sources/Wells/WellsReporter.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import os.log
3 |
4 | import Background
5 |
6 | public enum ReporterError: Error {
7 | case failedToCreateURL
8 | }
9 |
10 | public actor WellsReporter {
11 | public typealias ReportLocationProvider = @Sendable (String) -> URL?
12 | public typealias ExistingLogHandler = @Sendable (URL, Date) -> Void
13 |
14 | public static let shared = WellsReporter()
15 | public static let uploadFileExtension = "wellsdata"
16 | private static let maximumAccountCount = 5
17 | private static let maximumLogAge: TimeInterval = 2.0 * 24.0 * 60.0 * 60.0
18 | public static let defaultRetryInterval = 5 * 60.0
19 |
20 | public private(set) var existingLogHandler: ExistingLogHandler = { _, _ in }
21 |
22 | public nonisolated let baseURL: URL
23 | private let logger = OSLog(subsystem: "com.chimehq.Wells", category: "Reporter")
24 | private let uploader: Uploader
25 | private let backgroundIdentifier: String?
26 | public private(set) var locationProvider: ReportLocationProvider
27 |
28 | public static nonisolated let defaultBackgroundIdentifier: String = {
29 | let bundleId = Bundle.main.bundleIdentifier ?? "com.chimehq.Wells"
30 |
31 | return bundleId + ".Uploader"
32 | }()
33 |
34 | public init(
35 | baseURL: URL = defaultDirectory,
36 | backgroundIdentifier: String? = WellsReporter.defaultBackgroundIdentifier,
37 | locationProvider: @escaping ReportLocationProvider
38 | ) {
39 | self.baseURL = baseURL
40 | self.backgroundIdentifier = backgroundIdentifier
41 | self.locationProvider = {
42 | baseURL.appendingPathComponent($0).appendingPathExtension(WellsReporter.uploadFileExtension)
43 | }
44 |
45 | let config: URLSessionConfiguration
46 |
47 | if let identifier = backgroundIdentifier {
48 | config = URLSessionConfiguration.background(withIdentifier: identifier)
49 | } else {
50 | config = URLSessionConfiguration.default
51 | }
52 |
53 | self.uploader = Uploader(
54 | sessionConfiguration: config,
55 | identifierProvider: { $0.originalRequest?.uploadIdentifier }
56 | )
57 |
58 | Task {
59 | try? await Task.sleep(nanoseconds: 10 * 1_000_000_000)
60 |
61 | await handleExistingLogs()
62 | }
63 | }
64 |
65 | public init(
66 | baseURL: URL = defaultDirectory,
67 | backgroundIdentifier: String? = WellsReporter.defaultBackgroundIdentifier
68 | ) {
69 | let provider: ReportLocationProvider = {
70 | baseURL.appendingPathComponent($0).appendingPathExtension(WellsReporter.uploadFileExtension)
71 | }
72 |
73 | self.init(baseURL: baseURL, backgroundIdentifier: backgroundIdentifier, locationProvider: provider)
74 | }
75 |
76 | public var usingBackgroundUploads: Bool {
77 | return backgroundIdentifier != nil
78 | }
79 |
80 | private func defaultURL(for identifier: String) -> URL {
81 | return baseURL.appendingPathComponent(identifier).appendingPathExtension(WellsReporter.uploadFileExtension)
82 | }
83 |
84 | public func setLocationProvider(_ value: @escaping ReportLocationProvider) {
85 | self.locationProvider = value
86 | }
87 |
88 | public func setExistingLogHandler(_ value: @escaping ExistingLogHandler) {
89 | self.existingLogHandler = value
90 | }
91 |
92 | private func reportURL(for identifier: String) -> URL? {
93 | locationProvider(identifier)
94 | }
95 |
96 | public func submit(_ data: Data, uploadRequest: URLRequest) throws {
97 | let identifier = UUID().uuidString
98 |
99 | guard let fileURL = reportURL(for: identifier) else {
100 | throw ReporterError.failedToCreateURL
101 | }
102 |
103 | try createReportDirectoryIfNeeded()
104 |
105 | try data.write(to: fileURL)
106 |
107 | submit(fileURL: fileURL, identifier: identifier, uploadRequest: uploadRequest)
108 | }
109 |
110 | public func submit(fileURL: URL, identifier: String, uploadRequest: URLRequest) {
111 | var request = uploadRequest
112 |
113 | // embed our header for background tracking
114 | request.uploadIdentifier = identifier
115 |
116 | Task {
117 | await uploader.beginUpload(of: fileURL, with: request, identifier: identifier, handler: { _, result in
118 | Task {
119 | await self.handleUploadComplete(result, for: identifier, request: uploadRequest)
120 | }
121 | })
122 | }
123 | }
124 |
125 | private func handleUploadComplete(_ result: Result, for identifier: String, request: URLRequest) {
126 | let networkResponse = NetworkResponse(with: result)
127 |
128 | switch networkResponse {
129 | case .rejected:
130 | os_log("Server rejected report submission: %{public}@", log: self.logger, type: .error, identifier)
131 |
132 | removeFile(with: identifier)
133 | case let .failed(error):
134 | os_log("Failed to submit report: %{public}@ - %{public}@", log: self.logger, type: .error, identifier, String(describing: error))
135 |
136 | removeFile(with: identifier)
137 | case let .retry(response):
138 | let interval = response.retryAfterInterval ?? Self.defaultRetryInterval
139 |
140 | retrySubmission(of: identifier, after: interval, with: request)
141 | case .success:
142 | os_log("Submitted report successfully: %{public}@", log: self.logger, type: .error, identifier)
143 |
144 | removeFile(with: identifier)
145 | }
146 | }
147 |
148 | public func createReportDirectoryIfNeeded() throws {
149 | if FileManager.default.directoryExists(at: baseURL) {
150 | return
151 | }
152 |
153 | try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
154 | }
155 |
156 | func handleExistingLogs() {
157 | let urls = try? FileManager.default.contentsOfDirectory(
158 | at: baseURL,
159 | includingPropertiesForKeys: [.creationDateKey]
160 | )
161 |
162 | guard let urls = urls else {
163 | return
164 | }
165 |
166 | for url in urls {
167 | let values = try? url.resourceValues(forKeys: [.creationDateKey])
168 | let date = values?.creationDate ?? Date.distantPast
169 |
170 | self.existingLogHandler(url, date)
171 | }
172 | }
173 |
174 | private func removeFile(at url: URL) {
175 | do {
176 | try FileManager.default.removeItem(at: url)
177 | } catch {
178 | os_log("failed to remove log at %{public}@ %{public}@", log: self.logger, type: .error, url.path, String(describing: error))
179 | }
180 | }
181 |
182 | private func removeFile(with identifier: String) {
183 | guard let fileURL = reportURL(for: identifier) else {
184 | os_log("Failed to compute URL for %{public}@", log: self.logger, type: .error, identifier)
185 |
186 | return
187 | }
188 |
189 | removeFile(at: fileURL)
190 | }
191 |
192 | private func retrySubmission(of identifier: String, after interval: TimeInterval, with request: URLRequest) {
193 | guard let fileURL = reportURL(for: identifier) else {
194 | os_log("Failed to compute URL for %{public}@", log: logger, type: .error, identifier)
195 |
196 | return
197 | }
198 |
199 | let count = request.attemptCount
200 |
201 | if count >= WellsReporter.maximumAccountCount {
202 | os_log("Exceeded maximum retry count for %{public}@", log: logger, type: .error, identifier)
203 |
204 | removeFile(at: fileURL)
205 |
206 | return
207 | }
208 |
209 | let delay = Int(max(interval, 60.0))
210 |
211 | os_log("Retrying submission after %{public}d %{public}@", log: logger, type: .info, delay, identifier)
212 |
213 | var newRequest = request
214 |
215 | newRequest.attemptCount = count + 1
216 |
217 | Task {
218 | try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
219 |
220 | self.submit(fileURL: fileURL, identifier: identifier, uploadRequest: newRequest)
221 | }
222 | }
223 |
224 | private func handleExistingLog(at url: URL, date: Date) {
225 | let oldDate = Date().addingTimeInterval(-WellsReporter.maximumLogAge)
226 |
227 | guard date < oldDate else { return }
228 |
229 | os_log("removing old log %{public}@", log: logger, type: .info, url.path)
230 | removeFile(at: url)
231 | }
232 | }
233 |
234 | extension WellsReporter {
235 | private static var fallbackDirectory: URL {
236 | return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
237 | }
238 |
239 | private static var bundleScopedCachesDirectory: URL? {
240 | return FileManager.default.bundleIdSubdirectoryURL(for: .cachesDirectory)
241 | }
242 |
243 | public static var defaultDirectory: URL {
244 | let baseURL = bundleScopedCachesDirectory ?? fallbackDirectory
245 |
246 | return baseURL.appendingPathComponent("com.chimehq.Wells")
247 | }
248 | }
249 |
--------------------------------------------------------------------------------