Skip to content

Commit

Permalink
Add result builders to MultiLogger and MultiTrackers 👷 (#265)
Browse files Browse the repository at this point in the history
Some of our "multiplexer" instances for logging (`Log.MultiLogger`),
analytics (`Analytics.MultiTracker`) and performance metrics
(`PerformanceMetrics.MultiTracker`) require type erasure on the child
elements to which they forward events, causing the setup to be
cumbersome and not intuitive.

By leveraging result builders we can make this a bit smoother to users
by abstracting/automating the type erasure "dance". Furthermore, it
unlocks control flow (e.g. `if/else`, `for ... in`, `if #available`)
which allows more advanced setups to be defined directly in the builder.

## Changes

- Add result builders to facilitate instantiating certain "multiplexer"
instances, most notably the ones that require type erasure:

  + `Log.MultiLogger` (requires erasing to `AnyMetadataLogDestination`)

  + `Analytics.MultiTracker` (requires erasing to `AnyAnalyticsTracker`)

  + `PerformanceMetrics.MultiTracker` (doesn't require erasing)

- Add relevant UTs.
  • Loading branch information
p4checo authored Apr 25, 2024
1 parent 40b1f63 commit 3f11627
Show file tree
Hide file tree
Showing 10 changed files with 398 additions and 7 deletions.
8 changes: 4 additions & 4 deletions Alicerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
0A266F0F1ED33B65009CD0D7 /* CAGradientLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A266F0E1ED33B65009CD0D7 /* CAGradientLayer.swift */; };
0A266F201ED374F5009CD0D7 /* AssertDumpsEqual.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A266F1F1ED374F5009CD0D7 /* AssertDumpsEqual.swift */; };
0A266F861ED59DC7009CD0D7 /* Alicerce.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0A3C2D711EA7E3E800EFB7D4 /* Alicerce.framework */; };
0A266F8C1ED59FB6009CD0D7 /* MultiTrackerTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B57E9871EB15F510027AB30 /* MultiTrackerTestCase.swift */; };
0A266F8C1ED59FB6009CD0D7 /* Analytics+MultiTrackerTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B57E9871EB15F510027AB30 /* Analytics+MultiTrackerTestCase.swift */; };
0A266F901ED59FB6009CD0D7 /* Route+ComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3C2D0A1EA7E1EE00EFB7D4 /* Route+ComponentTests.swift */; };
0A266F911ED59FB6009CD0D7 /* Route+TrieNode_AddTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3C2D0B1EA7E1EE00EFB7D4 /* Route+TrieNode_AddTests.swift */; };
0A266F921ED59FB6009CD0D7 /* Route+TrieNode_InitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3C2D0C1EA7E1EE00EFB7D4 /* Route+TrieNode_InitTests.swift */; };
Expand Down Expand Up @@ -615,7 +615,7 @@
1B4D4CB61F05016B00FA4260 /* URLRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLRequest.swift; sourceTree = "<group>"; };
1B57E97C1EB150C80027AB30 /* Analytics+MultiTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Analytics+MultiTracker.swift"; sourceTree = "<group>"; };
1B57E97E1EB1510D0027AB30 /* AnalyticsTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnalyticsTracker.swift; sourceTree = "<group>"; };
1B57E9871EB15F510027AB30 /* MultiTrackerTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultiTrackerTestCase.swift; sourceTree = "<group>"; };
1B57E9871EB15F510027AB30 /* Analytics+MultiTrackerTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Analytics+MultiTrackerTestCase.swift"; sourceTree = "<group>"; };
1B57E9891EB1606F0027AB30 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
1B667A0920127C1600A8CD5A /* StackOrchestrator+Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StackOrchestrator+Store.swift"; sourceTree = "<group>"; };
1B667A0B20127C7000A8CD5A /* StackOrchestratorPerformanceMetricsTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackOrchestratorPerformanceMetricsTracker.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1435,7 +1435,7 @@
1B57E9861EB15F3C0027AB30 /* Analytics */ = {
isa = PBXGroup;
children = (
1B57E9871EB15F510027AB30 /* MultiTrackerTestCase.swift */,
1B57E9871EB15F510027AB30 /* Analytics+MultiTrackerTestCase.swift */,
0A708F6C20E99D9A001784DA /* MockAnalyticsTracker.swift */,
);
path = Analytics;
Expand Down Expand Up @@ -1826,7 +1826,7 @@
4838FE5723A951E6007311F0 /* TopConstrainableProxyTestCase.swift in Sources */,
0A708F6E20E99D9F001784DA /* MockAnalyticsTracker.swift in Sources */,
3E8D61952546F90400C08EA2 /* ConstraintGroupToggleTestCase.swift in Sources */,
0A266F8C1ED59FB6009CD0D7 /* MultiTrackerTestCase.swift in Sources */,
0A266F8C1ED59FB6009CD0D7 /* Analytics+MultiTrackerTestCase.swift in Sources */,
0A85F0E720B3177E0095AFFB /* PublicKeyAlgorithmTestCase.swift in Sources */,
0A266F901ED59FB6009CD0D7 /* Route+ComponentTests.swift in Sources */,
0A266F911ED59FB6009CD0D7 /* Route+TrieNode_AddTests.swift in Sources */,
Expand Down
49 changes: 49 additions & 0 deletions Sources/Analytics/Trackers/Analytics+MultiTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ public extension Analytics {
self.trackers = trackers
}

/// Creates an analytics multi tracker instance.
/// - Parameter trackers: The result builder that outputs the analytics trackers to register.
public init(@TrackerBuilder trackers: () -> [AnyAnalyticsTracker<State, Action, ParameterKey>]) {

self.trackers = trackers()

assert(!self.trackers.isEmpty, "🙅‍♂️ Trackers shouldn't be empty, since it renders this tracker useless!")
}

// MARK: - Tracking

/// Tracks an analytics event, by propagating it to all the registered sub trackers.
Expand All @@ -33,3 +42,43 @@ public extension Analytics {
}
}
}

