firebase log level

This commit is contained in:
oscarz
2024-08-29 18:25:13 +08:00
parent 8500300d18
commit 27c160beaf
1165 changed files with 122916 additions and 1 deletions

View File

@ -0,0 +1,76 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// An enumeration of time periods.
enum TimePeriod: Int, CaseIterable, Codable {
/// The raw value is the number of calendar days within each time period.
/// More types can be enabled in future iterations (i.e. `weekly = 7, monthly = 28`).
case daily = 1
/// The number of seconds in a given time period.
var timeInterval: TimeInterval {
Double(rawValue) * 86400 /* seconds in day */
}
}
/// A structure representing SDK usage.
struct Heartbeat: Codable, Equatable {
/// The version of the heartbeat.
private static let version: Int = 0
/// An anonymous string of information (i.e. user agent) to associate the heartbeat with.
let agent: String
/// The date when the heartbeat was recorded.
let date: Date
/// The heartbeat's model version.
let version: Int
/// An array of `TimePeriod`s that the heartbeat is tagged with. See `TimePeriod`.
///
/// Heartbeats represent anonymous data points that measure SDK usage in moving averages for
/// various time periods. Because a single heartbeat can help calculate moving averages for
/// multiple
/// time periods, this property serves to capture all the time periods that the heartbeat can
/// represent in
/// a moving average.
let timePeriods: [TimePeriod]
/// Designated initializer.
/// - Parameters:
/// - agent: An anonymous string of information to associate the heartbeat with.
/// - date: The date when the heartbeat was recorded.
/// - version: The heartbeat's version. Defaults to the current version.
init(agent: String,
date: Date,
timePeriods: [TimePeriod] = [],
version: Int = version) {
self.agent = agent
self.date = date
self.timePeriods = timePeriods
self.version = version
}
}
extension Heartbeat: HeartbeatsPayloadConvertible {
func makeHeartbeatsPayload() -> HeartbeatsPayload {
let userAgentPayloads = [
HeartbeatsPayload.UserAgentPayload(agent: agent, dates: [date]),
]
return HeartbeatsPayload(userAgentPayloads: userAgentPayloads)
}
}

View File

@ -0,0 +1,157 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// An object that provides API to log and flush heartbeats from a synchronized storage container.
public final class HeartbeatController {
/// Used for standardizing dates for calendar-day comparison.
private enum DateStandardizer {
private static let calendar: Calendar = {
var calendar = Calendar(identifier: .iso8601)
calendar.locale = Locale(identifier: "en_US_POSIX")
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
return calendar
}()
static func standardize(_ date: Date) -> (Date) {
return calendar.startOfDay(for: date)
}
}
/// The thread-safe storage object to log and flush heartbeats from.
private let storage: HeartbeatStorageProtocol
/// The max capacity of heartbeats to store in storage.
private let heartbeatsStorageCapacity: Int = 30
/// Current date provider. It is used for testability.
private let dateProvider: () -> Date
/// Used for standardizing dates for calendar-day comparison.
private static let dateStandardizer = DateStandardizer.self
/// Public initializer.
/// - Parameter id: The `id` to associate this controller's heartbeat storage with.
public convenience init(id: String) {
self.init(id: id, dateProvider: Date.init)
}
/// Convenience initializer. Mirrors the semantics of the public initializer with the added
/// benefit of
/// injecting a custom date provider for improved testability.
/// - Parameters:
/// - id: The id to associate this controller's heartbeat storage with.
/// - dateProvider: A date provider.
convenience init(id: String, dateProvider: @escaping () -> Date) {
let storage = HeartbeatStorage.getInstance(id: id)
self.init(storage: storage, dateProvider: dateProvider)
}
/// Designated initializer.
/// - Parameters:
/// - storage: A heartbeat storage container.
/// - dateProvider: A date provider. Defaults to providing the current date.
init(storage: HeartbeatStorageProtocol,
dateProvider: @escaping () -> Date = Date.init) {
self.storage = storage
self.dateProvider = { Self.dateStandardizer.standardize(dateProvider()) }
}
/// Asynchronously logs a new heartbeat, if needed.
///
/// - Note: This API is thread-safe.
/// - Parameter agent: The string agent (i.e. Firebase User Agent) to associate the logged
/// heartbeat with.
public func log(_ agent: String) {
let date = dateProvider()
storage.readAndWriteAsync { heartbeatsBundle in
var heartbeatsBundle = heartbeatsBundle ??
HeartbeatsBundle(capacity: self.heartbeatsStorageCapacity)
// Filter for the time periods where the last heartbeat to be logged for
// that time period was logged more than one time period (i.e. day) ago.
let timePeriods = heartbeatsBundle.lastAddedHeartbeatDates.filter { timePeriod, lastDate in
date.timeIntervalSince(lastDate) >= timePeriod.timeInterval
}
.map { timePeriod, _ in timePeriod }
if !timePeriods.isEmpty {
// A heartbeat should only be logged if there is a time period(s) to
// associate it with.
let heartbeat = Heartbeat(agent: agent, date: date, timePeriods: timePeriods)
heartbeatsBundle.append(heartbeat)
}
return heartbeatsBundle
}
}
/// Synchronously flushes heartbeats from storage into a heartbeats payload.
///
/// - Note: This API is thread-safe.
/// - Returns: The flushed heartbeats in the form of `HeartbeatsPayload`.
@discardableResult
public func flush() -> HeartbeatsPayload {
let resetTransform = { (heartbeatsBundle: HeartbeatsBundle?) -> HeartbeatsBundle? in
guard let oldHeartbeatsBundle = heartbeatsBundle else {
return nil // Storage was empty.
}
// The new value that's stored will use the old's cache to prevent the
// logging of duplicates after flushing.
return HeartbeatsBundle(
capacity: self.heartbeatsStorageCapacity,
cache: oldHeartbeatsBundle.lastAddedHeartbeatDates
)
}
do {
// Synchronously gets and returns the stored heartbeats, resetting storage
// using the given transform.
let heartbeatsBundle = try storage.getAndSet(using: resetTransform)
// If no heartbeats bundle was stored, return an empty payload.
return heartbeatsBundle?.makeHeartbeatsPayload() ?? HeartbeatsPayload.emptyPayload
} catch {
// If the operation throws, assume no heartbeat(s) were retrieved or set.
return HeartbeatsPayload.emptyPayload
}
}
/// Synchronously flushes the heartbeat for today.
///
/// If no heartbeat was logged today, the returned payload is empty.
///
/// - Note: This API is thread-safe.
/// - Returns: A heartbeats payload for the flushed heartbeat.
@discardableResult
public func flushHeartbeatFromToday() -> HeartbeatsPayload {
let todaysDate = dateProvider()
var todaysHeartbeat: Heartbeat?
storage.readAndWriteSync { heartbeatsBundle in
guard var heartbeatsBundle = heartbeatsBundle else {
return nil // Storage was empty.
}
todaysHeartbeat = heartbeatsBundle.removeHeartbeat(from: todaysDate)
return heartbeatsBundle
}
// Note that `todaysHeartbeat` is updated in the above read/write block.
if todaysHeartbeat != nil {
return todaysHeartbeat!.makeHeartbeatsPayload()
} else {
return HeartbeatsPayload.emptyPayload
}
}
}

