Skip to content

Commit 1005bbd

Browse files
Signpost instrumentation of reducers (#142)
* Signpost higher-order reducer. * cleanup * guard * clean up * wip * more * wip * wip * wip * clean up * clean up * wip * run * wip * wip * clean up * Update Makefile * No failures * cleanup * Update Makefile * already guarded Co-authored-by: Stephen Celis <[email protected]>
1 parent 1e98d10 commit 1005bbd

File tree

12 files changed

+253
-125
lines changed

12 files changed

+253
-125
lines changed

Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Counter.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> {
3030
return .none
3131
}
3232
}
33+
.signpost()
3334

3435
struct CounterView: View {
3536
let store: Store<CounterState, CounterAction>

Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Timers.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ let timersReducer = Reducer<TimersState, TimersAction, TimersEnvironment> {
4343
: Effect.cancel(id: TimerId())
4444
}
4545
}
46+
.signpost()
47+
.debug()
4648

4749
// MARK: - Timer feature view
4850

Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-Recursion.swift

Lines changed: 53 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -104,61 +104,61 @@ struct NestedView: View {
104104
}
105105
}
106106

107-
#if DEBUG
108-
extension NestedState {
109-
static let mock = NestedState(
110-
children: [
111-
NestedState(
112-
children: [
113-
NestedState(
114-
children: [],
115-
id: UUID(),
116-
description: ""
117-
),
118-
],
119-
id: UUID(),
120-
description: "Bar"
121-
),
122-
NestedState(
123-
children: [
124-
NestedState(
125-
children: [],
126-
id: UUID(),
127-
description: "Fizz"
128-
),
129-
NestedState(
130-
children: [],
131-
id: UUID(),
132-
description: "Buzz"
133-
),
134-
],
135-
id: UUID(),
136-
description: "Baz"
137-
),
138-
NestedState(
139-
children: [],
140-
id: UUID(),
141-
description: ""
142-
),
143-
],
144-
id: UUID(),
145-
description: "Foo"
146-
)
147-
}
148-
#endif
107+
extension NestedState {
108+
static let mock = NestedState(
109+
children: [
110+
NestedState(
111+
children: [
112+
NestedState(
113+
children: [],
114+
id: UUID(),
115+
description: ""
116+
),
117+
],
118+
id: UUID(),
119+
description: "Bar"
120+
),
121+
NestedState(
122+
children: [
123+
NestedState(
124+
children: [],
125+
id: UUID(),
126+
description: "Fizz"
127+
),
128+
NestedState(
129+
children: [],
130+
id: UUID(),
131+
description: "Buzz"
132+
),
133+
],
134+
id: UUID(),
135+
description: "Baz"
136+
),
137+
NestedState(
138+
children: [],
139+
id: UUID(),
140+
description: ""
141+
),
142+
],
143+
id: UUID(),
144+
description: "Foo"
145+
)
146+
}
149147

150-
struct NestedView_Previews: PreviewProvider {
151-
static var previews: some View {
152-
NavigationView {
153-
NestedView(
154-
store: Store(
155-
initialState: .mock,
156-
reducer: nestedReducer,
157-
environment: NestedEnvironment(
158-
uuid: UUID.init
148+
#if DEBUG
149+
struct NestedView_Previews: PreviewProvider {
150+
static var previews: some View {
151+
NavigationView {
152+
NestedView(
153+
store: Store(
154+
initialState: .mock,
155+
reducer: nestedReducer,
156+
environment: NestedEnvironment(
157+
uuid: UUID.init
158+
)
159159
)
160160
)
161-
)
161+
}
162162
}
163163
}
164-
}
164+
#endif

Examples/CaseStudies/SwiftUICaseStudies/04-HigherOrderReducers-ResuableOfflineDownloads/ReusableComponents-Download.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ let cityMapReducer = Reducer<CityMapState, CityMapAction, CityMapEnvironment> {
7777
downloadClient: $0.downloadClient,
7878
mainQueue: $0.mainQueue
7979
)
80-
})
80+
}
81+
)
82+
.signpost()
8183