extension Analytics.MultiTracker {

@resultBuilder
public struct TrackerBuilder {

public typealias AnyAnalyticsTracker = Analytics.AnyAnalyticsTracker<State, Action, ParameterKey>

public static func buildExpression<Tracker: AnalyticsTracker>(_ tracker: Tracker) -> [AnyAnalyticsTracker]
where Tracker.State == State, Tracker.Action == Action, Tracker.ParameterKey == ParameterKey {

[tracker.eraseToAnyAnalyticsTracker()]
}

public static func buildExpression(_ tracker: AnyAnalyticsTracker) -> [AnyAnalyticsTracker] { [tracker] }

public static func buildExpression(_ trackers: [AnyAnalyticsTracker]) -> [AnyAnalyticsTracker] { trackers }

public static func buildBlock(_ trackers: [AnyAnalyticsTracker]...) -> [AnyAnalyticsTracker] {

trackers.flatMap { $0 }
}

public static func buildOptional(_ tracker: [AnyAnalyticsTracker]?) -> [AnyAnalyticsTracker] { tracker ?? [] }

public static func buildEither(first tracker: [AnyAnalyticsTracker]) -> [AnyAnalyticsTracker] { tracker }

public static func buildEither(second tracker: [AnyAnalyticsTracker]) -> [AnyAnalyticsTracker] { tracker }

public static func buildLimitedAvailability(_ tracker: [AnyAnalyticsTracker]) -> [AnyAnalyticsTracker] {

tracker
}

public static func buildArray(_ trackers: [[AnyAnalyticsTracker]]) -> [AnyAnalyticsTracker] {

trackers.flatMap { $0 }
}
}
}
79 changes: 79 additions & 0 deletions Sources/Logging/Loggers/Log+MultiLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,41 @@ extension Log {
/// The logger's log destination error callback closure.
private let onError: LogDestinationErrorClosure?

/// Creates a new multi logger instance, with the specified log destinations and modules.
///
/// - Note:
/// Module filtering works as follows:
///
/// A log message having a module parameter will only be logged _if the module is registered_ in the logger, and
/// the log message's level is *above* the module's registered minimum log level. On the other hand, if the
/// message is logged without module (i.e. using the `Logger`'s `log` API, i.e. *without* `module` parameter),
/// no module filtering will be made.
///
/// - Parameters:
/// - modules: The log modules and respective minimum log level to be registered. Used when the
/// `ModuleLogger` APIs are used (i.e. with `module` parameter).
/// - onError: The logger's log destination error callback closure.
/// - destinations: The result builder which outputs log destinations to forward logging events to.
public init(
modules: [Module: Log.Level] = [:],
onError: LogDestinationErrorClosure? = nil,
@DestinationBuilder destinations: () -> [AnyMetadataLogDestination<MetadataKey>]
) {

self.modules = modules

self.onError = onError ?? { destination, error in
Log.internalLogger.error("💥 LogDestination '\(destination)' failed operation with error: \(error)")
}

self.destinations = destinations()

assert(
!self.destinations.isEmpty,
"🙅‍♂️ Destinations shouldn't be empty, since it renders this logger useless!"
)
}

/// Creates a new multi logger instance, with the specified log destinations and modules.
///
/// - Note:
Expand Down Expand Up @@ -209,3 +244,47 @@ extension Log {
}
}
}