View File

@ -0,0 +1,140 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#if DEBUG
import Foundation
/// A utility class intended to be used only in testing contexts.
@objc(FIRHeartbeatLoggingTestUtils)
@objcMembers
public class HeartbeatLoggingTestUtils: NSObject {
/// This should mirror the `Constants` enum in the `HeartbeatLogging` module.
/// See `HeartbeatLogging/Sources/StorageFactory.swift`.
public enum Constants {
/// The name of the file system directory where heartbeat data is stored.
public static let heartbeatFileStorageDirectoryPath = "google-heartbeat-storage"
/// The name of the user defaults suite where heartbeat data is stored.
public static let heartbeatUserDefaultsSuiteName = "com.google.heartbeat.storage"
}
public static var dateFormatter: DateFormatter {
HeartbeatsPayload.dateFormatter
}
public static var emptyHeartbeatsPayload: _ObjC_HeartbeatsPayload {
let literalData = """
{
"version": 2,
"heartbeats": []
}
"""
.data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(HeartbeatsPayload.dateFormatter)
let heartbeatsPayload = try! decoder.decode(HeartbeatsPayload.self, from: literalData)
return _ObjC_HeartbeatsPayload(heartbeatsPayload)
}
public static var nonEmptyHeartbeatsPayload: _ObjC_HeartbeatsPayload {
let literalData = """
{
"version": 2,
"heartbeats": [
{
"agent": "dummy_agent_1",
"dates": ["2021-11-01", "2021-11-02"]
},
{
"agent": "dummy_agent_2",
"dates": ["2021-11-03"]
}
]
}
"""
.data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(HeartbeatsPayload.dateFormatter)
let heartbeatsPayload = try! decoder.decode(HeartbeatsPayload.self, from: literalData)
return _ObjC_HeartbeatsPayload(heartbeatsPayload)
}
@objc(assertEncodedPayloadString:isEqualToLiteralString:withError:)
public static func assertEqualPayloadStrings(_ encoded: String, _ literal: String) throws {
var encodedData = Data(base64URLEncoded: encoded)!
if encodedData.count > 0 {
encodedData = try! encodedData.unzipped()
}
let literalData = literal.data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(HeartbeatsPayload.dateFormatter)
let payloadFromEncoded = try? decoder.decode(HeartbeatsPayload.self, from: encodedData)
let payloadFromLiteral = try? decoder.decode(HeartbeatsPayload.self, from: literalData)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .formatted(HeartbeatsPayload.dateFormatter)
encoder.outputFormatting = .prettyPrinted
let payloadDataFromEncoded = try! encoder.encode(payloadFromEncoded)
let payloadDataFromLiteral = try! encoder.encode(payloadFromLiteral)
assert(
payloadFromEncoded == payloadFromLiteral,
"""
Mismatched payloads!
Payload 1:
\(String(data: payloadDataFromEncoded, encoding: .utf8) ?? "")
Payload 2:
\(String(data: payloadDataFromLiteral, encoding: .utf8) ?? "")
"""
)
}
/// Removes all underlying storage containers used by the module.
/// - Throws: An error if the storage container could not be removed.
public static func removeUnderlyingHeartbeatStorageContainers() throws {
#if os(tvOS)
UserDefaults().removePersistentDomain(forName: Constants.heartbeatUserDefaultsSuiteName)
#else
let applicationSupportDirectory = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let heartbeatsDirectoryURL = applicationSupportDirectory
.appendingPathComponent(
Constants.heartbeatFileStorageDirectoryPath, isDirectory: true
)
do {
try FileManager.default.removeItem(at: heartbeatsDirectoryURL)
} catch CocoaError.fileNoSuchFile {
// Do nothing.
} catch {
throw error
}
#endif // os(tvOS)
}
}
#endif // ENABLE_FIREBASE_CORE_INTERNAL_TESTING_UTILS

