Skip to content

Commit 949d2f3

Browse files
authored
Merge pull request #101 from unsignedapps/diagnostics
Added diagnostics for flag resolution and real-time updates
2 parents 5f54671 + 0240e53 commit 949d2f3

25 files changed

Lines changed: 554 additions & 62 deletions

.swiftlint.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ disabled_rules:
66
- type_name
77
- vertical_whitespace
88
- identifier_name
9+
- let_var_whitespace
910

1011
opt_in_rules:
1112
- anyobject_protocol
@@ -67,6 +68,10 @@ line_length:
6768
ignores_function_declarations: true
6869
warning: 180
6970

71+
file_length:
72+
warning: 800
73+
error: 1500
74+
7075
identifier_name:
7176
excluded: [id, x, y, z, r, g, b, a, i, j, k, cx, cy, dx, dy, _rootGroup]
7277

Sources/Vexil/Diagnostics.swift

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//
2+
// Diagnostics.swift
3+
// Vexil
4+
//
5+
// Created by Rob Amos on 12/12/21.
6+
//
7+
8+
import Foundation
9+
10+
/// A diagnostic that is returned by `FlagPole.makeDiagnostics()`
11+
///
12+
public enum FlagPoleDiagnostic: Equatable {
13+
14+
// MARK: - Cases
15+
16+
case currentValue(key: String, value: BoxedFlagValue, resolvedBy: String?)
17+
case changedValue(key: String, value: BoxedFlagValue, resolvedBy: String?, changedBy: String?)
18+
19+
}
20+
21+
22+
// MARK: - Initialisation
23+
24+
extension Array where Element == FlagPoleDiagnostic {
25+
26+
/// Creates diagnostic cases from an initial snapshot
27+
init<Root> (current: Snapshot<Root>) where Root: FlagContainer {
28+
self = current.values
29+
.sorted(by: { $0.key < $1.key })
30+
.compactMap { element -> FlagPoleDiagnostic? in
31+
guard let value = element.value.boxed else {
32+
return nil
33+
}
34+
return .currentValue(key: element.key, value: value, resolvedBy: element.value.source)
35+
}
36+
37+
}
38+
39+
/// Creates diagnostic cases from a changed snapshot
40+
init<Root> (changed: Snapshot<Root>, sources: [String]?) where Root: FlagContainer {
41+
let changedBy: String?
42+
if let sources = sources {
43+
changedBy = Set(sources).sorted().joined(separator: ", ")
44+
} else {
45+
changedBy = nil
46+
}
47+
48+
self = changed.values
49+
.sorted(by: { $0.key < $1.key })
50+
.compactMap { element -> FlagPoleDiagnostic? in
51+
guard let value = element.value.boxed else {
52+
return nil
53+
}
54+
return .changedValue(key: element.key, value: value, resolvedBy: element.value.source, changedBy: changedBy)
55+
}
56+
57+
}
58+
59+
}
60+
61+
62+
// MARK: - Debugging
63+
64+
extension FlagPoleDiagnostic: CustomDebugStringConvertible {
65+
66+
public var debugDescription: String {
67+
switch self {
68+
case let .currentValue(key: key, value: value, resolvedBy: source):
69+
return "Current value of flag '\(key)' is '\(String(describing: value))'. Resolved by: \(source ?? "Default value")"
70+
case let .changedValue(key: key, value: value, resolvedBy: source, changedBy: trigger):
71+
return "Value of flag '\(key)' was changed to '\(String(describing: value))' by '\(trigger ?? "an unknown source")'. Resolved by: \(source ?? "Default value")"
72+
}
73+
}
74+
75+
}
76+
77+
78+
// MARK: - Errors
79+
80+
public extension FlagPoleDiagnostic {
81+
82+
enum Error: LocalizedError {
83+
case notEnabledForSnapshot
84+
85+
public var errorDescription: String? {
86+
switch self {
87+
case .notEnabledForSnapshot:
88+
return "This snapshot was not taken with diagnostics enabled. Take it again using `FlagPole.snapshot(enableDiagnostics: true)`"
89+
}
90+
}
91+
}
92+
93+
}

