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