View File

@ -0,0 +1,180 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// A type that can perform atomic operations using block-based transformations.
protocol HeartbeatStorageProtocol {
func readAndWriteSync(using transform: (HeartbeatsBundle?) -> HeartbeatsBundle?)
func readAndWriteAsync(using transform: @escaping (HeartbeatsBundle?) -> HeartbeatsBundle?)
func getAndSet(using transform: (HeartbeatsBundle?) -> HeartbeatsBundle?) throws
-> HeartbeatsBundle?
}
/// Thread-safe storage object designed for transforming heartbeat data that is persisted to disk.
final class HeartbeatStorage: HeartbeatStorageProtocol {
/// The identifier used to differentiate instances.
private let id: String
/// The underlying storage container to read from and write to.
private let storage: Storage
/// The encoder used for encoding heartbeat data.
private let encoder: JSONEncoder = .init()
/// The decoder used for decoding heartbeat data.
private let decoder: JSONDecoder = .init()
/// The queue for synchronizing storage operations.
private let queue: DispatchQueue
/// Designated initializer.
/// - Parameters:
/// - id: A string identifier.
/// - storage: The underlying storage container where heartbeat data is stored.
init(id: String,
storage: Storage) {
self.id = id
self.storage = storage
queue = DispatchQueue(label: "com.heartbeat.storage.\(id)")
}
// MARK: - Instance Management
/// Statically allocated cache of `HeartbeatStorage` instances keyed by string IDs.
private static var cachedInstances: [String: WeakContainer<HeartbeatStorage>] = [:]
/// Gets an existing `HeartbeatStorage` instance with the given `id` if one exists. Otherwise,
/// makes a new instance with the given `id`.
///
/// - Parameter id: A string identifier.
/// - Returns: A `HeartbeatStorage` instance.
static func getInstance(id: String) -> HeartbeatStorage {
if let cachedInstance = cachedInstances[id]?.object {
return cachedInstance
} else {
let newInstance = HeartbeatStorage.makeHeartbeatStorage(id: id)
cachedInstances[id] = WeakContainer(object: newInstance)
return newInstance
}
}
/// Makes a `HeartbeatStorage` instance using a given `String` identifier.
///
/// The created persistent storage object is platform dependent. For tvOS, user defaults
/// is used as the underlying storage container due to system storage limits. For all other
/// platforms,
/// the file system is used.
///
/// - Parameter id: A `String` identifier used to create the `HeartbeatStorage`.
/// - Returns: A `HeartbeatStorage` instance.
private static func makeHeartbeatStorage(id: String) -> HeartbeatStorage {
#if os(tvOS)
let storage = UserDefaultsStorage.makeStorage(id: id)
#else
let storage = FileStorage.makeStorage(id: id)
#endif // os(tvOS)
return HeartbeatStorage(id: id, storage: storage)
}
deinit {
// Removes the instance if it was cached.
Self.cachedInstances.removeValue(forKey: id)
}
// MARK: - HeartbeatStorageProtocol
/// Synchronously reads from and writes to storage using the given transform block.
/// - Parameter transform: A block to transform the currently stored heartbeats bundle to a new
/// heartbeats bundle value.
func readAndWriteSync(using transform: (HeartbeatsBundle?) -> HeartbeatsBundle?) {
queue.sync {
let oldHeartbeatsBundle = try? load(from: storage)
let newHeartbeatsBundle = transform(oldHeartbeatsBundle)
try? save(newHeartbeatsBundle, to: storage)
}
}
/// Asynchronously reads from and writes to storage using the given transform block.
/// - Parameter transform: A block to transform the currently stored heartbeats bundle to a new
/// heartbeats bundle value.
func readAndWriteAsync(using transform: @escaping (HeartbeatsBundle?) -> HeartbeatsBundle?) {
queue.async { [self] in
let oldHeartbeatsBundle = try? load(from: storage)
let newHeartbeatsBundle = transform(oldHeartbeatsBundle)
try? save(newHeartbeatsBundle, to: storage)
}
}
/// Synchronously gets the current heartbeat data from storage and resets the storage using the
/// given transform block.
///
/// This API is like any `getAndSet`-style API in that it gets (and returns) the current value and
/// uses
/// a block to transform the current value (or, soon-to-be old value) to a new value.
///
/// - Parameter transform: An optional block used to reset the currently stored heartbeat.
/// - Returns: The heartbeat data that was stored (before the `transform` was applied).
@discardableResult
func getAndSet(using transform: (HeartbeatsBundle?) -> HeartbeatsBundle?) throws
-> HeartbeatsBundle? {
let heartbeatsBundle: HeartbeatsBundle? = try queue.sync {
let oldHeartbeatsBundle = try? load(from: storage)
let newHeartbeatsBundle = transform(oldHeartbeatsBundle)
try save(newHeartbeatsBundle, to: storage)
return oldHeartbeatsBundle
}
return heartbeatsBundle
}
/// Loads and decodes the stored heartbeats bundle from a given storage object.
/// - Parameter storage: The storage container to read from.
/// - Returns: The decoded `HeartbeatsBundle` loaded from storage; `nil` if storage is empty.
/// - Throws: An error if storage could not be read or the data could not be decoded.
private func load(from storage: Storage) throws -> HeartbeatsBundle? {
let data = try storage.read()
if data.isEmpty {
return nil
} else {
let heartbeatData = try data.decoded(using: decoder) as HeartbeatsBundle
return heartbeatData
}
}
/// Saves the encoding of the given value to the given storage container.
/// - Parameters:
/// - heartbeatsBundle: The heartbeats bundle to encode and save.
/// - storage: The storage container to write to.
private func save(_ heartbeatsBundle: HeartbeatsBundle?, to storage: Storage) throws {
if let heartbeatsBundle {
let data = try heartbeatsBundle.encoded(using: encoder)
try storage.write(data)
} else {
try storage.write(nil)
}
}
}
private extension Data {
/// Returns the decoded value of this `Data` using the given decoder. Defaults to `JSONDecoder`.
/// - Returns: The decoded value.
func decoded<T>(using decoder: JSONDecoder = .init()) throws -> T where T: Decodable {
try decoder.decode(T.self, from: self)
}
}
private extension Encodable {
/// Returns the `Data` encoding of this value using the given encoder.
/// - Parameter encoder: An encoder used to encode the value. Defaults to `JSONEncoder`.
/// - Returns: The data encoding of the value.
func encoded(using encoder: JSONEncoder = .init()) throws -> Data {
try encoder.encode(self)
}
}

