Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add full data-race safety and enable strict concurrency checking for Swift 6 compatibility #174

Merged
merged 11 commits into from
Aug 12, 2024
Merged
14 changes: 14 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# EditorConfig is awesome: https://editorconfig.org
root = true

[*]

indent_style = space
tab_width = 8
indent_size = 4

end_of_line = lf
insert_final_newline = true

max_line_length = 160
trim_trailing_whitespace = true
11 changes: 7 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.9
// swift-tools-version: 5.9
import PackageDescription

let package = Package(
Expand All @@ -19,15 +19,18 @@ let package = Package(
.target(
name: "TelemetryDeck",
dependencies: ["TelemetryClient"],
resources: [.copy("PrivacyInfo.xcprivacy")]
resources: [.copy("PrivacyInfo.xcprivacy")],
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
),
.target(
name: "TelemetryClient",
resources: [.copy("PrivacyInfo.xcprivacy")]
resources: [.copy("PrivacyInfo.xcprivacy")],
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
),
.testTarget(
name: "TelemetryClientTests",
dependencies: ["TelemetryClient"]
dependencies: ["TelemetryClient"],
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")]
)
]
)
22 changes: 11 additions & 11 deletions Sources/TelemetryClient/JSONFormatting.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import Foundation

extension Formatter {
static let iso8601: ISO8601DateFormatter = {
static var iso8601: ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
}

static let iso8601noFS = ISO8601DateFormatter()
static var iso8601noFS: ISO8601DateFormatter { ISO8601DateFormatter() }

static let iso8601dateOnly: ISO8601DateFormatter = {
static var iso8601dateOnly: ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withFullDate]
return formatter
}()
}
}

extension JSONDecoder.DateDecodingStrategy {
Expand All @@ -28,33 +28,33 @@ extension JSONDecoder.DateDecodingStrategy {
}

extension JSONDecoder {
static var telemetryDecoder: JSONDecoder = {
static var telemetryDecoder: JSONDecoder {
let decoder = JSONDecoder()
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
dateFormatter.locale = Locale(identifier: "en_US")
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
decoder.dateDecodingStrategy = .formatted(dateFormatter)
return decoder
}()
}

static var druidDecoder: JSONDecoder = {
static var druidDecoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .customISO8601
return decoder
}()
}
}

extension JSONEncoder {
static var telemetryEncoder: JSONEncoder = {
static var telemetryEncoder: JSONEncoder {
let encoder = JSONEncoder()
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
dateFormatter.locale = Locale(identifier: "en_US")
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
encoder.dateEncodingStrategy = .formatted(dateFormatter)
return encoder
}()
}
}

