Skip to content

Commit dd0134f

Browse files
feat: richer Session Replay click descriptions (target text/selector) (#217)
## Summary - Populate the SR `Click` payload's `clickTextContent` from the tapped element's visible text (previously the accessibility identifier, usually empty) and fall back to the class name for `clickSelector`, matching the web `Click` semantics so the session player shows a meaningful description. - Add **temporary** debug logs of the JSON shapes for both the OTel `click` span and the SR `Click` custom event. ## Note The debug log lines are intentional and will be removed before release. ## Test plan - [ ] Tap UI elements; confirm `[Otel Click]` and `[SR Click]` debug logs show populated `event.text` / `clickTextContent`. - [ ] Verify the session player renders a non-empty click description. Made with [Cursor](https://cursor.com) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes what text is recorded for taps (session replay and OTel) and touches PII-sensitive extraction paths; incorrect filtering could still leak input or omit useful labels. > > **Overview** > Improves **Session Replay click descriptions** by filling `clickTextContent` from the tapped element’s visible text (`TouchTarget.text` via `extractViewInfo`) and using **accessibility identifier, else class name** for `clickSelector`, aligned with web Click semantics. > > **Privacy hardening** in `ViewInfo` so richer text capture does not leak user input: `UITextField`, `UISearchBar`, and editable `UITextView` no longer read typed values—only placeholder / accessibility labels. Descendant title scanning skips text-input views (`isSensitiveTextInput`) so internal label subviews cannot surface passwords or queries into click/OTel text. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 66e0bb0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c019bdd commit dd0134f

7 files changed

Lines changed: 65 additions & 10 deletions

File tree

Sources/LaunchDarklyObservability/API/Observe.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ public protocol TracesApi {
8787
/// - name The name of the span
8888
/// - attributes The attributes to record with the span
8989
func startSpan(name: String, attributes: [String : AttributeValue]) -> Span
90+
/// Start a span with an explicit kind.
91+
/// - name The name of the span
92+
/// - attributes The attributes to record with the span
93+
/// - spanKind The kind of the span (defaults to `.client` for most spans)
94+
func startSpan(name: String, attributes: [String : AttributeValue], spanKind: SpanKind) -> Span
9095
}
9196

9297
extension TracesApi {
@@ -97,4 +102,10 @@ extension TracesApi {
97102
public func startSpan(name: String) -> Span {
98103
startSpan(name: name, attributes: [:])
99104
}
105+
106+
/// Default implementation forwards to ``startSpan(name:attributes:)``, leaving the span kind
107+
/// to the implementation's default. Conformers that can honor a specific kind override this.
108+
public func startSpan(name: String, attributes: [String : AttributeValue], spanKind: SpanKind) -> Span {
109+
startSpan(name: name, attributes: attributes)
110+
}
100111
}

Sources/LaunchDarklyObservability/Plugin/ObservabilityHookExporter.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ extension ObservabilityHookExporter: ObservabilityHookExporting {
117117
attributes[Self.SEMCONV_FEATURE_FLAG_PROVIDER_NAME] = .string(Self.PROVIDER_NAME)
118118
attributes[Self.SEMCONV_FEATURE_FLAG_CONTEXT_ID] = .string(contextKey)
119119

120-
let span = traceClient.startSpan(name: Self.FEATURE_FLAG_SPAN_NAME, attributes: attributes)
120+
let span = traceClient.startSpan(name: Self.FEATURE_FLAG_SPAN_NAME, attributes: attributes, spanKind: .internal)
121121

122122
if let (_, evictedSpan) = spans.setValue(span, forKey: evaluationId) {
123123
evictedSpan.end()

Sources/LaunchDarklyObservability/Traces/TraceClient.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ final class TraceClient: TracesApi {
3131
let span = builder.startSpan()
3232
return span
3333
}
34+
35+
/// Starts a span with an explicit kind. Used for the few spans that must not use the
36+
/// default `.client` kind (e.g. flag evaluations, which are `.internal`).
37+
func startSpan(name: String, attributes: [String : AttributeValue], spanKind: SpanKind) -> any Span {
38+
let builder = tracer.spanBuilder(spanName: name)
39+
builder.setSpanKind(spanKind: spanKind)
40+
attributes.forEach {
41+
builder.setAttribute(key: $0.key, value: $0.value)
42+
}
43+
44+
let span = builder.startSpan()
45+
return span
46+
}
3447
}
3548

3649
/// Internal method used to set span start date

Sources/LaunchDarklyObservability/Traces/TracerDecorator.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ final class TracerDecorator: Tracer {
2828
func spanBuilder(spanName: String) -> any SpanBuilder {
2929
let builder = tracer.spanBuilder(spanName: spanName)
3030

31+
// Spans default to `.client`; callers that need a different kind (e.g. flag
32+
// evaluations, which are `.internal`) override this after building.
33+
builder.setSpanKind(spanKind: .client)
34+
3135
if let parent = OpenTelemetry.instance.contextProvider.activeSpan {
3236
builder.setParent(parent)
3337
}
@@ -68,14 +72,16 @@ extension TracerDecorator: TracesApi {
6872

6973
/// Internal method used to set span start date
7074
extension Tracer {
71-
func startSpan(name: String, attributes: [String : AttributeValue], startTime: Date) -> any Span {
75+
func startSpan(name: String, attributes: [String : AttributeValue], startTime: Date, spanKind: SpanKind = .client) -> any Span {
7276
let builder = spanBuilder(spanName: name)
77+
builder.setSpanKind(spanKind: spanKind)
7378
attributes.forEach {
7479
builder.setAttribute(key: $0.key, value: $0.value)
7580
}
7681

7782
builder.setStartTime(time: startTime)
78-
83+
84+
7985
let span = builder.startSpan()
8086
return span
8187
}

Sources/LaunchDarklyObservability/UIInteractions/UInteraction+Span.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ extension TouchInteraction {
2020

2121
let span = tracer.startSpan(name: SemanticConvention.clickSpanName,
2222
attributes: attributes,
23-
startTime: Date(timeIntervalSince1970: startTimestamp))
23+
startTime: Date(timeIntervalSince1970: startTimestamp),
24+
spanKind: .client)
2425
span.end(time: Date(timeIntervalSince1970: timestamp))
2526
}
2627
}

Sources/LaunchDarklyObservability/UIInteractions/ViewInfo.swift

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,19 @@ extension UIView {
4040
return ViewInfo(title: firstNonEmpty(label.text, self.accessibilityLabel), category: "label")
4141
}
4242
if let tf = self as? UITextField {
43-
return ViewInfo(title: firstNonEmpty(tf.text, tf.placeholder, self.accessibilityLabel), category: "textField")
43+
// Never capture an input's typed value (passwords, emails, etc.) - only the
44+
// placeholder / accessibility label, which are developer-set, non-sensitive labels.
45+
return ViewInfo(title: firstNonEmpty(tf.placeholder, self.accessibilityLabel), category: "textField")
4446
}
4547
if let tv = self as? UITextView {
46-
return ViewInfo(title: firstNonEmpty(tv.text, self.accessibilityLabel), category: "textView")
48+
// Editable text views hold user input; capture only the label. Non-editable text views
49+
// are display content (label-like) and safe to read.
50+
let title = tv.isEditable ? self.accessibilityLabel : firstNonEmpty(tv.text, self.accessibilityLabel)
51+
return ViewInfo(title: title, category: "textView")
4752
}
4853
if let sb = self as? UISearchBar {
49-
return ViewInfo(title: firstNonEmpty(sb.text, sb.placeholder, self.accessibilityLabel), category: "searchBar")
54+
// Never capture the typed search query - only the placeholder / accessibility label.
55+
return ViewInfo(title: firstNonEmpty(sb.placeholder, self.accessibilityLabel), category: "searchBar")
5056
}
5157
if let seg = self as? UISegmentedControl {
5258
let selected = seg.selectedSegmentIndex
@@ -72,6 +78,10 @@ extension UIView {
7278

7379
// Depth-first search for a sensible text inside subviews (UILabel/UIButton)
7480
private func firstDescendantTitle() -> String? {
81+
// Never read or descend into text-input views. They render typed values through
82+
// private label subviews, so scanning descendants would leak user input
83+
// (passwords, emails, search queries, etc.) into the captured title.
84+
if isSensitiveTextInput { return nil }
7585
if let label = self as? UILabel, let t = cleaned(label.text) { return t }
7686
if let btn = self as? UIButton {
7787
return firstNonEmpty(btn.titleLabel?.text, btn.currentTitle, btn.title(for: .normal), btn.attributedTitle(for: .normal)?.string)
@@ -82,6 +92,15 @@ extension UIView {
8292
return nil
8393
}
8494

95+
// Views that hold user-typed input. Their values must never be captured, and we must
96+
// not descend into them since their text is rendered via internal subviews.
97+
private var isSensitiveTextInput: Bool {
98+
if self is UITextField { return true }
99+
if self is UISearchBar { return true }
100+
if let textView = self as? UITextView { return textView.isEditable }
101+
return false
102+
}
103+
85104
private func allSegmentTitles(_ seg: UISegmentedControl) -> String? {
86105
guard seg.numberOfSegments > 0 else { return nil }
87106
let titles = (0..<seg.numberOfSegments)

Sources/LaunchDarklySessionReplay/Exporter/RRWebEventGenerator.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,15 @@ actor RRWebEventGenerator {
228228
func clickEvent(interaction: TouchInteraction) -> Event? {
229229
guard case .touchDown = interaction.kind else { return nil }
230230

231+
// Mirror the web `Click` payload (`highlight-run` ClickListener):
232+
// - clickTarget: element identifier (web: full CSS selector path; iOS analog: class name)
233+
// - clickTextContent: the element's visible text (web: `target.textContent`)
234+
// - clickSelector: simple selector (web: `#id` else tag; iOS analog: a11y id else class name)
235+
let target = interaction.target
231236
let eventData = CustomEventData(tag: .click, payload: ClickPayload(
232-
clickTarget: interaction.target?.className ?? "",
233-
clickTextContent: interaction.target?.accessibilityIdentifier ?? "",
234-
clickSelector: interaction.target?.accessibilityIdentifier ?? "view"))
237+
clickTarget: target?.className ?? "",
238+
clickTextContent: target?.text ?? "",
239+
clickSelector: target?.accessibilityIdentifier ?? target?.className ?? "view"))
235240
let event = Event(type: .Custom,
236241
data: AnyEventData(eventData),
237242
timestamp: interaction.timestamp,

0 commit comments

Comments
 (0)