8284
struct CityMapRowView: View {
8385
let store: Store<CityMapState, CityMapAction>

Examples/LocationManager/Common/AppCore.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public enum AppAction: Equatable {
5555
case localSearchResponse(Result<LocalSearchResponse, LocalSearchClient.Error>)
5656
case locationManager(LocationManager.Action)
5757
case onAppear
58+
case onDisappear
5859
case updateRegion(CoordinateRegion?)
5960
}
6061

@@ -157,6 +158,10 @@ public let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, ac
157158
return environment.locationManager.create(id: LocationManagerId())
158159
.map(AppAction.locationManager)
159160

161+
case .onDisappear:
162+
return environment.locationManager.destroy(id: LocationManagerId())
163+
.fireAndForget()
164+
160165
case let .updateRegion(region):
161166
state.region = region
162167

@@ -180,6 +185,7 @@ public let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, ac
180185
locationManagerReducer
181186
.pullback(state: \.self, action: /AppAction.locationManager, environment: { $0 })
182187
)
188+
.signpost()
183189
.debug()
184190

185191
private let locationManagerReducer = Reducer<AppState, LocationManager.Action, AppEnvironment>

Examples/LocationManager/Mobile/LocationManagerView.swift

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ struct LocationManagerView: View {
6464
Alert(title: Text(alert.title))
6565
}
6666
.onAppear { viewStore.send(.onAppear) }
67+
.onDisappear { viewStore.send(.onDisappear) }
6768
}
6869
}
6970
}
@@ -95,39 +96,41 @@ struct ContentView: View {
9596
}
9697
}
9798