View File

@ -0,0 +1,151 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// A type that can be converted to a `HeartbeatsPayload`.
protocol HeartbeatsPayloadConvertible {
func makeHeartbeatsPayload() -> HeartbeatsPayload
}
/// A codable collection of heartbeats that has a fixed capacity and optimizations for storing
/// heartbeats of
/// multiple time periods.
struct HeartbeatsBundle: Codable, HeartbeatsPayloadConvertible {
/// The maximum number of heartbeats that can be stored in the buffer.
let capacity: Int
/// A cache used for keeping track of the last heartbeat date recorded for a given time period.
///
/// The cache contains the last added date for each time period. The reason only the date is
/// cached is
/// because it's the only piece of information that should be used by clients to determine whether
/// or not
/// to append a new heartbeat.
private(set) var lastAddedHeartbeatDates: [TimePeriod: Date]
/// A ring buffer of heartbeats.
private var buffer: RingBuffer<Heartbeat>
/// A default cache provider that provides a dictionary of all time periods mapping to a default
/// date.
static var cacheProvider: () -> [TimePeriod: Date] {
let timePeriodsAndDates = TimePeriod.allCases.map { ($0, Date.distantPast) }
return { Dictionary(uniqueKeysWithValues: timePeriodsAndDates) }
}
/// Designated initializer.
/// - Parameters:
/// - capacity: The heartbeat capacity of the initialized collection.
/// - cache: A cache of time periods mapping to dates. Defaults to using static `cacheProvider`.
init(capacity: Int,
cache: [TimePeriod: Date] = cacheProvider()) {
buffer = RingBuffer(capacity: capacity)
self.capacity = capacity
lastAddedHeartbeatDates = cache
}
/// Appends a heartbeat to this collection.
/// - Parameter heartbeat: The heartbeat to append.
mutating func append(_ heartbeat: Heartbeat) {
guard capacity > 0 else {
return // Do not append if capacity is non-positive.
}
do {
// Push the heartbeat to the back of the buffer.
if let overwrittenHeartbeat = try buffer.push(heartbeat) {
// If a heartbeat was overwritten, update the cache to ensure it's date
// is removed.
lastAddedHeartbeatDates = lastAddedHeartbeatDates.mapValues { date in
overwrittenHeartbeat.date == date ? .distantPast : date
}
}
// Update cache with the new heartbeat's date.
for timePeriod in heartbeat.timePeriods {
lastAddedHeartbeatDates[timePeriod] = heartbeat.date
}
} catch let error as RingBuffer<Heartbeat>.Error {
// A ring buffer error occurred while pushing to the buffer so the bundle
// is reset.
self = HeartbeatsBundle(capacity: capacity)
// Create a diagnostic heartbeat to capture the failure and add it to the
// buffer. The failure is added as a key/value pair to the agent string.
// Given that the ring buffer has been reset, it is not expected for the
// second push attempt to fail.
let errorDescription = error.errorDescription.replacingOccurrences(of: " ", with: "-")
let diagnosticHeartbeat = Heartbeat(
agent: "\(heartbeat.agent) error/\(errorDescription)",
date: heartbeat.date,
timePeriods: heartbeat.timePeriods
)
let secondPushAttempt = Result {
try buffer.push(diagnosticHeartbeat)
}
if case .success = secondPushAttempt {
// Update cache with the new heartbeat's date.
for timePeriod in diagnosticHeartbeat.timePeriods {
lastAddedHeartbeatDates[timePeriod] = diagnosticHeartbeat.date
}
}
} catch {
// Ignore other error.
}
}
/// Removes the heartbeat associated with the given date.
/// - Parameter date: The date of the heartbeat needing removal.
/// - Returns: The heartbeat that was removed or `nil` if there was no heartbeat to remove.
@discardableResult
mutating func removeHeartbeat(from date: Date) -> Heartbeat? {
var removedHeartbeat: Heartbeat?
var poppedHeartbeats: [Heartbeat] = []
while let poppedHeartbeat = buffer.pop() {
if poppedHeartbeat.date == date {
removedHeartbeat = poppedHeartbeat
break
}
poppedHeartbeats.append(poppedHeartbeat)
}
for poppedHeartbeat in poppedHeartbeats.reversed() {
do {
try buffer.push(poppedHeartbeat)
} catch {
// Ignore error.
}
}
return removedHeartbeat
}
/// Makes and returns a `HeartbeatsPayload` from this heartbeats bundle.
/// - Returns: A heartbeats payload.
func makeHeartbeatsPayload() -> HeartbeatsPayload {
let agentAndDates = buffer.map { heartbeat in
(heartbeat.agent, [heartbeat.date])
}
let userAgentPayloads = [String: [Date]](agentAndDates, uniquingKeysWith: +)
.map(HeartbeatsPayload.UserAgentPayload.init)
.sorted { $0.agent < $1.agent } // Sort payloads by user agent.
return HeartbeatsPayload(userAgentPayloads: userAgentPayloads)
}
}