extension Log.MultiLogger {

@resultBuilder
public struct DestinationBuilder {

public typealias AnyLogDestination = AnyMetadataLogDestination<MetadataKey>

public static func buildExpression<Destination: MetadataLogDestination>(
_ destination: Destination
) -> [AnyLogDestination] where Destination.MetadataKey == MetadataKey {

[destination.eraseToAnyMetadataLogDestination()]
}

public static func buildExpression(_ destinations: AnyLogDestination) -> [AnyLogDestination] { [destinations] }

public static func buildExpression(_ destinations: [AnyLogDestination]) -> [AnyLogDestination] { destinations }

public static func buildBlock(_ destinations: [AnyLogDestination]...) -> [AnyLogDestination] {

destinations.flatMap { $0 }
}

public static func buildOptional(_ destinations: [AnyLogDestination]?) -> [AnyLogDestination] {

destinations ?? []
}

public static func buildEither(first destination: [AnyLogDestination]) -> [AnyLogDestination] { destination }

public static func buildEither(second destination: [AnyLogDestination]) -> [AnyLogDestination] { destination }

public static func buildLimitedAvailability(_ destination: [AnyLogDestination]) -> [AnyLogDestination] {

destination
}

public static func buildArray(_ destinations: [[AnyLogDestination]]) -> [AnyLogDestination] {

destinations.flatMap { $0 }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ public extension PerformanceMetrics {
/// The tracker's token dictionary, containing the mapping between internal and sub trackers' tokens.
private let tokens = Atomic<[Token<Tag> : [Token<Tag>]]>([:])

/// Creates a new performance metrics multi trcker instance, with the specified sub trackers.
///
/// - Parameters:
/// -trackers: The result builder to output thje sub trackers to forward performance measuring events to.
public init(@TrackerBuilder trackers: () -> [PerformanceMetricsTracker]) {

self.trackers = trackers()

assert(
self.trackers.isEmpty == false,
"🙅‍♂️ Trackers shouldn't be empty, since it renders this tracker useless!"
)
}

/// Creates a new performance metrics multi trcker instance, with the specified sub trackers.
///
/// - Parameters:
Expand Down Expand Up @@ -144,3 +158,52 @@ public extension PerformanceMetrics {

}
}

extension PerformanceMetrics.MultiTracker {

@resultBuilder
public struct TrackerBuilder {

public static func buildExpression(_ tracker: PerformanceMetricsTracker) -> [PerformanceMetricsTracker] {

[tracker]
}

public static func buildExpression(_ trackers: [PerformanceMetricsTracker]) -> [PerformanceMetricsTracker] {

trackers
}

public static func buildBlock(_ trackers: [PerformanceMetricsTracker]...) -> [PerformanceMetricsTracker] {

trackers.flatMap { $0 }
}

public static func buildOptional(_ tracker: [PerformanceMetricsTracker]?) -> [PerformanceMetricsTracker] {

tracker ?? []
}

public static func buildEither(first tracker: [PerformanceMetricsTracker]) -> [PerformanceMetricsTracker] {

tracker
}

public static func buildEither(second tracker: [PerformanceMetricsTracker]) -> [PerformanceMetricsTracker] {

tracker
}

public static func buildLimitedAvailability(
_ tracker: [PerformanceMetricsTracker]
) -> [PerformanceMetricsTracker] {

tracker
}

public static func buildArray(_ trackers: [[PerformanceMetricsTracker]]) -> [PerformanceMetricsTracker] {

trackers.flatMap { $0 }
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import XCTest
@testable import Alicerce

final class MultiTrackerTestCase: XCTestCase {
final class Analytics_MultiTrackerTestCase: XCTestCase {

enum MockState {
case screen(name: String)
Expand All @@ -22,6 +22,72 @@ final class MultiTrackerTestCase: XCTestCase {
typealias MultiTracker = Analytics.MultiTracker<MockState, MockAction, MockParameterKey>
typealias MockSubTracker = MockAnalyticsTracker<MockState, MockAction, MockParameterKey>

// init

func testInit_WithResultBuilder_ShouldInstantiateCorrectTrackers() {

let subTracker1 = MockSubTracker()
let subTracker2 = MockSubTracker()
let subTracker3 = MockSubTracker()
let subTracker4 = MockSubTracker()
let subTrackerOpt = MockSubTracker()
let subTrackerTrue = MockSubTracker()
let subTrackerFalse = MockSubTracker()
let subTrackerArray = (1...3).map { _ in MockSubTracker() }
let subTrackerAvailable = MockSubTracker()

let optVar: Bool? = true
let optNil: Bool? = nil
let trueVar = true
let falseVar = false

let tracker = MultiTracker {
subTracker1
subTracker2

subTracker3.eraseToAnyAnalyticsTracker()

[subTracker4].map { $0.eraseToAnyAnalyticsTracker() }

if let _ = optVar { subTrackerOpt }
if let _ = optNil { subTrackerOpt }

if trueVar {
subTrackerTrue
} else {
subTrackerFalse
}

if falseVar {
subTrackerTrue
} else {
subTrackerFalse
}

for tracker in subTrackerArray { tracker }

if #available(iOS 1.337, *) { subTrackerAvailable }
}

XCTAssertDumpsEqual(
tracker.trackers,
(
[
subTracker1,
subTracker2,
subTracker3,
subTracker4,
subTrackerOpt,
subTrackerTrue,
subTrackerFalse
]
+ subTrackerArray
+ [subTrackerAvailable]
)
.map { $0.eraseToAnyAnalyticsTracker() }
)
}

// track

func testTrack_WithActionEvent_ShouldCallTrackOnAlltrackers() {
Expand Down
4 changes: 3 additions & 1 deletion Tests/AlicerceTests/Analytics/MockAnalyticsTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ final class MockAnalyticsTracker<S, A, PK: AnalyticsParameterKey>: AnalyticsTrac

var trackInvokedClosure: ((Event) -> Void)?

init() {}
let id: UUID

init(id: UUID = .init()) { self.id = id }

func track(_ event: Event) { trackInvokedClosure?(event) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ class MockMetadataLogDestination<Module: LogModule, MetadataKey: Hashable>: Meta

var minLevel: Log.Level { mockMinLevel }

let id: UUID

// MARK: - Lifecycle

public init(mockMinLevel: Log.Level = .verbose) {
public init(id: UUID = .init(), mockMinLevel: Log.Level = .verbose) {

self.id = id
self.mockMinLevel = mockMinLevel
}

Expand Down
Loading

0 comments on commit 3f11627

Please sign in to comment.