Sources/Vexil/Flag.swift

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public struct Flag<Value>: Decorated, Identifiable where Value: FlagValue {
4242

4343
/// The `Flag` value. This is a calculated property based on the `FlagPole`s sources.
4444
public var wrappedValue: Value {
45-
return value(in: nil) ?? self.defaultValue
45+
return value(in: nil)?.value ?? self.defaultValue
4646
}
4747

4848
/// The string-based Key for this `Flag`, as calculated during `init`. This key is
@@ -149,17 +149,19 @@ public struct Flag<Value>: Decorated, Identifiable where Value: FlagValue {
149149

150150
// MARK: - Lookup Support
151151

152-
func value (in source: FlagValueSource?) -> Value? {
153-
guard let lookup = self.decorator.lookup, let key = self.decorator.key else { return self.defaultValue }
154-
let value: Value? = lookup.lookup(key: key, in: source)
152+
func value (in source: FlagValueSource?) -> LookupResult<Value>? {
153+
guard let lookup = self.decorator.lookup, let key = self.decorator.key else {
154+
return LookupResult(source: nil, value: self.defaultValue)
155+
}
156+
let value: LookupResult<Value>? = lookup.lookup(key: key, in: source)
155157

156158
// if we're looking up against a specific source we return only what we get from it
157159
if source != nil {
158160
return value
159161
}
160162

161163
// otherwise we're looking up on the FlagPole - which must always return a value so go back to our default
162-
return value ?? self.defaultValue
164+
return value ?? LookupResult(source: nil, value: self.defaultValue)
163165
}
164166

165167
}

Sources/Vexil/Lookup.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,19 @@ import Foundation
1818
/// Only `FlagPole` and `Snapshot`s conform to this.
1919
///
2020
internal protocol Lookup: AnyObject {
21-
func lookup<Value> (key: String, in source: FlagValueSource?) -> Value? where Value: FlagValue
21+
func lookup<Value> (key: String, in source: FlagValueSource?) -> LookupResult<Value>? where Value: FlagValue
2222

2323
#if !os(Linux)
2424
func publisher<Value> (key: String) -> AnyPublisher<Value, Never> where Value: FlagValue
2525
#endif
2626
}
2727

28+
/// A lightweight internal type used to support diagnostics by tagging the values with the source that resolved it
29+
struct LookupResult<Value> where Value: FlagValue {
30+
let source: String?
31+
let value: Value
32+
}
33+
2834
extension FlagPole: Lookup {
2935

3036
/// This is the primary lookup function in a `FlagPole`. When you access the `Flag.wrappedValue`
@@ -33,14 +39,15 @@ extension FlagPole: Lookup {
3339
/// It iterates through our `FlagValueSource`s and asks each if they have a `FlagValue` for
3440
/// that key, returning the first non-nil value it finds.
3541
///
36-
func lookup<Value> (key: String, in source: FlagValueSource?) -> Value? where Value: FlagValue {
42+
func lookup<Value> (key: String, in source: FlagValueSource?) -> LookupResult<Value>? where Value: FlagValue {
3743
if let source = source {
3844
return source.flagValue(key: key)
45+
.map { LookupResult(source: source.name, value: $0) }
3946
}
4047

4148
for source in self._sources {
4249
if let value: Value = source.flagValue(key: key) {
43-
return value
50+
return LookupResult(source: source.name, value: value)
4451
}
4552
}
4653
return nil

Sources/Vexil/Pole.swift

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,14 @@ public class FlagPole<RootGroup> where RootGroup: FlagContainer {
5959
#if !os(Linux)
6060

6161
if #available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) {
62-
self.setupSnapshotPublishing(keys: self.allFlagKeys, sendImmediately: true)
62+
let oldSourceNames = oldValue.map(\.name)
63+
let newSourceNames = _sources.map(\.name)
64+
65+
self.setupSnapshotPublishing(
66+
keys: self.allFlagKeys,
67+
sendImmediately: true,
68+
changedSources: oldSourceNames.difference(from: newSourceNames).map(\.element)
69+
)
6370
}
6471

6572
#endif
@@ -159,7 +166,7 @@ public class FlagPole<RootGroup> where RootGroup: FlagContainer {
159166

160167
// MARK: - Real Time Changes
161168

162-
#if !os(Linux)
169+
#if !os(Linux)
163170

164171
/// An internal state variable used so we don't setup the `Publisher` infrastructure
165172
/// until someone has accessed `self.publisher`
@@ -179,44 +186,106 @@ public class FlagPole<RootGroup> where RootGroup: FlagContainer {
179186
let snapshot = self.latestSnapshot
180187
if self.shouldSetupSnapshotPublishing == false {
181188
self.shouldSetupSnapshotPublishing = true
182-
self.setupSnapshotPublishing(keys: self.allFlagKeys, sendImmediately: true)
189+
self.setupSnapshotPublishing(keys: self.allFlagKeys, sendImmediately: false)
183190
}
184191
return snapshot.eraseToAnyPublisher()
185192
}
186193

187194
private lazy var cancellables = Set<AnyCancellable>()
188195

189-
private func setupSnapshotPublishing (keys: Set<String>, sendImmediately: Bool) {
196+
private func setupSnapshotPublishing (keys: Set<String>, sendImmediately: Bool, changedSources: [String]? = nil) {
190197
guard self.shouldSetupSnapshotPublishing else { return }
191198

192199
// cancel our existing one
193200
self.cancellables.forEach { $0.cancel() }
194201
self.cancellables.removeAll()
195202

196203
let upstream = self._sources
197-
.compactMap { source in
198-
source.valuesDidChange(keys: keys)
204+
.compactMap { source -> AnyPublisher<(String, Set<String>), Never>? in
205+
let maybePublisher = source.valuesDidChange(keys: keys)
199206
?? source.valuesDidChange?.map({ _ in [] }).eraseToAnyPublisher() // backwards compatibility
207+
208+
guard let publisher = maybePublisher else {
209+
return nil
210+
}
211+
212+
let name = source.name
213+
return publisher
214+
.map { (name, $0) }
215+
.eraseToAnyPublisher()
200216
}
201217

202218
Publishers.MergeMany(upstream)
203-
.sink { [weak self] keys in
219+
.sink { [weak self] source, keys in
204220
guard let self = self else { return }
205221

206222
let snapshot = Snapshot(flagPole: self, snapshot: self.latestSnapshot.value)
207-
let changed = Snapshot(flagPole: self, copyingFlagValuesFrom: .pole, keys: keys.isEmpty == true ? nil : keys)
223+
let changed = Snapshot(flagPole: self, copyingFlagValuesFrom: .pole, keys: keys.isEmpty == true ? nil : keys, diagnosticsEnabled: self._diagnosticsEnabled)
208224
snapshot.merge(changed)
209-
210225
self.latestSnapshot.send(snapshot)
226+
227+
if self._diagnosticsEnabled == true {
228+
self.diagnosticSubject.send(.init(changed: changed, sources: [source]))
229+
}
211230
}
212231
.store(in: &self.cancellables)
213232

214233
if sendImmediately {
215-
self.latestSnapshot.send(self.snapshot())
234+
let snapshot = self.snapshot()
235+
self.latestSnapshot.send(snapshot)
236+
if self._diagnosticsEnabled == true {
237+
self.diagnosticSubject.send(.init(changed: snapshot, sources: changedSources))
238+
}
239+
}
240+
}
241+
242+
#endif // !os(Linux)
243+
244+
// MARK: - Diagnostics
245+
246+
var _diagnosticsEnabled = false
247+
248+
/// Returns the current diagnostic state of all flags managed by this FlagPole.
249+
///
250+
/// This method is intended to be called from the debugger
251+
///
252+
public func makeDiagnostics () -> [FlagPoleDiagnostic] {
253+
return .init(current: self.snapshot(enableDiagnostics: true))
254+
}
255+
256+
#if !os(Linux)
257+
258+
private lazy var diagnosticSubject = PassthroughSubject<[FlagPoleDiagnostic], Never>()
259+
260+
/// A `Publisher` that can be used to monitor diagnostic outputs
261+
///
262+
/// An array of `Diagnostic` messages is emitted every time a flag value changes. It can be one of two types:
263+
///
264+
/// - The value of every flag on the `FlagPole` at the time of subscribing, and which `FlagValueSource` it was resolved by
265+
/// - An array of the flag values that were changed, which `FlagValueSource` they were changed by, and their resolved value/source
266+
///
267+
public func makeDiagnosticsPublisher () -> AnyPublisher<[FlagPoleDiagnostic], Never> {
268+
let wasAlreadyEnabled = _diagnosticsEnabled
269+
_diagnosticsEnabled = true
270+
271+
let snapshot = self.latestSnapshot.value
272+
273+
// if publishing hasn't been started yet (ie they've accessed `_diagnosticsPublisher` before `publisher`)
274+
if self.shouldSetupSnapshotPublishing == false {
275+
self.shouldSetupSnapshotPublishing = true
276+
self.setupSnapshotPublishing(keys: self.allFlagKeys, sendImmediately: false)
277+
278+
// if publishing has already been started, but diagnostics were not previously enabled, we setup again to make sure they are available
279+
} else if wasAlreadyEnabled == false {
280+
self.setupSnapshotPublishing(keys: self.allFlagKeys, sendImmediately: true)
216281
}
282+
283+
return diagnosticSubject
284+
.prepend(.init(current: snapshot))
285+
.eraseToAnyPublisher()
217286
}
218287

219-
#endif
288+
#endif // !os(Linux)
220289

221290

222291
// MARK: - Snapshots
@@ -229,10 +298,11 @@ public class FlagPole<RootGroup> where RootGroup: FlagContainer {
229298
/// or nil then the values of each `Flag` within the `FlagPole` is copied
230299
/// into the snapshot instead.
231300
///
232-
public func snapshot (of source: FlagValueSource? = nil) -> Snapshot<RootGroup> {
301+
public func snapshot (of source: FlagValueSource? = nil, enableDiagnostics: Bool = false) -> Snapshot<RootGroup> {
233302
return Snapshot (
234303
flagPole: self,
235-
copyingFlagValuesFrom: source.flatMap(Snapshot.Source.source) ?? .pole
304+
copyingFlagValuesFrom: source.flatMap(Snapshot.Source.source) ?? .pole,
305+
diagnosticsEnabled: enableDiagnostics || self._diagnosticsEnabled
236306
)
237307
}
238308

Sources/Vexil/Snapshots/AnyFlag.swift

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,16 @@
88
protocol AnyFlag {
99
var key: String { get }
1010

11-
func getFlagValue (in source: FlagValueSource?) -> Any?
11+
func getFlagValue (in source: FlagValueSource?, diagnosticsEnabled: Bool) -> LocatedFlagValue?
1212
func save (to source: FlagValueSource) throws
1313
}
1414

15-
protocol AnyFlagGroup {
16-
func allFlags () -> [AnyFlag]
17-
}
18-
1915
extension Flag: AnyFlag {
20-
func getFlagValue(in source: FlagValueSource?) -> Any? {
21-
return value(in: source)
16+
func getFlagValue (in source: FlagValueSource?, diagnosticsEnabled: Bool) -> LocatedFlagValue? {
17+
guard let result = value(in: source) else {
18+
return nil
19+
}
20+
return LocatedFlagValue(lookupResult: result, diagnosticsEnabled: diagnosticsEnabled)
2221
}
2322

2423
func save(to source: FlagValueSource) throws {
@@ -27,6 +26,12 @@ extension Flag: AnyFlag {
2726
}
2827

2928

29+
// MARK: - Flag Groups
30+
31+
protocol AnyFlagGroup {
32+
func allFlags () -> [AnyFlag]
33+
}
34+
3035
extension FlagGroup: AnyFlagGroup {
3136
func allFlags () -> [AnyFlag] {
3237
return Mirror(reflecting: self.wrappedValue)

0 commit comments

Comments
 (0)