View File

@ -0,0 +1,181 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
#if SWIFT_PACKAGE
@_implementationOnly import GoogleUtilities_NSData
#else
@_implementationOnly import GoogleUtilities
#endif // SWIFT_PACKAGE
/// A type that provides a string representation for use in an HTTP header.
public protocol HTTPHeaderRepresentable {
func headerValue() -> String
}
/// A value type representing a payload of heartbeat data intended for sending in network requests.
///
/// This type's structure is optimized for type-safe encoding into a HTTP payload format.
/// The current encoding format for the payload's current version is:
///
/// {
/// "version": 2,
/// "heartbeats": [
/// {
/// "agent": "dummy_agent_1",
/// "dates": ["2021-11-01", "2021-11-02"]
/// },
/// {
/// "agent": "dummy_agent_2",
/// "dates": ["2021-11-03"]
/// }
/// ]
/// }
///
public struct HeartbeatsPayload: Codable, Sendable {
/// The version of the payload. See go/firebase-apple-heartbeats for details regarding current
/// version.
static let version: Int = 2
/// A payload component composed of a user agent and array of dates (heartbeats).
struct UserAgentPayload: Codable {
/// An anonymous agent string.
let agent: String
/// An array of dates where each date represents a "heartbeat".
let dates: [Date]
}
/// An array of user agent payloads.
let userAgentPayloads: [UserAgentPayload]
/// The version of the payload structure.
let version: Int
/// Alternative keys for properties so encoding follows platform-wide payload structure.
enum CodingKeys: String, CodingKey {
case userAgentPayloads = "heartbeats"
case version
}
/// Designated initializer.
/// - Parameters:
/// - userAgentPayloads: An array of payloads containing heartbeat data corresponding to a
/// given user agent.
/// - version: A version of the payload. Defaults to the static default.
init(userAgentPayloads: [UserAgentPayload] = [], version: Int = version) {
self.userAgentPayloads = userAgentPayloads
self.version = version
}
/// A Boolean value indicating whether the payload is empty.
public var isEmpty: Bool {
userAgentPayloads.isEmpty
}
}
// MARK: - HTTPHeaderRepresentable
extension HeartbeatsPayload: HTTPHeaderRepresentable {
/// Returns a processed payload string intended for use in a HTTP header.
/// - Returns: A string value from the heartbeats payload.
public func headerValue() -> String {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .formatted(Self.dateFormatter)
#if DEBUG
// Sort keys in debug builds to simplify output comparisons in unit tests.
encoder.outputFormatting = .sortedKeys
#endif // DEBUG
guard let data = try? encoder.encode(self) else {
// If encoding fails, fall back to encoding with an empty payload.
return Self.emptyPayload.headerValue()
}
do {
let gzippedData = try data.zipped()
return gzippedData.base64URLEncodedString()
} catch {
// If gzipping fails, fall back to encoding with base64URL.
return data.base64URLEncodedString()
}
}
}
// MARK: - Static Defaults
extension HeartbeatsPayload {
/// Convenience instance that represents an empty payload.
static let emptyPayload = HeartbeatsPayload()
/// A default date formatter that uses `yyyy-MM-dd` format.
public static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter
}()
}
// MARK: - Equatable
extension HeartbeatsPayload: Equatable {}
extension HeartbeatsPayload.UserAgentPayload: Equatable {}
// MARK: - Data
public extension Data {
/// Returns a Base-64 URL-safe encoded string.
///
/// - parameter options: The options to use for the encoding. Default value is `[]`.
/// - returns: The Base-64 URL-safe encoded string.
func base64URLEncodedString(options: Data.Base64EncodingOptions = []) -> String {
base64EncodedString()
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "=", with: "")
}
/// Initialize a `Data` from a Base-64 URL encoded String using the given options.
///
/// Returns nil when the input is not recognized as valid Base-64.
/// - parameter base64URLString: The string to parse.
/// - parameter options: Encoding options. Default value is `[]`.
init?(base64URLEncoded base64URLString: String, options: Data.Base64DecodingOptions = []) {
var base64Encoded = base64URLString
.replacingOccurrences(of: "_", with: "/")
.replacingOccurrences(of: "-", with: "+")
// Pad the string with "=" signs until the string's length is a multiple of 4.
while !base64Encoded.count.isMultiple(of: 4) {
base64Encoded.append("=")
}
self.init(base64Encoded: base64Encoded, options: options)
}
/// Returns the compressed data.
/// - Returns: The compressed data.
/// - Throws: An error if compression failed.
func zipped() throws -> Data {
try NSData.gul_data(byGzippingData: self)
}
/// Returns the uncompressed data.
/// - Returns: The decompressed data.
/// - Throws: An error if decompression failed.
func unzipped() throws -> Data {
try NSData.gul_data(byInflatingGzippedData: self)
}
}

