├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Package.swift ├── Tests └── GoogleCloudLoggingTests │ └── GoogleCloudLoggingTests.swift ├── README.md ├── LICENSE.txt └── Sources └── GoogleCloudLogging ├── GoogleCloudLogging.swift └── GoogleCloudLogHandler.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "GoogleCloudLogging", 7 | platforms: [ 8 | .iOS(.v11), 9 | .macOS(.v10_13), 10 | .tvOS(.v11), 11 | .watchOS(.v4), 12 | ], 13 | products: [ 14 | .library( 15 | name: "GoogleCloudLogging", 16 | targets: ["GoogleCloudLogging"]), 17 | ], 18 | dependencies: [ 19 | .package(url: "https://github.com/apple/swift-log.git", from: "1.2.0"), 20 | ], 21 | targets: [ 22 | .target( 23 | name: "GoogleCloudLogging", 24 | dependencies: [ 25 | .product(name: "Logging", package: "swift-log"), 26 | ]), 27 | .testTarget( 28 | name: "GoogleCloudLoggingTests", 29 | dependencies: ["GoogleCloudLogging"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /Tests/GoogleCloudLoggingTests/GoogleCloudLoggingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import GoogleCloudLogging 3 | import Logging 4 | 5 | 6 | final class GoogleCloudLoggingTests: XCTestCase { 7 | 8 | static let url = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("swiftlog-ab02c56147dc.json") 9 | 10 | 11 | override class func setUp() { 12 | 13 | try! GoogleCloudLogHandler.setup(serviceAccountCredentials: url, clientId: UUID()) 14 | LoggingSystem.bootstrap(GoogleCloudLogHandler.init) 15 | } 16 | 17 | 18 | func testTokenRequest() { 19 | 20 | let gcl = try! GoogleCloudLogging(serviceAccountCredentials: Self.url) 21 | let dg = DispatchGroup() 22 | dg.enter() 23 | gcl.requestToken { result in 24 | if case .failure = result { XCTFail() } 25 | print(result) 26 | dg.leave() 27 | } 28 | dg.wait() 29 | } 30 | 31 | 32 | func testEntriesWrite() { 33 | 34 | let gcl = try! GoogleCloudLogging(serviceAccountCredentials: Self.url) 35 | let dg = DispatchGroup() 36 | dg.enter() 37 | let e1 = GoogleCloudLogging.Log.Entry(logName: "", timestamp: nil, severity: nil, insertId: nil, labels: nil, sourceLocation: nil, textPayload: "Message 1") 38 | let e2 = GoogleCloudLogging.Log.Entry(logName: " Test-2\n.", timestamp: Date(), severity: .default, insertId: nil, labels: [:], sourceLocation: nil, textPayload: " Message\n2 👌") 39 | let e3 = GoogleCloudLogging.Log.Entry(logName: "/Test_3", timestamp: Date() - 10, severity: .emergency, insertId: "ttt", labels: ["a": "A", "b": "B"], sourceLocation: .init(file: #file, line: String(#line), function: #function), textPayload: "Message 3") 40 | gcl.write(entries: [e1, e2, e3]) { result in 41 | if case .failure = result { XCTFail() } 42 | print(result) 43 | dg.leave() 44 | } 45 | dg.wait() 46 | } 47 | 48 | 49 | func testLogHandler() { 50 | 51 | var logger1 = Logger(label: "first logger") 52 | logger1.logLevel = .debug 53 | logger1[metadataKey: "only-on"] = "first" 54 | 55 | var logger2 = logger1 56 | logger2.logLevel = .error // this must not override `logger1`'s log level 57 | logger2[metadataKey: "only-on"] = "second" // this must not override `logger1`'s metadata 58 | 59 | XCTAssertEqual(.debug, logger1.logLevel) 60 | XCTAssertEqual(.error, logger2.logLevel) 61 | XCTAssertEqual("first", logger1[metadataKey: "only-on"]) 62 | XCTAssertEqual("second", logger2[metadataKey: "only-on"]) 63 | } 64 | 65 | 66 | func testDictionaryUpdate() { 67 | 68 | var dictionary = ["a": 1, "b": 2] 69 | 70 | XCTAssertEqual(dictionary.update(with: [:]), [:]) 71 | XCTAssertEqual(dictionary, ["a": 1, "b": 2]) 72 | 73 | XCTAssertEqual(dictionary.update(with: ["c": 3, "b": 2]), ["b": 2]) 74 | XCTAssertEqual(dictionary, ["a": 1, "b": 2, "c": 3]) 75 | 76 | XCTAssertEqual(dictionary.update(with: ["a": 0]), ["a": 1]) 77 | XCTAssertEqual(dictionary, ["a": 0, "b": 2, "c": 3]) 78 | 79 | XCTAssertEqual(dictionary.update(with: ["d": 1, "e": 0]), [:]) 80 | XCTAssertEqual(dictionary, ["a": 0, "b": 2, "c": 3, "d": 1, "e": 0]) 81 | } 82 | 83 | 84 | func testISO8601DateFormatterNanoseconds() { 85 | 86 | XCTAssertEqual(ISO8601DateFormatter.internetDateTimeWithNanosecondsString(from: Date(timeIntervalSinceReferenceDate: 615695580)), "2020-07-06T02:33:00.0Z") 87 | XCTAssertEqual(ISO8601DateFormatter.internetDateTimeWithNanosecondsString(from: Date(timeIntervalSinceReferenceDate: 615695580.235942)), "2020-07-06T02:33:00.235942Z") 88 | XCTAssertEqual(ISO8601DateFormatter.internetDateTimeWithNanosecondsString(from: Date(timeIntervalSinceReferenceDate: 615695580.987654321)), "2020-07-06T02:33:00.9876543Z") 89 | XCTAssertEqual(ISO8601DateFormatter.internetDateTimeWithNanosecondsString(from: Date(timeIntervalSinceReferenceDate: 0.987654321)), "2001-01-01T00:00:00.987654321Z") 90 | XCTAssertEqual(ISO8601DateFormatter.internetDateTimeWithNanosecondsString(from: Date(timeIntervalSinceReferenceDate: -0.9876543211)), "2000-12-31T23:59:59.012345678Z") 91 | } 92 | 93 | 94 | func testSafeLogId() { 95 | 96 | XCTAssertEqual("My_class-1.swift".safeLogId(), "My_class-1.swift") 97 | XCTAssertEqual(" Mÿ@Cláss!✌️/ ".safeLogId(), "_MyClass_") 98 | XCTAssertEqual("Мой еёжз класс".safeLogId(), "Moj_eezz_klass") 99 | XCTAssertEqual("".safeLogId(), "_") 100 | } 101 | 102 | 103 | func testGoogleCloudLogHandler() { 104 | 105 | var logger = Logger(label: "GoogleCloudLoggingTests") 106 | logger[metadataKey: "LoggerMetadataKey"] = "LoggerMetadataValue" 107 | logger.critical("LoggerMessage", metadata: ["MessageMetadataKey": "MessageMetadataValue"]) 108 | Thread.sleep(forTimeInterval: 3) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GoogleCloudLogging 2 | 3 | Event logging for client applications on [Apple platforms](#supported-platforms) with support for offline work and automatic upload to [Google Cloud (GCP)](https://cloud.google.com). The package depends on [SwiftLog](https://github.com/apple/swift-log) - an official logging API for Swift, so it can be easly integrated into the project and combined with other logging backends. Log events are stored locally in the [JSON Lines](http://jsonlines.org) file format and bulk uploaded to GCP using the [Cloud Logging API v2](https://cloud.google.com/logging/docs/reference/v2/rest) at time intervals, upon defined event or explicit request. 4 | 5 | > And yes, it logs itself! (with recursion protection) 🤘 6 | 7 | ## Rationale 8 | Google-recommended logging solution for client applications is the Analytics framework, which is now part of the Firebase SDK. Here is a comparison of their framework and this library in terms of logging: 9 | 10 | Library | FirebaseAnalytics | GoogleCloudLogging 11 | --- | --- | --- 12 | Platform | Mobile only. _Even Catalyst is not currently supported._ | All modern Apple's OSs. _It is essential for development of universal SwiftUI apps._ 13 | Source code | Closed source. _All application and users data is available to Google._ | Open source. _A few hundred lines of pure Swift, no implicit data submission._ 14 | Dependences | Part of the Firebase SDK. _Brings a bunch of Objective-C/C++ code with runtime swizzling etc._ | Only relies on SwiftLog and Apple's embedded frameworks. 15 | Distribution | CocoaPods/Carthage. _SwiftPM is currently cannot be supported due to closed source and dependencies._ | SwiftPM, _which is preferred as integrated with the Swift build system._ 16 | Backend | Google Analytics for Firebase. _Includes some predefined marketing tools._ | GCP Operations (formerly Stackdriver). _Flexible custom log views, metrics, notifications, export etc._ 17 | Integration | Registration of your app in Google is required. | Only need to generate an access key. 18 | Logging | Proprietary logging functions and implicit usage tracking. | SwiftLog logging API. _Single line connection of logging backend._ 19 | 20 | ## Getting Started 21 | ### Add Package Dependency 22 | Open your application project in Xcode 11 or later, go to menu `File -> Swift Packages -> Add Package Dependency...` and paste the package repository URL `https://github.com/DnV1eX/GoogleCloudLogging.git`. 23 | 24 | ### Create Service Account 25 | In your web browser, open the [Google Cloud Console](https://console.cloud.google.com) and create a new project. In `IAM & Admin -> Service Accounts` create a service account choosing `Logging -> Logs Writer` role. In the last step, create and download private key choosing `JSON` format. You need to include this file in your application bundle. 26 | 27 | > Just drag the file into the Xcode project and tick the desired targets in the file inspector. 28 | 29 | ### Setup Logging 30 | 1. Import both `SwiftLog` and `GoogleCloudLogging` modules: 31 | ```swift 32 | import Logging 33 | import GoogleCloudLogging 34 | ``` 35 | 36 | 2. Register the logging backend once after the app launch: 37 | ```swift 38 | LoggingSystem.bootstrap(GoogleCloudLogHandler.init) 39 | ``` 40 | Alternatively, you can register several backends, for example, in order to send logs to both GCP and the Xcode console: 41 | ```swift 42 | LoggingSystem.bootstrap { MultiplexLogHandler([GoogleCloudLogHandler(label: $0), StreamLogHandler.standardOutput(label: $0)]) } 43 | ``` 44 | 45 | 3. Configure GoogleCloudLogHandler: 46 | ```swift 47 | do { 48 | try GoogleCloudLogHandler.setup(serviceAccountCredentials: Bundle.main.url(forResource: /* GCP private key file name */, withExtension: "json")!, clientId: UIDevice.current.identifierForVendor) 49 | } catch { 50 | // Log GoogleCloudLogHandler setup error 51 | } 52 | ``` 53 | If UIKit is not available, you can generate random `clientId` using `UUID()` and store it between the app launches. 54 | 55 | > You can customize GoogleCloudLogHandler's static variables which are all thread safe and documented in the [source code](Sources/GoogleCloudLogging/GoogleCloudLogHandler.swift). 56 | 57 | > It is recommended to explicitly upload logs calling `GoogleCloudLogHandler.upload()` when hiding or exiting the app. 58 | 59 | ## How to Use 60 | ### Emit Logs 61 | 1. Import `SwiftLog` module into the desired file: 62 | ```swift 63 | import Logging 64 | ``` 65 | 66 | 2. Create `logger` which can be a type, instance, or global constant or variable: 67 | ```swift 68 | static let logger = Logger(label: /* Logged class name */) 69 | ``` 70 | > You can customize the minimum emitted log level and set the logger metadata. 71 | 72 | 3. Emit log messages in a certain log level: 73 | ```swift 74 | logger.info(/* Logged info message */) 75 | logger.error(/* Logged error message */, metadata: [LogKey.error: "\(error)"]) 76 | ``` 77 | > It is a good practice to define `typealias LogKey = GoogleCloudLogHandler.MetadataKey` and extend it with your custom keys rather than use string literals. 78 | 79 | > `GoogleCloudLogHandler.globalMetadata` takes precedence over `Logger` metadata which in turn takes precedence over log message metadata in case of key overlapping. 80 | 81 | ### Analyze Logs 82 | In your web browser, open the [GCP Operations Logging](https://console.cloud.google.com/logs) and select your project. You will see a list of logs for a given **time range** which can be filtered by **log name** _(logger label)_, **severity** _(log level)_, **text payload** _(message)_, **labels** _(metadata)_ etc. **Resource type** for logs produced by GoogleCloudLogHandler is always _Global_. 83 | 84 | > You can switch to the new Logs Viewer Preview that introduces new features, such as advanced log queries and histograms. 85 | 86 | > Click on _clientId_ label value of the desired log entry and pick "Show matching entries" in order to view logs from the same app instance only. 87 | 88 | ## Supported Platforms 89 | * iOS 11+ 90 | * iPadOS 13+ 91 | * macOS 10.13+ 92 | * tvOS 11+ 93 | * watchOS 4+ 94 | 95 | ## License 96 | Copyright © 2020 DnV1eX. All rights reserved. 97 | Licensed under the Apache License, Version 2.0. 98 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Sources/GoogleCloudLogging/GoogleCloudLogging.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GoogleCloudLogging.swift 3 | // GoogleCloudLogging 4 | // 5 | // Created by Alexey Demin on 2020-04-27. 6 | // Copyright © 2020 DnV1eX. All rights reserved. 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Foundation 22 | 23 | 24 | class GoogleCloudLogging { 25 | 26 | enum InitError: Error { 27 | case wrongCredentialsType(Credentials) 28 | } 29 | 30 | enum TokenRequestError: Error { 31 | case invalidURL(String) 32 | case noDataReceived(URLResponse?) 33 | case errorReceived(Response.Error) 34 | case wrongTokenType(Token) 35 | } 36 | 37 | enum EntriesWriteError: Error { 38 | case noEntriesToSend 39 | case tokenExpired(Token) 40 | case noDataReceived(URLResponse?) 41 | case errorReceived(Response.Error) 42 | } 43 | 44 | 45 | struct Credentials: Decodable { 46 | enum CodingKeys: String, CodingKey { 47 | case type = "type" 48 | case projectId = "project_id" 49 | case privateKeyId = "private_key_id" 50 | case privateKey = "private_key" 51 | case clientEmail = "client_email" 52 | case clientId = "client_id" 53 | case authURI = "auth_uri" 54 | case tokenURI = "token_uri" 55 | case authProviderX509CertURL = "auth_provider_x509_cert_url" 56 | case clientX509CertURL = "client_x509_cert_url" 57 | } 58 | let type: String 59 | let projectId: String 60 | let privateKeyId: String 61 | let privateKey: String 62 | let clientEmail: String 63 | let clientId: String 64 | let authURI: String 65 | let tokenURI: String 66 | let authProviderX509CertURL: String 67 | let clientX509CertURL: String 68 | } 69 | 70 | 71 | enum Scope: String { 72 | case loggingWrite = "https://www.googleapis.com/auth/logging.write" 73 | } 74 | 75 | 76 | enum JWT { 77 | 78 | enum KeyError: Error { 79 | case unableToDecode(from: String) 80 | } 81 | 82 | struct Header: Encodable { 83 | enum CodingKeys: String, CodingKey { 84 | case type = "typ" 85 | case algorithm = "alg" 86 | } 87 | let type: String 88 | let algorithm: String 89 | } 90 | 91 | struct Payload: Encodable { 92 | enum CodingKeys: String, CodingKey { 93 | case issuer = "iss" 94 | case audience = "aud" 95 | case expiration = "exp" 96 | case issuedAt = "iat" 97 | case scope = "scope" 98 | } 99 | let issuer: String 100 | let audience: String 101 | let expiration: Int 102 | let issuedAt: Int 103 | let scope: String 104 | } 105 | 106 | static func create(using credentials: Credentials, for scopes: [Scope]) throws -> String { 107 | 108 | let header = Header(type: "JWT", algorithm: "RS256") 109 | let now = Date() 110 | let payload = Payload(issuer: credentials.clientEmail, 111 | audience: credentials.tokenURI, 112 | expiration: Int(now.addingTimeInterval(3600).timeIntervalSince1970), 113 | issuedAt: Int(now.timeIntervalSince1970), 114 | scope: scopes.map(\.rawValue).joined(separator: " ")) 115 | let encoder = JSONEncoder() 116 | let encodedHeader = try encoder.encode(header).base64URLEncodedString() 117 | let encodedPayload = try encoder.encode(payload).base64URLEncodedString() 118 | let privateKey = try key(from: credentials.privateKey) 119 | let signature = try sign(Data("\(encodedHeader).\(encodedPayload)".utf8), with: privateKey) 120 | let encodedSignature = signature.base64URLEncodedString() 121 | return "\(encodedHeader).\(encodedPayload).\(encodedSignature)" 122 | } 123 | 124 | static func key(from pem: String) throws -> Data { 125 | let unwrappedPEM = pem.split(separator: "\n").filter { !$0.contains("PRIVATE KEY") }.joined() 126 | let headerLength = 26 127 | guard let der = Data(base64Encoded: unwrappedPEM), der.count > headerLength else { throw KeyError.unableToDecode(from: pem) } 128 | return der[headerLength...] 129 | } 130 | 131 | static func sign(_ data: Data, with key: Data) throws -> Data { 132 | var error: Unmanaged? 133 | let attributes = [kSecAttrKeyType: kSecAttrKeyTypeRSA, kSecAttrKeyClass: kSecAttrKeyClassPrivate, kSecAttrKeySizeInBits: 256] as CFDictionary 134 | guard let privateKey = SecKeyCreateWithData(key as CFData, attributes, &error) else { 135 | throw error!.takeRetainedValue() as Error 136 | } 137 | guard let signature = SecKeyCreateSignature(privateKey, .rsaSignatureMessagePKCS1v15SHA256, data as CFData, &error) as Data? else { 138 | throw error!.takeRetainedValue() as Error 139 | } 140 | return signature 141 | } 142 | } 143 | 144 | 145 | struct Response: Decodable { 146 | struct Error: Decodable { 147 | let code: Int 148 | let message: String 149 | let status: String 150 | } 151 | let error: Error? 152 | } 153 | 154 | 155 | struct Token: Decodable { 156 | enum CodingKeys: String, CodingKey { 157 | case accessToken = "access_token" 158 | case expiresIn = "expires_in" 159 | case tokenType = "token_type" 160 | } 161 | let accessToken: String 162 | let expiresIn: Int 163 | let tokenType: String 164 | 165 | let receiptDate = Date() 166 | var isExpired: Bool { receiptDate + TimeInterval(expiresIn) < Date() } 167 | } 168 | 169 | 170 | struct Log: Encodable { 171 | struct MonitoredResource: Encodable { 172 | let type: String 173 | let labels: [String: String] 174 | 175 | static func global(projectId: String) -> MonitoredResource { MonitoredResource(type: "global", labels: ["project_id": projectId]) } 176 | } 177 | struct Entry: Codable { 178 | enum Severity: String, Codable { 179 | case `default` = "DEFAULT" 180 | case debug = "DEBUG" 181 | case info = "INFO" 182 | case notice = "NOTICE" 183 | case warning = "WARNING" 184 | case error = "ERROR" 185 | case critical = "CRITICAL" 186 | case alert = "ALERT" 187 | case emergency = "EMERGENCY" 188 | } 189 | struct SourceLocation: Codable { 190 | let file: String 191 | let line: String 192 | let function: String 193 | } 194 | var logName: String 195 | let timestamp: Date? 196 | let severity: Severity? 197 | let insertId: String? 198 | let labels: [String: String]? 199 | let sourceLocation: SourceLocation? 200 | let textPayload: String 201 | } 202 | let resource: MonitoredResource 203 | let entries: [Entry] 204 | 205 | static func name(projectId: String, logId: String) -> String { "projects/\(projectId)/logs/\(logId.safeLogId())" } 206 | } 207 | 208 | 209 | let serviceAccountCredentials: Credentials 210 | 211 | private var accessToken: Token? 212 | 213 | let completionHandlerQueue = DispatchQueue(label: "GoogleCloudLogging.CompletionHandler") 214 | let accessTokenQueue = DispatchQueue(label: "GoogleCloudLogging.AccessToken") 215 | 216 | let session: URLSession 217 | 218 | 219 | init(serviceAccountCredentials url: URL) throws { 220 | 221 | let data = try Data(contentsOf: url) 222 | let credentials = try JSONDecoder().decode(Credentials.self, from: data) 223 | guard credentials.type == "service_account" else { throw InitError.wrongCredentialsType(credentials) } 224 | 225 | serviceAccountCredentials = credentials 226 | 227 | let operationQueue = OperationQueue() 228 | operationQueue.underlyingQueue = completionHandlerQueue 229 | session = URLSession(configuration: .ephemeral, delegate: nil, delegateQueue: operationQueue) 230 | } 231 | 232 | 233 | func requestToken(completionHandler: @escaping (Result) -> Void) { 234 | 235 | completionHandlerQueue.async { 236 | guard let url = URL(string: self.serviceAccountCredentials.tokenURI) else { 237 | completionHandler(.failure(TokenRequestError.invalidURL(self.serviceAccountCredentials.tokenURI))) 238 | return 239 | } 240 | var request = URLRequest(url: url) 241 | request.httpMethod = "POST" 242 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 243 | do { 244 | let jwt = try JWT.create(using: self.serviceAccountCredentials, for: [.loggingWrite]) 245 | request.httpBody = try JSONEncoder().encode(["grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": jwt]) 246 | } catch { 247 | completionHandler(.failure(error)) 248 | return 249 | } 250 | 251 | self.session.dataTask(with: request) { data, response, error in 252 | completionHandler(Result { 253 | if let error = error { throw error } 254 | guard let data = data else { throw TokenRequestError.noDataReceived(response) } 255 | if let responseError = try JSONDecoder().decode(Response.self, from: data).error { throw TokenRequestError.errorReceived(responseError) } 256 | let token = try JSONDecoder().decode(Token.self, from: data) 257 | guard token.tokenType == "Bearer" else { throw TokenRequestError.wrongTokenType(token) } 258 | return token 259 | }) 260 | }.resume() 261 | } 262 | } 263 | 264 | 265 | func write(entries: [Log.Entry], completionHandler: @escaping (Result) -> Void) { 266 | 267 | accessTokenQueue.async { 268 | if let token = self.accessToken, !token.isExpired { 269 | self.write(entries: entries, token: token, completionHandler: completionHandler) 270 | } else { 271 | let tokenReference = Referenced() 272 | let tokenRequestSemaphore = DispatchSemaphore(value: 0) 273 | self.requestToken { result in 274 | switch result { 275 | case let .success(token): 276 | tokenReference.wrappedValue = token 277 | tokenRequestSemaphore.signal() 278 | self.write(entries: entries, token: token, completionHandler: completionHandler) 279 | case let .failure(error): 280 | tokenRequestSemaphore.signal() 281 | completionHandler(.failure(error)) 282 | } 283 | } 284 | tokenRequestSemaphore.wait() 285 | if let token = tokenReference.wrappedValue { 286 | self.accessToken = token 287 | } 288 | } 289 | } 290 | } 291 | 292 | 293 | func write(entries: [Log.Entry], token: Token, completionHandler: @escaping (Result) -> Void) { 294 | 295 | completionHandlerQueue.async { 296 | guard !entries.isEmpty else { 297 | completionHandler(.failure(EntriesWriteError.noEntriesToSend)) 298 | return 299 | } 300 | guard !token.isExpired else { 301 | completionHandler(.failure(EntriesWriteError.tokenExpired(token))) 302 | return 303 | } 304 | let url = URL(string: "https://logging.googleapis.com/v2/entries:write")! 305 | var request = URLRequest(url: url) 306 | request.httpMethod = "POST" 307 | request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") 308 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 309 | do { 310 | let encoder = JSONEncoder() 311 | encoder.dateEncodingStrategy = .iso8601WithNanoseconds 312 | let entries: [Log.Entry] = entries.map { 313 | var entry = $0 314 | entry.logName = Log.name(projectId: self.serviceAccountCredentials.projectId, logId: $0.logName) 315 | return entry 316 | } 317 | request.httpBody = try encoder.encode(Log(resource: .global(projectId: self.serviceAccountCredentials.projectId), entries: entries)) 318 | } catch { 319 | completionHandler(.failure(error)) 320 | return 321 | } 322 | 323 | self.session.dataTask(with: request) { data, response, error in 324 | completionHandler(Result { 325 | if let error = error { throw error } 326 | guard let data = data else { throw EntriesWriteError.noDataReceived(response) } 327 | if let responseError = try JSONDecoder().decode(Response.self, from: data).error { throw EntriesWriteError.errorReceived(responseError) } 328 | }) 329 | }.resume() 330 | } 331 | } 332 | } 333 | 334 | 335 | 336 | extension Data { 337 | 338 | func base64URLEncodedString() -> String { 339 | base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") 340 | } 341 | } 342 | 343 | 344 | 345 | extension ISO8601DateFormatter { 346 | 347 | static func internetDateTimeWithNanosecondsString(from date: Date, timeZone: TimeZone = TimeZone(secondsFromGMT: 0)!) -> String { 348 | var string = ISO8601DateFormatter.string(from: date, timeZone: timeZone, formatOptions: .withInternetDateTime) 349 | var timeInterval = date.timeIntervalSinceReferenceDate 350 | if timeInterval < 0 { 351 | timeInterval += (-timeInterval * 2).rounded(.up) 352 | } 353 | string.insert(contentsOf: "\(timeInterval)".drop { $0 != "." }.prefix(10), at: string.index(string.startIndex, offsetBy: 19)) 354 | return string 355 | } 356 | } 357 | 358 | 359 | extension JSONEncoder.DateEncodingStrategy { 360 | 361 | static let iso8601WithNanoseconds = custom { date, encoder in 362 | var container = encoder.singleValueContainer() 363 | try container.encode(ISO8601DateFormatter.internetDateTimeWithNanosecondsString(from: date)) 364 | } 365 | } 366 | 367 | 368 | 369 | extension CharacterSet { 370 | 371 | static let asciiDigits = CharacterSet(charactersIn: "0"..."9") 372 | 373 | static let uppercaseLatinAlphabet = CharacterSet(charactersIn: "A"..."Z") 374 | 375 | static let lowercaseLatinAlphabet = CharacterSet(charactersIn: "a"..."z") 376 | 377 | static let logIdSymbols = CharacterSet(charactersIn: "-._") 378 | .union(.asciiDigits) 379 | .union(.uppercaseLatinAlphabet) 380 | .union(.lowercaseLatinAlphabet) 381 | } 382 | 383 | 384 | extension String { 385 | 386 | func safeLogId() -> String { 387 | let logId = String((applyingTransform(.toLatin, reverse: false) ?? self) 388 | .folding(options: [.diacriticInsensitive, .widthInsensitive], locale: .init(identifier: "en_US")) 389 | .replacingOccurrences(of: " ", with: "_") 390 | .unicodeScalars 391 | .filter(CharacterSet.logIdSymbols.contains) 392 | .prefix(511)) 393 | return logId.isEmpty ? "_" : logId 394 | } 395 | } 396 | 397 | 398 | 399 | @propertyWrapper 400 | class Referenced { 401 | var wrappedValue: T? 402 | } 403 | -------------------------------------------------------------------------------- /Sources/GoogleCloudLogging/GoogleCloudLogHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GoogleCloudLogHandler.swift 3 | // GoogleCloudLogging 4 | // 5 | // Created by Alexey Demin on 2020-05-07. 6 | // Copyright © 2020 DnV1eX. All rights reserved. 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Foundation 22 | import Logging 23 | 24 | /// Customizable SwiftLog logging backend for Google Cloud Logging via REST API v2 with offline functionality. 25 | public struct GoogleCloudLogHandler: LogHandler { 26 | 27 | /// Predefined metadata key strings. 28 | /// 29 | /// - clientId 30 | /// - buildConfiguration 31 | /// - error 32 | /// - description 33 | /// 34 | /// A good practice is to add custom keys in *extension* rather than use string literals. 35 | /// 36 | /// It is also convenient to define *typealias*: 37 | /// 38 | /// typealias LogKey = GoogleCloudLogHandler.MetadataKey 39 | /// 40 | public struct MetadataKey { 41 | public static let clientId = "clientId" 42 | public static let buildConfiguration = "buildConfiguration" 43 | public static let error = "error" 44 | public static let description = "description" 45 | } 46 | 47 | /// Global metadata dictionary. *Atomic*. 48 | /// 49 | /// Used to store `clientId`. 50 | /// 51 | /// Another good use case is storing `buildConfiguration` when logger is used in *Debug* builds (`LogKey` is *typealias* for `GoogleCloudLogHandler.MetadataKey`): 52 | /// 53 | /// #if DEBUG 54 | /// GoogleCloudLogHandler.globalMetadata[LogKey.buildConfiguration] = "Debug" 55 | /// #endif 56 | /// 57 | /// - Remark: Takes precedence over `Logger` and `Log` metadata keys in case of overlapping. 58 | /// 59 | /// - Warning: Do not abuse `globalMetadata` as it is added to each log entry of the app. 60 | /// 61 | @Atomic public static var globalMetadata: Logger.Metadata = [:] 62 | 63 | /// Overridden log level of each `Logger`. *Atomic*. 64 | /// 65 | /// For example, you can set `.trace` in a particular app instance to debug some special error case or track its behavior. 66 | /// 67 | @Atomic public static var forcedLogLevel: Logger.Level? 68 | 69 | /// Initial log level of `Logger`. *Atomic*. 70 | /// 71 | /// **Default** is `.info`. 72 | /// 73 | @Atomic public static var defaultLogLevel: Logger.Level = .info 74 | 75 | /// The log level upon receipt of which an attempt is made to immediately `upload` local logs to the server. *Atomic*. 76 | /// 77 | /// **Default** is `.critical`. 78 | /// 79 | @Atomic public static var signalingLogLevel: Logger.Level? = .critical 80 | 81 | /// Log entry upload size limit in bytes. *Atomic*. 82 | /// 83 | /// Logs that exceed the limit are excluded from the upload and deleted. 84 | /// 85 | /// **Default** is equivalent to `256 KB`, which is the approximate Google Cloud limit. 86 | /// 87 | @Atomic public static var maxLogEntrySize: UInt? = 256_000 88 | 89 | /// Log upload size limit in bytes. *Atomic*. 90 | /// 91 | /// Overflow is excluded from the upload and deleted starting with older logs. 92 | /// 93 | /// **Default** is equivalent to `10 MB`, which is the approximate Google Cloud limit. 94 | /// 95 | @Atomic public static var maxLogSize: UInt? = 10_000_000 96 | 97 | /// Logs retention period in seconds. *Atomic*. 98 | /// 99 | /// Expired logs are excluded from the upload and deleted. 100 | /// 101 | /// **Default** is equivalent to `30 days`, which is the default Google Cloud logs retention period. 102 | /// 103 | @Atomic public static var retentionPeriod: TimeInterval? = 3600 * 24 * 30 104 | 105 | /// Log upload interval in seconds. *Atomic*. 106 | /// 107 | /// Schedules the next and all repeated uploads after the specified time interval. 108 | /// 109 | /// **Default** is equivalent to `1 hour`. 110 | /// 111 | @Atomic public static var uploadInterval: TimeInterval? = 3600 { 112 | didSet { 113 | if logging != nil { 114 | timer.schedule(delay: uploadInterval, repeating: uploadInterval) 115 | logger.debug("Log upload interval has been updated", metadata: [MetadataKey.uploadInterval: uploadInterval.map { "\($0)" } ?? "nil"]) 116 | } 117 | } 118 | } 119 | 120 | /// Whether to include additional information about the source code location. *Atomic*. 121 | /// 122 | /// For each logger call, the source file path, the line number within the source file and the function name are added to the produced log entry. 123 | /// Assign `false` to opt out this behavior. 124 | /// 125 | /// **Default** is `true`. 126 | /// 127 | @Atomic public static var includeSourceLocation = true 128 | 129 | /// Internal logger for GoogleCloudLogHandler. *Atomic*. 130 | /// 131 | /// You can choose an appropriate `logLevel`. 132 | /// 133 | @Atomic public static var logger = Logger(label: "GoogleCloudLogHandler") 134 | 135 | /// URL to the local logs storage. *Atomic*. 136 | /// 137 | /// It can only be set once during `setup()`. 138 | /// Logs are stored in JSON Lines format. 139 | /// 140 | /// **Default** is `/tmp/GoogleCloudLogEntries.jsonl`. 141 | /// 142 | @Atomic public internal(set) static var logFile = FileManager.default.temporaryDirectory.appendingPathComponent("GoogleCloudLogEntries", isDirectory: false).appendingPathExtension("jsonl") 143 | 144 | @Atomic static var logging: GoogleCloudLogging? 145 | 146 | static let fileHandleQueue = DispatchQueue(label: "GoogleCloudLogHandler.FileHandle") 147 | 148 | static let timer: DispatchSourceTimer = { 149 | let timer = DispatchSource.makeTimerSource() 150 | timer.setEventHandler(handler: uploadOnSchedule) 151 | timer.activate() 152 | return timer 153 | }() 154 | 155 | 156 | public subscript(metadataKey key: String) -> Logger.Metadata.Value? { 157 | get { 158 | metadata[key] 159 | } 160 | set { 161 | metadata[key] = newValue 162 | } 163 | } 164 | 165 | public var metadata: Logger.Metadata = [:] 166 | 167 | 168 | private var _logLevel: Logger.Level = Self.defaultLogLevel 169 | 170 | public var logLevel: Logger.Level { 171 | get { 172 | Self.forcedLogLevel ?? _logLevel 173 | } 174 | set { 175 | _logLevel = newValue 176 | } 177 | } 178 | 179 | 180 | let label: String 181 | 182 | 183 | static func prepareLogFile() throws { 184 | 185 | if (try? logFile.checkResourceIsReachable()) != true { 186 | try FileManager.default.createDirectory(at: logFile.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) 187 | try Data().write(to: logFile) 188 | } 189 | } 190 | 191 | /// Setup `GoogleCloudLogHandler`. 192 | /// 193 | /// It must be called once, usually right after the app has been launched. 194 | /// 195 | /// - Parameters: 196 | /// - url: Service account credentials URL pointing to a JSON file generated by GCP and typically located in the app bundle. 197 | /// - clientId: The unique identifier for the client app instance that is added to each log entry and used to group and differ logs. 198 | /// - logFile: Optional custom log file URL. 199 | /// 200 | /// - Throws: `Error` if the service account credentials are unreadable or malformed, and also if unable to access or create the log file. 201 | /// 202 | public static func setup(serviceAccountCredentials url: URL, clientId: UUID?, logFile: URL = logFile) throws { 203 | 204 | let isFirstSetup = (logging == nil) 205 | logging = try GoogleCloudLogging(serviceAccountCredentials: url) 206 | 207 | globalMetadata[MetadataKey.clientId] = clientId.map(Logger.MetadataValue.stringConvertible) 208 | 209 | Self.logFile = logFile 210 | try prepareLogFile() 211 | 212 | upload() 213 | 214 | DispatchQueue.main.async { // Async in case setup before LoggingSystem bootstrap. 215 | if isFirstSetup { 216 | logger.info("GoogleCloudLogHandler has been setup", metadata: [MetadataKey.serviceAccountCredentials: "\(url)", MetadataKey.logFile: "\(logFile)"]) 217 | } else { 218 | logger.warning("Repeated setup of GoogleCloudLogHandler", metadata: [MetadataKey.serviceAccountCredentials: "\(url)", MetadataKey.logFile: "\(logFile)"]) 219 | fileHandleQueue.async { // Assert in fileHandleQueue so warning is saved. 220 | assertionFailure("App should only setup GoogleCloudLogHandler once") 221 | } 222 | } 223 | } 224 | } 225 | 226 | /// Use as `GoogleCloudLogHandler` factory for `LoggingSystem.bootstrap()` and `Logger.init()`. 227 | /// 228 | /// - Parameter label: Forwarded `logger.label` which is typically the enclosing class name. 229 | /// 230 | public init(label: String) { 231 | 232 | assert(Self.logging != nil, "App must setup GoogleCloudLogHandler before init") 233 | 234 | self.label = label 235 | 236 | DispatchQueue.main.async { 237 | Self.logger.trace("GoogleCloudLogHandler has been initialized", metadata: [MetadataKey.label: "\(label)"]) 238 | } 239 | } 240 | 241 | 242 | public func log(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, file: String, function: String, line: UInt) { 243 | 244 | let date = Date() 245 | 246 | Self.fileHandleQueue.async { 247 | let hashValue: Int? = Self.globalMetadata[MetadataKey.clientId].map { 248 | var hasher = Hasher() 249 | hasher.combine("\($0)") // Required in case random seeding is disabled. 250 | hasher.combine("\(message)") 251 | hasher.combine(date) 252 | return hasher.finalize() 253 | } 254 | var metadata = metadata ?? [:] 255 | var replacedMetadata = metadata.update(with: self.metadata) 256 | if !replacedMetadata.isEmpty, file != #file || function != #function { 257 | Self.logger.warning("Log metadata is replaced by logger metadata", metadata: [MetadataKey.replacedMetadata: .dictionary(replacedMetadata)]) 258 | } 259 | replacedMetadata = metadata.update(with: Self.globalMetadata) 260 | if !replacedMetadata.isEmpty, file != #file || function != #function { 261 | Self.logger.warning("Log metadata is replaced by global metadata", metadata: [MetadataKey.replacedMetadata: .dictionary(replacedMetadata)]) 262 | } 263 | let labels = metadata.mapValues { "\($0)" } 264 | let logEntry = GoogleCloudLogging.Log.Entry(logName: self.label, 265 | timestamp: date, 266 | severity: .init(level: level), 267 | insertId: hashValue.map { String($0, radix: 36) }, 268 | labels: labels.isEmpty ? nil : labels, 269 | sourceLocation: Self.includeSourceLocation ? .init(file: file, line: "\(line)", function: function) : nil, 270 | textPayload: "\(message)") 271 | do { 272 | try Self.prepareLogFile() 273 | let fileHandle = try FileHandle(forWritingTo: Self.logFile) 274 | try fileHandle.legacySeekToEnd() 275 | try fileHandle.legacyWrite(contentsOf: JSONEncoder().encode(logEntry)) 276 | try fileHandle.legacyWrite(contentsOf: [.newline]) 277 | } catch { 278 | if file != #file || function != #function { 279 | Self.logger.error("Unable to save log entry", metadata: [MetadataKey.logEntry: "\(logEntry)", MetadataKey.error: "\(error)"]) 280 | } 281 | return 282 | } 283 | 284 | if let signalingLevel = Self.signalingLogLevel, level >= signalingLevel, file != #file || function != "uploadOnSchedule()" { 285 | Self.upload() 286 | } 287 | } 288 | } 289 | 290 | /// Upload saved logs to GCP. 291 | /// 292 | /// It is called automatically after `setup()` and is repeating at `uploadInterval`. 293 | /// 294 | /// You can also call `upload()` manually in code e.g. before exit the app. 295 | /// 296 | public static func upload() { 297 | 298 | assert(logging != nil, "App must setup GoogleCloudLogHandler before upload") 299 | 300 | timer.schedule(delay: nil, repeating: uploadInterval) 301 | } 302 | 303 | 304 | static func uploadOnSchedule() { 305 | 306 | logger.debug("Start uploading logs") 307 | 308 | fileHandleQueue.async { 309 | do { 310 | let fileHandle = try FileHandle(forReadingFrom: logFile) 311 | guard let data = try fileHandle.legacyReadToEnd(), !data.isEmpty else { 312 | logger.debug("No logs to upload") 313 | return 314 | } 315 | 316 | var lines = data.split(separator: .newline) 317 | let lineCount = lines.count 318 | var logSize = lines.reduce(0) { $0 + $1.count } 319 | let exclusionMessage = "Some log entries are excluded from the upload" 320 | 321 | if let maxLogEntrySize = maxLogEntrySize, logSize > maxLogEntrySize { 322 | lines.removeAll { $0.count > maxLogEntrySize } 323 | let removedLineCount = lineCount - lines.count 324 | if removedLineCount > 0 { 325 | logSize = lines.reduce(0) { $0 + $1.count } 326 | logger.warning("\(exclusionMessage) due to exceeding the log entry size limit", metadata: [MetadataKey.excludedLogEntryCount: "\(removedLineCount)", MetadataKey.maxLogEntrySize: "\(maxLogEntrySize)"]) 327 | } 328 | } 329 | 330 | if let maxLogSize = maxLogSize, logSize > maxLogSize { 331 | let lineCount = lines.count 332 | repeat { 333 | logSize -= lines.removeFirst().count 334 | } while logSize > maxLogSize 335 | let removedLineCount = lineCount - lines.count 336 | logger.warning("\(exclusionMessage) due to exceeding the log size limit", metadata: [MetadataKey.excludedLogEntryCount: "\(removedLineCount)", MetadataKey.maxLogSize: "\(maxLogSize)"]) 337 | } 338 | 339 | let decoder = JSONDecoder() 340 | var logEntries = lines.compactMap { try? decoder.decode(GoogleCloudLogging.Log.Entry.self, from: $0) } 341 | let undecodedLogEntryCount = lines.count - logEntries.count 342 | if undecodedLogEntryCount > 0 { 343 | logger.warning("\(exclusionMessage) due to decoding failure", metadata: [MetadataKey.excludedLogEntryCount: "\(undecodedLogEntryCount)"]) 344 | } 345 | 346 | if let retentionPeriod = retentionPeriod { 347 | let logEntryCount = logEntries.count 348 | logEntries.removeAll { $0.timestamp.map { -$0.timeIntervalSinceNow > retentionPeriod } ?? false } 349 | let removedLogEntryCount = logEntryCount - logEntries.count 350 | if removedLogEntryCount > 0 { 351 | logger.warning("\(exclusionMessage) due to exceeding the retention period", metadata: [MetadataKey.excludedLogEntryCount: "\(removedLogEntryCount)", MetadataKey.retentionPeriod: "\(retentionPeriod)"]) 352 | } 353 | } 354 | 355 | func deleteOldEntries() { 356 | do { 357 | try (fileHandle.legacyReadToEnd() ?? Data()).write(to: logFile, options: .atomic) 358 | logger.debug("Uploaded logs have been deleted") 359 | } catch { 360 | logger.error("Unable to delete uploaded logs", metadata: [MetadataKey.error: "\(error)"]) 361 | } 362 | } 363 | 364 | func updateOldEntries() { 365 | do { 366 | if lineCount != logEntries.count { 367 | let encoder = JSONEncoder() 368 | var lines = logEntries.compactMap { try? encoder.encode($0) } 369 | if let data = try fileHandle.legacyReadToEnd(), !data.isEmpty { 370 | lines.append(data) 371 | } 372 | try Data(lines.joined(separator: [.newline])).write(to: logFile, options: .atomic) 373 | logger.debug("Overflowed or expired logs have been deleted") 374 | } else { 375 | logger.debug("No overflowed or expired logs to delete") 376 | } 377 | } catch { 378 | logger.error("Unable to delete overflowed or expired logs", metadata: [MetadataKey.error: "\(error)"]) 379 | } 380 | } 381 | 382 | guard let logging = logging else { 383 | logger.critical("Attempt to upload logs without GoogleCloudLogHandler setup") 384 | updateOldEntries() 385 | return 386 | } 387 | 388 | logging.write(entries: logEntries) { result in 389 | fileHandleQueue.async { 390 | switch result { 391 | case .success: 392 | logger.info("Logs have been uploaded") 393 | deleteOldEntries() 394 | case .failure(let error): 395 | switch error { 396 | case let error as NSError where error.domain == NSURLErrorDomain && error.code == NSURLErrorNotConnectedToInternet: 397 | logger.notice("Logs cannot be uploaded without an internet connection") 398 | case let error as NSError where error.domain == NSURLErrorDomain && error.code == NSURLErrorTimedOut: 399 | logger.notice("Logs may not have been uploaded due to poor internet connection") 400 | case GoogleCloudLogging.EntriesWriteError.noEntriesToSend: 401 | logger.notice("No relevant logs to upload") 402 | default: 403 | logger.error("Unable to upload logs", metadata: [MetadataKey.error: "\(error)"]) 404 | } 405 | updateOldEntries() 406 | } 407 | } 408 | } 409 | } catch { 410 | logger.error("Unable to read saved logs", metadata: [MetadataKey.error: "\(error)"]) 411 | } 412 | } 413 | } 414 | } 415 | 416 | 417 | 418 | extension GoogleCloudLogging.Log.Entry.Severity { 419 | 420 | init(level: Logger.Level) { 421 | switch level { 422 | case .trace: self = .default 423 | case .debug: self = .debug 424 | case .info: self = .info 425 | case .notice: self = .notice 426 | case .warning: self = .warning 427 | case .error: self = .error 428 | case .critical: self = .critical 429 | } 430 | } 431 | } 432 | 433 | 434 | 435 | @propertyWrapper 436 | public class Atomic { 437 | 438 | private let queue = DispatchQueue(label: "GoogleCloudLogHandler.AtomicProperty", attributes: .concurrent) 439 | private var value: T 440 | public var wrappedValue: T { 441 | get { queue.sync { value } } 442 | set { queue.async(flags: .barrier) { self.value = newValue } } 443 | } 444 | public init(wrappedValue: T) { 445 | value = wrappedValue 446 | } 447 | } 448 | 449 | 450 | 451 | extension Data.Element { 452 | 453 | static let newline = Data("\n".utf8).first! // 10 454 | } 455 | 456 | 457 | 458 | extension FileHandle { 459 | 460 | @discardableResult 461 | func legacySeekToEnd() throws -> UInt64 { 462 | if #available(OSX 10.15, iOS 13.4, watchOS 6.2, tvOS 13.4, *) { 463 | return try seekToEnd() 464 | } else { 465 | return seekToEndOfFile() 466 | } 467 | } 468 | 469 | func legacyWrite(contentsOf data: T) throws where T : DataProtocol { 470 | if #available(OSX 10.15, iOS 13.4, watchOS 6.2, tvOS 13.4, *) { 471 | try write(contentsOf: data) 472 | } else { 473 | write(Data(data)) 474 | } 475 | } 476 | 477 | func legacyReadToEnd() throws -> Data? { 478 | if #available(OSX 10.15, iOS 13.4, watchOS 6.2, tvOS 13.4, *) { 479 | return try readToEnd() 480 | } else { 481 | return readDataToEndOfFile() 482 | } 483 | } 484 | } 485 | 486 | 487 | 488 | extension DispatchSourceTimer { 489 | 490 | func schedule(delay: TimeInterval?, repeating: TimeInterval?) { 491 | schedule(deadline: delay.map { .now() + $0 } ?? .now(), repeating: repeating.map { .seconds(Int($0)) } ?? .never, leeway: repeating.map { .seconds(Int($0 / 2)) } ?? .nanoseconds(0)) 492 | } 493 | } 494 | 495 | 496 | 497 | extension Dictionary { 498 | 499 | mutating func update(with other: [Key : Value]) -> [Key : Value] { 500 | var replaced = [Key : Value]() 501 | self = other.reduce(into: self) { replaced[$1.key] = $0.updateValue($1.value, forKey: $1.key) } 502 | return replaced 503 | } 504 | } 505 | 506 | 507 | 508 | extension GoogleCloudLogHandler.MetadataKey { 509 | 510 | static let serviceAccountCredentials = "serviceAccountCredentials" 511 | static let logFile = "logFile" 512 | static let label = "label" 513 | static let replacedMetadata = "replacedMetadata" 514 | static let logEntry = "logEntry" 515 | static let excludedLogEntryCount = "excludedLogEntryCount" 516 | static let maxLogEntrySize = "maxLogEntrySize" 517 | static let maxLogSize = "maxLogSize" 518 | static let retentionPeriod = "retentionPeriod" 519 | static let uploadInterval = "uploadInterval" 520 | } 521 | --------------------------------------------------------------------------------