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