View File

@ -0,0 +1,111 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// A generic circular queue structure.
struct RingBuffer<Element>: Sequence {
/// An array of heartbeats treated as a circular queue and initialized with a fixed capacity.
private var circularQueue: [Element?]
/// The current "tail" and insert point for the `circularQueue`.
private var tailIndex: Array<Element?>.Index
/// Error types for `RingBuffer` operations.
enum Error: LocalizedError {
case outOfBoundsPush(pushIndex: Array<Element?>.Index, endIndex: Array<Element?>.Index)
var errorDescription: String {
switch self {
case let .outOfBoundsPush(pushIndex, endIndex):
return "Out-of-bounds push at index \(pushIndex) to ring buffer with" +
"end index of \(endIndex)."
}
}
}
/// Designated initializer.
/// - Parameter capacity: An `Int` representing the capacity.
init(capacity: Int) {
circularQueue = Array(repeating: nil, count: capacity)
tailIndex = circularQueue.startIndex
}
/// Pushes an element to the back of the buffer, returning the element (`Element?`) that was
/// overwritten.
/// - Parameter element: The element to push to the back of the buffer.
/// - Returns: The element that was overwritten or `nil` if nothing was overwritten.
/// - Complexity: O(1)
@discardableResult
mutating func push(_ element: Element) throws -> Element? {
guard circularQueue.count > 0 else {
// Do not push if `circularQueue` is a fixed empty array.
return nil
}
guard circularQueue.indices.contains(tailIndex) else {
// We have somehow entered an invalid state (#10025).
throw Self.Error.outOfBoundsPush(
pushIndex: tailIndex,
endIndex: circularQueue.endIndex
)
}
let replaced = circularQueue[tailIndex]
circularQueue[tailIndex] = element
// Increment index, wrapping around to the start if needed.
tailIndex += 1
if tailIndex >= circularQueue.endIndex {
tailIndex = circularQueue.startIndex
}
return replaced
}
/// Pops an element from the back of the buffer, returning the element (`Element?`) that was
/// popped.
/// - Returns: The element that was popped or `nil` if there was no element to pop.
/// - Complexity: O(1)
@discardableResult
mutating func pop() -> Element? {
guard circularQueue.count > 0 else {
// Do not pop if `circularQueue` is a fixed empty array.
return nil
}
// Decrement index, wrapping around to the back if needed.
tailIndex -= 1
if tailIndex < circularQueue.startIndex {
tailIndex = circularQueue.endIndex - 1
}
guard let popped = circularQueue[tailIndex] else {
return nil // There is no element to pop.
}
circularQueue[tailIndex] = nil
return popped
}
func makeIterator() -> IndexingIterator<[Element]> {
circularQueue
.compactMap { $0 } // Remove `nil` elements.
.makeIterator()
}
}
// MARK: - Codable
extension RingBuffer: Codable where Element: Codable {}