extension Data {
Expand Down
10 changes: 5 additions & 5 deletions Sources/TelemetryClient/LogHandler.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

public struct LogHandler {
public enum LogLevel: Int, CustomStringConvertible {
public struct LogHandler: Sendable {
public enum LogLevel: Int, CustomStringConvertible, Sendable {
case debug = 0
case info = 1
case error = 2
Expand All @@ -19,9 +19,9 @@ public struct LogHandler {
}

let logLevel: LogLevel
let handler: (LogLevel, String) -> Void
let handler: @Sendable (LogLevel, String) -> Void

public init(logLevel: LogHandler.LogLevel, handler: @escaping (LogHandler.LogLevel, String) -> Void) {
public init(logLevel: LogHandler.LogLevel, handler: @escaping @Sendable (LogHandler.LogLevel, String) -> Void) {
self.logLevel = logLevel
self.handler = handler
}
Expand All @@ -32,7 +32,7 @@ public struct LogHandler {
}
}

public static var stdout = { logLevel in
public static func stdout(_ logLevel: LogLevel) -> LogHandler {
LogHandler(logLevel: logLevel) { level, message in
print("[TelemetryDeck: \(level.description)] \(message)")
}
Expand Down
1 change: 1 addition & 0 deletions Sources/TelemetryClient/Presets/NavigationStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation

/// This internal singleton keeps track of the last used navigation path so
/// that the ``TelemetryDeck.navigationPathChanged(to:customUserID:)`` function has a `from` source to work off of.
@MainActor
class NavigationStatus {
static let shared = NavigationStatus()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ extension TelemetryDeck {
/// - from: The navigation path at the beginning of the navigation event, identifying the view the user is leaving
/// - to: The navigation path at the end of the navigation event, identifying the view the user is arriving at
/// - customUserID: An optional string specifying a custom user identifier. If provided, it will override the default user identifier from the configuration. Default is `nil`.
@MainActor
public static func navigationPathChanged(from source: String, to destination: String, customUserID: String? = nil) {
NavigationStatus.shared.previousNavigationPath = destination

Expand Down Expand Up @@ -57,17 +58,20 @@ extension TelemetryDeck {
/// - Parameters:
/// - to: The navigation path representing the view the user is arriving at.
/// - customUserID: An optional string specifying a custom user identifier. If provided, it will override the default user identifier from the configuration. Default is `nil`.
@MainActor
public static func navigationPathChanged(to destination: String, customUserID: String? = nil) {
let source = NavigationStatus.shared.previousNavigationPath ?? ""

Self.navigationPathChanged(from: source, to: destination, customUserID: customUserID)
}

@MainActor
@available(*, unavailable, renamed: "navigationPathChanged(from:to:customUserID:)")
public static func navigate(from source: String, to destination: String, customUserID: String? = nil) {
self.navigationPathChanged(from: source, to: destination, customUserID: customUserID)
}

@MainActor
@available(*, unavailable, renamed: "navigationPathChanged(to:customUserID:)")
public static func navigate(to destination: String, customUserID: String? = nil) {
self.navigationPathChanged(to: destination, customUserID: customUserID)
Expand Down
4 changes: 4 additions & 0 deletions Sources/TelemetryClient/Signal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ internal struct SignalPostBody: Codable, Equatable {

/// The default payload that is included in payloads processed by TelemetryDeck.
public struct DefaultSignalPayload: Encodable {
@MainActor
public static var parameters: [String: String] {
var parameters: [String: String] = [
// deprecated names
Expand Down Expand Up @@ -338,6 +339,7 @@ extension DefaultSignalPayload {
}

/// The current devices screen resolution width in points.
@MainActor
static var screenResolutionWidth: String {
#if os(iOS) || os(tvOS)
return "\(UIScreen.main.bounds.width)"
Expand All @@ -354,6 +356,7 @@ extension DefaultSignalPayload {
}

/// The current devices screen resolution height in points.
@MainActor
static var screenResolutionHeight: String {
#if os(iOS) || os(tvOS)
return "\(UIScreen.main.bounds.height)"
Expand All @@ -370,6 +373,7 @@ extension DefaultSignalPayload {
}

/// The current devices screen orientation. Returns `Fixed` for devices that don't support an orientation change.
@MainActor
static var orientation: String {
#if os(iOS)
switch UIDevice.current.orientation {
Expand Down
4 changes: 2 additions & 2 deletions Sources/TelemetryClient/SignalCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import Foundation
/// correctly.
///
/// Currently the cache is only in-memory. This will probably change in the near future.
internal class SignalCache<T> where T: Codable {
internal class SignalCache<T>: @unchecked Sendable where T: Codable {
internal var logHandler: LogHandler?

private var cachedSignals: [T] = []
private let maximumNumberOfSignalsToPopAtOnce = 100

let queue = DispatchQueue(label: "telemetrydeck-signal-cache", attributes: .concurrent)
let queue = DispatchQueue(label: "com.telemetrydeck.SignalCache", attributes: .concurrent)

/// How many Signals are cached
func count() -> Int {
Expand Down
2 changes: 1 addition & 1 deletion Sources/TelemetryClient/SignalEnricher.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

public protocol SignalEnricher {
public protocol SignalEnricher: Sendable {
func enrich(
signalType: String,
for clientUser: String?,
Expand Down
58 changes: 33 additions & 25 deletions Sources/TelemetryClient/SignalManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ internal protocol SignalManageable {
func attemptToSendNextBatchOfCachedSignals()
}

internal class SignalManager: SignalManageable {
internal final class SignalManager: SignalManageable, @unchecked Sendable {
internal static let minimumSecondsToPassBetweenRequests: Double = 10

private var signalCache: SignalCache<SignalPostBody>
let configuration: TelemetryManagerConfiguration

private var sendTimer: Timer?

init(configuration: TelemetryManagerConfiguration) {
Expand Down Expand Up @@ -74,35 +75,41 @@ internal class SignalManager: SignalManageable {
customUserID: String?,
configuration: TelemetryManagerConfiguration
) {
DispatchQueue.global(qos: .utility).async {
let enrichedMetadata: [String: String] = configuration.metadataEnrichers
.map { $0.enrich(signalType: signalName, for: customUserID, floatValue: floatValue) }
.reduce([String: String](), { $0.applying($1) })

let payload = DefaultSignalPayload.parameters
.applying(enrichedMetadata)
.applying(parameters)

let signalPostBody = SignalPostBody(
receivedAt: Date(),
appID: UUID(uuidString: configuration.telemetryAppID)!,
clientUser: CryptoHashing.sha256(string: customUserID ?? self.defaultUserIdentifier, salt: configuration.salt),
sessionID: configuration.sessionID.uuidString,
type: "\(signalName)",
floatValue: floatValue,
payload: payload.toMultiValueDimension(),
isTestMode: configuration.testMode ? "true" : "false"
)

configuration.logHandler?.log(.debug, message: "Process signal: \(signalPostBody)")

self.signalCache.push(signalPostBody)
DispatchQueue.main.async {
let defaultUserIdentifier = self.defaultUserIdentifier
let defaultParameters = DefaultSignalPayload.parameters

DispatchQueue.global(qos: .utility).async {
let enrichedMetadata: [String: String] = configuration.metadataEnrichers
.map { $0.enrich(signalType: signalName, for: customUserID, floatValue: floatValue) }
.reduce([String: String](), { $0.applying($1) })

let payload = defaultParameters
.applying(enrichedMetadata)
.applying(parameters)

let signalPostBody = SignalPostBody(
receivedAt: Date(),
appID: UUID(uuidString: configuration.telemetryAppID)!,
clientUser: CryptoHashing.sha256(string: customUserID ?? defaultUserIdentifier, salt: configuration.salt),
sessionID: configuration.sessionID.uuidString,
type: "\(signalName)",
floatValue: floatValue,
payload: payload.toMultiValueDimension(),
isTestMode: configuration.testMode ? "true" : "false"
)

configuration.logHandler?.log(.debug, message: "Process signal: \(signalPostBody)")

self.signalCache.push(signalPostBody)
}
}
}

/// Sends one batch of signals from the cache if not empty.
/// If signals fail to send, we put them back into the cache to try again later.
@objc
@Sendable
internal func attemptToSendNextBatchOfCachedSignals() {
configuration.logHandler?.log(.debug, message: "Current signal cache count: \(signalCache.count())")

Expand Down Expand Up @@ -175,7 +182,7 @@ private extension SignalManager {
// MARK: - Comms

private extension SignalManager {
private func send(_ signalPostBodies: [SignalPostBody], completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) {
private func send(_ signalPostBodies: [SignalPostBody], completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) {
DispatchQueue.global(qos: .utility).async {
let path = "/api/v1/apps/\(self.configuration.telemetryAppID)/signals/multiple/"
let url = self.configuration.apiBaseURL.appendingPathComponent(path)
Expand Down Expand Up @@ -209,6 +216,7 @@ private extension SignalManager {
#endif

/// The default user identifier. If the platform supports it, the ``identifierForVendor``. Otherwise, a self-generated `UUID` which is persisted in custom `UserDefaults` if available.
@MainActor
var defaultUserIdentifier: String {
guard configuration.defaultUser == nil else { return configuration.defaultUser! }

Expand Down
Loading