98-
struct ContentView_Previews: PreviewProvider {
99-
static var previews: some View {
100-
// NB: CLLocationManager mostly does not work in SwiftUI previews, so we provide a mock
101-
// manager that has all authorization allowed and mocks the device's current location
102-
// to Brooklyn, NY.
103-
let mockLocation = Location(
104-
coordinate: CLLocationCoordinate2D(latitude: 40.6501, longitude: -73.94958)
105-
)
106-
let locationManagerSubject = PassthroughSubject<LocationManager.Action, Never>()
107-
let locationManager = LocationManager.mock(
108-
authorizationStatus: { .authorizedAlways },
109-
create: { _ in locationManagerSubject.eraseToEffect() },
110-
locationServicesEnabled: { true },
111-
requestLocation: { _ in
112-
.fireAndForget { locationManagerSubject.send(.didUpdateLocations([mockLocation])) }
113-
})
99+
#if DEBUG
100+
struct ContentView_Previews: PreviewProvider {
101+
static var previews: some View {
102+
// NB: CLLocationManager mostly does not work in SwiftUI previews, so we provide a mock
103+
// manager that has all authorization allowed and mocks the device's current location
104+
// to Brooklyn, NY.
105+
let mockLocation = Location(
106+
coordinate: CLLocationCoordinate2D(latitude: 40.6501, longitude: -73.94958)
107+
)
108+
let locationManagerSubject = PassthroughSubject<LocationManager.Action, Never>()
109+
let locationManager = LocationManager.mock(
110+
authorizationStatus: { .authorizedAlways },
111+
create: { _ in locationManagerSubject.eraseToEffect() },
112+
locationServicesEnabled: { true },
113+
requestLocation: { _ in
114+
.fireAndForget { locationManagerSubject.send(.didUpdateLocations([mockLocation])) }
115+
})
114116

115-
let appView = LocationManagerView(
116-
store: Store(
117-
initialState: AppState(),
118-
reducer: appReducer,
119-
environment: AppEnvironment(
120-
localSearch: .live,
121-
locationManager: locationManager
117+
let appView = LocationManagerView(
118+
store: Store(
119+
initialState: AppState(),
120+
reducer: appReducer,
121+
environment: AppEnvironment(
122+
localSearch: .live,
123+
locationManager: locationManager
124+
)
122125
)
123126
)
124-
)
125127

126-
return Group {
127-
ContentView()
128-
appView
129-
appView
130-
.environment(\.colorScheme, .dark)
128+
return Group {
129+
ContentView()
130+
appView
131+
appView
132+
.environment(\.colorScheme, .dark)
133+
}
131134
}
132135
}
133-
}
136+
#endif

Examples/SpeechRecognition/SpeechRecognition/SpeechClient/Live.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ extension SpeechClient {
1313
finishTask: { id in
1414
.fireAndForget {
1515
dependencies[id]?.finish()
16+
dependencies[id]?.subscriber.send(completion: .finished)
1617
dependencies[id] = nil
1718
}
1819
},
@@ -58,7 +59,8 @@ extension SpeechClient {
5859
inputNode: inputNode,
5960
recognitionTask: recognitionTask,
6061
speechRecognizer: speechRecognizer,
61-
speechRecognizerDelegate: speechRecognizerDelegate
62+
speechRecognizerDelegate: speechRecognizerDelegate,
63+
subscriber: subscriber
6264
)
6365

6466
inputNode.installTap(
@@ -79,6 +81,7 @@ extension SpeechClient {
7981

8082
return cancellable
8183
}
84+
.cancellable(id: id)
8285
},
8386
requestAuthorization: {
8487
.future { callback in
@@ -96,6 +99,7 @@ private struct SpeechDependencies {
9699
let recognitionTask: SFSpeechRecognitionTask
97100
let speechRecognizer: SFSpeechRecognizer
98101
let speechRecognizerDelegate: SpeechRecognizerDelegate
102+
let subscriber: Effect<SpeechClient.Action, SpeechClient.Error>.Subscriber
99103

100104
func finish() {
101105
self.audioEngine.stop()
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import Combine
2+
import os.signpost
3+
4+
extension Reducer {
5+
/// Instruments the reducer with
6+
/// [signposts](https://developer.apple.com/documentation/os/logging/recording_performance_data).
7+
/// Each invocation of the reducer will be measured by an interval, and the lifecycle of its
8+
/// effects will be measured with interval and event signposts.
9+
///
10+
/// To use, build your app for Instruments (⌘I), create a blank instrument, and then use the "+"
11+
/// icon at top right to add the signpost instrument. Start recording your app (red button at top
12+
/// left) and then you should see timing information for every action sent to the store and every
13+
/// effect executed.
14+
///
15+
/// Effect instrumentation can be particularly useful for inspecting the lifecycle of long-living
16+
/// effects. For example, if you start an effect (e.g. a location manager) in `onAppear` and
17+
/// forget to tear down the effect in `onDisappear`, it will clearly show in Instruments that the
18+
/// effect never completed.
19+
///
20+
/// - Parameters:
21+
/// - prefix: A string to print at the beginning of the formatted message for the signpost.
22+
/// - log: An `OSLog` to use for signposts.
23+
/// - Returns: A reducer that has been enhanced with instrumentation.
24+
public func signpost(
25+
_ prefix: String = "",
26+
log: OSLog = OSLog(
27+
subsystem: "co.pointfree.composable-architecture",
28+
category: "Reducer Instrumentation"
29+
)
30+
) -> Self {
31+
guard log.signpostsEnabled else { return self }
32+
33+
// NB: Prevent rendering as "N/A" in Instruments
34+
let zeroWidthSpace = "\u{200B}"
35+
36+
let prefix = prefix.isEmpty ? zeroWidthSpace : "[\(prefix)] "
37+
38+
return Self { state, action, environment in
39+
if log.signpostsEnabled {
40+
os_signpost(.begin, log: log, name: "Action", "%s%s", prefix, debugCaseOutput(action))
41+
}
42+
let effects = self.run(&state, action, environment)
43+
if log.signpostsEnabled {
44+
os_signpost(.end, log: log, name: "Action")
45+
}
46+
return
47+
effects
48+
.effectSignpost(prefix, log: log, action: action)
49+
.eraseToEffect()
50+
}
51+
}
52+
}
53+
54+
extension Publisher where Failure == Never {
55+
func effectSignpost(
56+
_ prefix: String,
57+
log: OSLog,
58+
action: Output
59+
) -> Publishers.HandleEvents<Self> {
60+
guard log.signpostsEnabled else { return self.handleEvents() }
61+
let actionOutput = debugCaseOutput(action)
62+
let sid = OSSignpostID(log: log)
63+
64+
return
65+
self
66+
.handleEvents(
67+
receiveSubscription: { _ in
68+
os_signpost(
69+
.begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix,
70+
actionOutput)
71+
},
72+
receiveOutput: { value in
73+
os_signpost(
74+
.event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput)
75+
},
76+
receiveCompletion: { completion in
77+
switch completion {
78+
case .finished:
79+
os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix)
80+
}
81+
},
82+
receiveCancel: {
83+
os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sCancelled", prefix)
84+
})
85+
}
86+
}
87+
88+
private func debugCaseOutput(_ value: Any) -> String {
89+
let mirror = Mirror(reflecting: value)
90+
switch mirror.displayStyle {
91+
case .enum:
92+
guard let child = mirror.children.first else {
93+
let childOutput = "\(value)"
94+
return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)"
95+
}
96+
let childOutput = debugCaseOutput(child.value)
97+
return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")"
98+
case .tuple:
99+
return mirror.children.map { label, value in
100+
let childOutput = debugCaseOutput(value)
101+
return "\(label.map { "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")"
102+
}
103+
.joined(separator: ", ")
104+
default:
105+
return ""
106+
}
107+
}

0 commit comments

Comments
 (0)