View File

@ -0,0 +1,145 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// A type that reads from and writes to an underlying storage container.
protocol Storage {
/// Reads and returns the data stored by this storage type.
/// - Returns: The data read from storage.
/// - Throws: An error if the read failed.
func read() throws -> Data
/// Writes the given data to this storage type.
/// - Throws: An error if the write failed.
func write(_ data: Data?) throws
}
/// Error types for `Storage` operations.
enum StorageError: Error {
case readError
case writeError
}
// MARK: - FileStorage
/// A object that provides API for reading and writing to a file system resource.
final class FileStorage: Storage {
/// A file system URL to the underlying file resource.
private let url: URL
/// The file manager used to perform file system operations.
private let fileManager: FileManager
/// Designated initializer.
/// - Parameters:
/// - url: A file system URL for the underlying file resource.
/// - fileManager: A file manager. Defaults to `default` manager.
init(url: URL, fileManager: FileManager = .default) {
self.url = url
self.fileManager = fileManager
}
/// Reads and returns the data from this object's associated file resource.
///
/// - Returns: The data stored on disk.
/// - Throws: An error if reading the contents of the file resource fails (i.e. file doesn't
/// exist).
func read() throws -> Data {
do {
return try Data(contentsOf: url)
} catch {
throw StorageError.readError
}
}
/// Writes the given data to this object's associated file resource.
///
/// When the given `data` is `nil`, this object's associated file resource is emptied.
///
/// - Parameter data: The `Data?` to write to this object's associated file resource.
func write(_ data: Data?) throws {
do {
try createDirectories(in: url.deletingLastPathComponent())
if let data {
try data.write(to: url, options: .atomic)
} else {
let emptyData = Data()
try emptyData.write(to: url, options: .atomic)
}
} catch {
throw StorageError.writeError
}
}
/// Creates all directories in the given file system URL.
///
/// If the directory for the given URL already exists, the error is ignored because the directory
/// has already been created.
///
/// - Parameter url: The URL to create directories in.
private func createDirectories(in url: URL) throws {
do {
try fileManager.createDirectory(
at: url,
withIntermediateDirectories: true
)
} catch CocoaError.fileWriteFileExists {
// Directory already exists.
} catch { throw error }
}
}
// MARK: - UserDefaultsStorage
/// A object that provides API for reading and writing to a user defaults resource.
final class UserDefaultsStorage: Storage {
/// The underlying defaults container.
private let defaults: UserDefaults
/// The key mapping to the object's associated resource in `defaults`.
private let key: String
/// Designated initializer.
/// - Parameters:
/// - defaults: The defaults container.
/// - key: The key mapping to the value stored in the defaults container.
init(defaults: UserDefaults, key: String) {
self.defaults = defaults
self.key = key
}
/// Reads and returns the data from this object's associated defaults resource.
///
/// - Returns: The data stored on disk.
/// - Throws: An error if no data has been stored to the defaults container.
func read() throws -> Data {
if let data = defaults.data(forKey: key) {
return data
} else {
throw StorageError.readError
}
}
/// Writes the given data to this object's associated defaults.
///
/// When the given `data` is `nil`, the associated default is removed.
///
/// - Parameter data: The `Data?` to write to this object's associated defaults.
func write(_ data: Data?) throws {
if let data {
defaults.set(data, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}

View File

@ -0,0 +1,66 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
private enum Constants {
/// The name of the file system directory where heartbeat data is stored.
static let heartbeatFileStorageDirectoryPath = "google-heartbeat-storage"
/// The name of the user defaults suite where heartbeat data is stored.
static let heartbeatUserDefaultsSuiteName = "com.google.heartbeat.storage"
}
/// A factory type for `Storage`.
protocol StorageFactory {
static func makeStorage(id: String) -> Storage
}
// MARK: - FileStorage + StorageFactory
extension FileStorage: StorageFactory {
static func makeStorage(id: String) -> Storage {
let rootDirectory = FileManager.default.applicationSupportDirectory
let heartbeatDirectoryPath = Constants.heartbeatFileStorageDirectoryPath
// Sanitize the `id` so the heartbeat file name does not include a ":".
let sanitizedID = id.replacingOccurrences(of: ":", with: "_")
let heartbeatFilePath = "heartbeats-\(sanitizedID)"
let storageURL = rootDirectory
.appendingPathComponent(heartbeatDirectoryPath, isDirectory: true)
.appendingPathComponent(heartbeatFilePath, isDirectory: false)
return FileStorage(url: storageURL)
}
}
extension FileManager {
var applicationSupportDirectory: URL {
urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
}
}
// MARK: - UserDefaultsStorage + StorageFactory
extension UserDefaultsStorage: StorageFactory {
static func makeStorage(id: String) -> Storage {
let suiteName = Constants.heartbeatUserDefaultsSuiteName
// It's safe to force unwrap the below defaults instance because the
// initializer only returns `nil` when the bundle id or `globalDomain`
// is passed in as the `suiteName`.
let defaults = UserDefaults(suiteName: suiteName)!
let key = "heartbeats-\(id)"
return UserDefaultsStorage(defaults: defaults, key: key)
}
}

View File

@ -0,0 +1,20 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// A structure used to weakly box reference types.
struct WeakContainer<Object: AnyObject> {
weak var object: Object?
}

View File

@ -0,0 +1,58 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// An object that provides API to log and flush heartbeats from a synchronized storage container.
@objc(FIRHeartbeatController)
@objcMembers
public class _ObjC_HeartbeatController: NSObject {
/// The underlying Swift object.
private let heartbeatController: HeartbeatController
/// Public initializer.
/// - Parameter id: The `id` to associate this controller's heartbeat storage with.
public init(id: String) {
heartbeatController = HeartbeatController(id: id)
}
/// Asynchronously logs a new heartbeat, if needed.
///
/// - Note: This API is thread-safe.
/// - Parameter agent: The string agent (i.e. Firebase User Agent) to associate the logged
/// heartbeat with.
public func log(_ agent: String) {
heartbeatController.log(agent)
}
/// Synchronously flushes heartbeats from storage into a heartbeats payload.
///
/// - Note: This API is thread-safe.
/// - Returns: A heartbeats payload for the flushed heartbeat(s).
public func flush() -> _ObjC_HeartbeatsPayload {
let heartbeatsPayload = heartbeatController.flush()
return _ObjC_HeartbeatsPayload(heartbeatsPayload)
}
/// Synchronously flushes the heartbeat for today.
///
/// If no heartbeat was logged today, the returned payload is empty.
///
/// - Note: This API is thread-safe.
/// - Returns: A heartbeats payload for the flushed heartbeat.
public func flushHeartbeatFromToday() -> _ObjC_HeartbeatsPayload {
let heartbeatsPayload = heartbeatController.flushHeartbeatFromToday()
return _ObjC_HeartbeatsPayload(heartbeatsPayload)
}
}

View File

@ -0,0 +1,40 @@
// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import Foundation
/// A model object representing a payload of heartbeat data intended for sending in network
/// requests.
@objc(FIRHeartbeatsPayload)
public class _ObjC_HeartbeatsPayload: NSObject, HTTPHeaderRepresentable {
/// The underlying Swift structure.
private let heartbeatsPayload: HeartbeatsPayload
/// Designated initializer.
/// - Parameter heartbeatsPayload: A native-Swift heartbeats payload.
public init(_ heartbeatsPayload: HeartbeatsPayload) {
self.heartbeatsPayload = heartbeatsPayload
}
/// Returns a processed payload string intended for use in a HTTP header.
/// - Returns: A string value from the heartbeats payload.
@objc public func headerValue() -> String {
heartbeatsPayload.headerValue()
}
/// A Boolean value indicating whether the payload is empty.
@objc public var isEmpty: Bool {
heartbeatsPayload.isEmpty
}
}

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array>
</array>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>