Skip to content

Commit ed5717d

Browse files
feat: richer click analytics (event.id/screen_id/screen_name, ldClick) + instrumentation gating (#229)
## Summary Enhances click/tap analytics on iOS and makes invasive touch instrumentation honor the `instrumentation` config. - **`event.id` via `ldId`/`ldClick`**: explicit developer-supplied identifier takes precedence over derived platform ids (`UIView.ldId(_:)` for UIKit, `.ldClick(_:)` for SwiftUI, resolved through a non-consuming `LdClickRegistry`). - **`event.screen_id` + `event.screen_name`**: stamped on both OTel `click` spans and Session Replay click events; `screen_name` threaded from the `ScreenStack`. - **Manual `trackClick` API** mirroring the auto-captured click shape. - **`Instrumentation.enabled` / `.disabled`** convenience accessors. - **Touch-capture gating**: the `UIWindow.sendEvent` swizzle + hit-testing now install only when `instrumentation.userTaps` is enabled **or** Session Replay is recording (SR self-starts the shared, idempotent manager). With both off, no swizzle/hit-testing is installed. - Fix `UIWindowSwizzleSource` `isActive` idempotency. ## Test plan - [ ] `LaunchDarklyObservability` + `LaunchDarklySessionReplay` build (simulator) - [ ] Unit tests (`LdClickRegistryTests`, `ClickSpanTests`) pass - [ ] TestApp: taps emit `event.id` (ldId), `event.screen_id`, `event.screen_name` on spans and SR - [ ] `instrumentation: .disabled` with SR off ⇒ no swizzle/hit-testing Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 6c175a7 commit ed5717d

31 files changed

Lines changed: 1077 additions & 54 deletions

Sources/LaunchDarklyObservability/API/LDObserve.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,8 @@ extension LDObserve: Observe {
7575
public func trackScreenView(name: String, screenClass: String?, screenId: String?, category: String?, properties: [String: Any]?) {
7676
client.trackScreenView(name: name, screenClass: screenClass, screenId: screenId, category: category, properties: properties)
7777
}
78+
79+
public func trackClick(id: String?, tag: String?, text: String?, screenId: String?, x: Int?, y: Int?, properties: [String: Any]?) {
80+
client.trackClick(id: id, tag: tag, text: text, screenId: screenId, x: x, y: y, properties: properties)
81+
}
7882
}

Sources/LaunchDarklyObservability/API/ObservabilityOptions.swift

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -159,27 +159,39 @@ public struct ObservabilityOptions {
159159
let memory: FeatureFlag
160160
let memoryWarnings: FeatureFlag
161161
let cpu: FeatureFlag
162-
/// Whether to emit legacy launch-time performance metrics. This currently only has an
163-
/// effect on Android (TTID/TTFD histograms); on iOS the legacy per-scene launch-time
164-
/// metric was refactored into the `app_launch` span, so this flag is presently inert on
165-
/// iOS and is retained for cross-platform parity (it is propagated as a single option from
166-
/// the Flutter SDK) and possible future use. The `app.start` span event on `app_launch`
167-
/// (cold/warm via `start.type`, with `start.duration_ms`) is always attached when
168-
/// ``Analytics/appLaunch`` is enabled and is never gated by this flag. Defaults to `.disabled`.
162+
/// Whether to emit launch-time performance telemetry. On Android this also gates the legacy
163+
/// TTID/TTFD histograms. On both platforms it gates the `app.start` span event on `app_launch`
164+
/// (cold/warm via `start.type`, with `start.duration_ms`): when this flag is disabled the
165+
/// `app.start` event is omitted and the `app_launch` span is anchored at the launch-detection
166+
/// time (rather than back-dated to process start) so it carries no startup duration. The
167+
/// `app_launch` span itself (with `event.launch_type` and version fields) is still emitted when
168+
/// ``Analytics/appLaunch`` is enabled. Defaults to `.disabled`.
169169
let launchTimes: FeatureFlag
170170
/// Whether to automatically detect screen changes by swizzling
171171
/// `UIViewController`. This drives both the `screen_view` span (gated
172172
/// separately by ``Analytics/screenViews``) and Session Replay `Navigate`
173173
/// events. Defaults to `.enabled`.
174174
let screens: FeatureFlag
175175

176+
/// Every automatic instrumentation feature enabled.
177+
public static var enabled: Self {
178+
.init(urlSession: .enabled, userTaps: .enabled, memory: .enabled, memoryWarnings: .enabled, cpu: .enabled, launchTimes: .enabled, screens: .enabled)
179+
}
180+
181+
/// Every automatic instrumentation feature disabled. Note this also turns off user-tap
182+
/// detection (so no `click` spans are emitted regardless of ``Analytics/taps``) and
183+
/// automatic screen detection (so no `screen_view`/Session Replay `Navigate` events).
184+
public static var disabled: Self {
185+
.init(urlSession: .disabled, userTaps: .disabled, memory: .disabled, memoryWarnings: .disabled, cpu: .disabled, launchTimes: .disabled, screens: .disabled)
186+
}
187+
176188
public init(
177189
urlSession: FeatureFlag = .disabled,
178190
userTaps: FeatureFlag = .enabled,
179191
memory: FeatureFlag = .disabled,
180192
memoryWarnings: FeatureFlag = .disabled,
181193
cpu: FeatureFlag = .disabled,
182-
launchTimes: FeatureFlag = .disabled,
194+
launchTimes: FeatureFlag = .enabled,
183195
screens: FeatureFlag = .enabled
184196
) {
185197
self.urlSession = urlSession
@@ -216,8 +228,10 @@ public struct ObservabilityOptions {
216228
/// analytics taxonomy app-lifecycle events.
217229
let appLifecycle: FeatureFlag
218230
/// Whether to emit an `app_launch` span (with `event.launch_type` and version
219-
/// fields, plus an `app.start` span event for the cold/warm startup dimension)
220-
/// once per process launch. Maps to the analytics taxonomy `app_launch` event.
231+
/// fields) once per process launch. Maps to the analytics taxonomy `app_launch`
232+
/// event. The cold/warm startup dimension (`app.start` span event with
233+
/// `start.type`/`start.duration_ms`) is attached only when
234+
/// ``Instrumentation/launchTimes`` is also enabled.
221235
let appLaunch: FeatureFlag
222236

223237
public static var enabled: Self {

Sources/LaunchDarklyObservability/API/Observe.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,23 @@ public protocol Observe: AnyObject, MetricsApi, LogsApi, TracesApi, ObserveConte
3636
/// attached at lower precedence than the reserved `event.*` fields, so
3737
/// they can never clobber the taxonomy.
3838
func trackScreenView(name: String, screenClass: String?, screenId: String?, category: String?, properties: [String: Any]?)
39+
/// Manually record a `click` event as a `click` span.
40+
///
41+
/// Use this to reproduce the taxonomy `click` event for interactions that automatic
42+
/// tap capture cannot observe. Emitted through the same `analytics.taps` gate as
43+
/// automatic click spans.
44+
/// - Parameters:
45+
/// - id: Stable element identifier (`event.id`).
46+
/// - tag: Element tag/class (`event.tag`), e.g. `UIButton`.
47+
/// - text: Visible label/text of the element (`event.text`).
48+
/// - screenId: Stable screen id (`event.screen_id`). When `nil`, the current tracked
49+
/// screen id is used so the click correlates with the active `screen_view`.
50+
/// - x: Tap x coordinate in screen pixels (`event.x`).
51+
/// - y: Tap y coordinate in screen pixels (`event.y`).
52+
/// - properties: Optional custom attributes (same conversion rules as a `track`
53+
/// event's `properties`). Attached at lower precedence than the reserved `event.*`
54+
/// fields, so they can never clobber the taxonomy.
55+
func trackClick(id: String?, tag: String?, text: String?, screenId: String?, x: Int?, y: Int?, properties: [String: Any]?)
3956
}
4057

4158
extension Observe {
@@ -57,6 +74,12 @@ extension Observe {
5774
public func trackScreenView(name: String, screenClass: String?, screenId: String?, category: String?) {
5875
trackScreenView(name: name, screenClass: screenClass, screenId: screenId, category: category, properties: nil)
5976
}
77+
78+
/// Convenience: record a `click` with the common element fields. The current screen id
79+
/// is used unless `screenId` is supplied.
80+
public func trackClick(id: String?, tag: String? = nil, text: String? = nil, screenId: String? = nil) {
81+
trackClick(id: id, tag: tag, text: text, screenId: screenId, x: nil, y: nil, properties: nil)
82+
}
6083
}
6184

6285
/// Context for transfer data from Observability to SessionReplay during initialization

Sources/LaunchDarklyObservability/API/SemanticConvention.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public enum SemanticConvention {
3131
public static let startDurationMs = "start.duration_ms"
3232
public static let eventScreenClass = "event.screen_class"
3333
public static let eventScreenId = "event.screen_id"
34+
public static let eventScreenName = "event.screen_name"
3435
public static let eventPreviousScreen = "event.previous_screen"
3536
public static let eventCategory = "event.category"
3637
public static let eventType = "event.type"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#if canImport(UIKit)
2+
import SwiftUI
3+
4+
public extension View {
5+
/// Tags this SwiftUI element so an auto-captured `click` event reports `event.id == id` when the
6+
/// user taps it. Prefer a human-readable, stable id (e.g. `"checkout.pay_button"`).
7+
///
8+
/// Unlike approaches that bridge the SwiftUI view tree into UIKit, this attaches a SwiftUI tap
9+
/// gesture that fires during the tap and records the id; the SDK's interaction capture then uses
10+
/// it. It relies only on public SwiftUI gesture APIs, so it is robust across SwiftUI/iOS versions,
11+
/// and it does not modify `accessibilityIdentifier` or the view hierarchy. The underlying control
12+
/// stays fully interactive (the gesture is attached simultaneously).
13+
func ldClick(_ id: String) -> some View {
14+
modifier(LdClickModifier(id: id))
15+
}
16+
}
17+
18+
private struct LdClickModifier: ViewModifier {
19+
let id: String
20+
21+
@ViewBuilder
22+
func body(content: Content) -> some View {
23+
if #available(iOS 16.0, tvOS 16.0, *) {
24+
// `.global` is the root of the SwiftUI hierarchy: it matches UIKit window coordinates
25+
// for a full-screen window but is screen-relative otherwise. The interaction resolver
26+
// reconciles both spaces when matching, so taps still resolve under iPad multitasking.
27+
content.simultaneousGesture(
28+
SpatialTapGesture(coordinateSpace: .global).onEnded { value in
29+
LdClickRegistry.shared.record(id: id, location: value.location)
30+
}
31+
)
32+
} else {
33+
content.simultaneousGesture(
34+
TapGesture().onEnded {
35+
LdClickRegistry.shared.record(id: id, location: nil)
36+
}
37+
)
38+
}
39+
}
40+
}
41+
#endif
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#if canImport(UIKit)
2+
import UIKit
3+
4+
public extension UIView {
5+
/// Tags this view with a stable analytics identifier used as `event.id` for auto-captured
6+
/// `click` events on this view (or its descendants, when the tap resolves to a child). Prefer a
7+
/// human-readable, stable id (e.g. `"checkout.pay_button"`). Takes precedence over
8+
/// `accessibilityIdentifier`.
9+
///
10+
/// For SwiftUI, use the `.ldClick(_:)` view modifier instead.
11+
func ldId(_ id: String) {
12+
LdIdStorage.set(self, id: id)
13+
}
14+
}
15+
#endif

Sources/LaunchDarklyObservability/Client/NoOpObservabilityService.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ final class NoOpObservabilityService: Observe {
2525
func track(key: String, properties: [String: Any]?, metricValue: Double?) {}
2626

2727
func trackScreenView(name: String, screenClass: String?, screenId: String?, category: String?, properties: [String: Any]?) {}
28+
29+
func trackClick(id: String?, tag: String?, text: String?, screenId: String?, x: Int?, y: Int?, properties: [String: Any]?) {}
2830
}
2931

3032
extension NoOpObservabilityService {

Sources/LaunchDarklyObservability/Client/ObservabilityService.swift

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ final class ObservabilityService: InternalObserve {
3939
private var instruments = [AutoInstrumentation]()
4040

4141
private let userInteractionManager: UserInteractionManager
42-
private let screenStack = ScreenStack()
42+
private let screenStack: ScreenStack
4343
private var screenViewManager: ScreenViewManager?
4444
/// Broadcasts each recorded screen view so Session Replay can emit `Navigate` events.
4545
private let screenViewSubject = PassthroughSubject<ScreenViewEvent, Never>()
@@ -203,10 +203,21 @@ final class ObservabilityService: InternalObserve {
203203
// `click` span. Capture still flows to Session Replay regardless of either flag.
204204
let userTapsEnabled = options.instrumentation.userTaps.isEnabled
205205
let publishTaps = options.analytics.taps.isEnabled
206-
let userInteractionManager = UserInteractionManager(options: options, sessionManaging: sessionManager) { interaction in
206+
// Created as a local so the tap closure can read the current screen id without
207+
// capturing `self` (which isn't fully initialized yet at this point in `init`).
208+
let screenStack = ScreenStack()
209+
self.screenStack = screenStack
210+
let userInteractionManager = UserInteractionManager(
211+
options: options,
212+
sessionManaging: sessionManager,
213+
// The active screen is read once at tap time and stamped onto the interaction, so the
214+
// OTel span here and the Session Replay click event report the identical screen.
215+
screenInfoProvider: { (screenStack.currentId, screenStack.current) }
216+
) { interaction in
207217
guard userTapsEnabled else { return }
208218
guard publishTaps else { return }
209-
interaction.startEndSpan(tracer: tracerDecorator)
219+
// Correlate the tap with the active screen (taxonomy §4.1 `event.screen_id`).
220+
interaction.startEndSpan(tracer: tracerDecorator, screenId: interaction.screenId, screenName: interaction.screenName)
210221
}
211222
self.userInteractionManager = userInteractionManager
212223

@@ -292,7 +303,13 @@ extension ObservabilityService {
292303
)
293304
}
294305

295-
userInteractionManager.start()
306+
// The touch-capture hook (UIWindow.sendEvent swizzle + hit-testing) is invasive, so it is
307+
// only installed when something needs it: tap detection here (gated by
308+
// `instrumentation.userTaps`) or Session Replay, which starts the same shared manager
309+
// itself. With both off, no swizzle or hit-testing is installed.
310+
if options.instrumentation.userTaps.isEnabled {
311+
userInteractionManager.start()
312+
}
296313

297314
if options.instrumentation.screens.isEnabled {
298315
screenViewManager?.start()
@@ -464,6 +481,38 @@ extension ObservabilityService: Observe {
464481
)
465482
)
466483
}
484+
485+
/// Manually emit a `click` span, mirroring the automatic tap instrumentation. Use this
486+
/// to reproduce the taxonomy `click` event for interactions automatic capture can't observe.
487+
///
488+
/// Gated by `analytics.taps` (the same flag as automatic click spans). When `screenId` is
489+
/// `nil`, the current tracked screen id is used so the click correlates with the active
490+
/// `screen_view`. Reserved `event.*` fields take precedence over caller `properties`,
491+
/// matching the `screen_view`/`track` precedence model.
492+
func trackClick(id: String?, tag: String?, text: String?, screenId: String?, x: Int?, y: Int?, properties: [String: Any]?) {
493+
guard options.analytics.taps.isEnabled else { return }
494+
495+
let spanAttributes = ClickAttributes.build(
496+
id: id,
497+
tag: tag,
498+
text: text,
499+
// Default to the current screen so the click correlates with the active `screen_view`.
500+
screenId: screenId ?? screenStack.currentId,
501+
screenName: screenStack.current,
502+
x: x,
503+
y: y,
504+
contextKeyAttributes: cachedContextKeyAttributes,
505+
properties: properties?.toOtelAttributes() ?? [:]
506+
)
507+
508+
// Mirror the automatic tap span: a CLIENT-kind `click` span built via the decorator.
509+
let builder = tracerDecorator.spanBuilder(spanName: SemanticConvention.clickSpanName)
510+
builder.setSpanKind(spanKind: .client)
511+
for (key, value) in spanAttributes {
512+
builder.setAttribute(key: key, value: value)
513+
}
514+
builder.startSpan().end()
515+
}
467516
}
468517

469518
extension ObservabilityService: TrackEmitting {
@@ -644,14 +693,18 @@ extension ObservabilityService: TrackEmitting {
644693
// load (`AppStartTime`) and end it at the launch-detection time carried by the signal, so
645694
// analytics timestamps reflect the real startup window and aren't skewed by SDK init work.
646695
let launchTime = Date(timeIntervalSince1970: signal.timestamp)
647-
let spanStart = min(AppStartTime.stats.startDate, launchTime)
696+
// The startup-performance dimension (cold/warm `start.type` + `start.duration_ms`) is gated by
697+
// `instrumentation.launchTimes`. When it is off we also anchor the span at the launch-detection
698+
// time instead of back-dating it to process start, so the span window carries no startup
699+
// duration and `start.duration_ms` can't be recovered from it.
700+
let includeLaunchTime = options.instrumentation.launchTimes.isEnabled
701+
let spanStart = includeLaunchTime ? min(AppStartTime.stats.startDate, launchTime) : launchTime
648702

649703
let span = tracer.startSpan(name: SemanticConvention.appLaunchSpanName, attributes: spanAttributes, startTime: spanStart)
650704
// Taxonomy §4.6: cold/warm lives on the `app.start` span event (orthogonal to
651-
// `event.launch_type`). Always attach when known under `analytics.appLaunch`.
652-
// `instrumentation.launchTimes` is inert on iOS (the legacy per-scene launch metric was
653-
// folded into this span) and intentionally never gated this event.
654-
if let startType = signal.startType {
705+
// `event.launch_type`), attached under `analytics.appLaunch` and gated by
706+
// `instrumentation.launchTimes`.
707+
if includeLaunchTime, let startType = signal.startType {
655708
var eventAttributes: [String: AttributeValue] = [
656709
SemanticConvention.startType: .string(startType.rawValue)
657710
]

0 commit comments

Comments
 (0)