Skip to content

Commit a421812

Browse files
feat: Android SR Identify support (#330)
## Summary <img width="771" height="631" alt="image" src="https://github.com/user-attachments/assets/ac6a2691-a75b-4856-870d-13042b3960a9" /> <img width="1039" height="821" alt="image" src="https://github.com/user-attachments/assets/b05f0d7c-47b3-49c7-9760-eccb6b308f0b" /> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Implements Session Replay identify flow (hooks, exporter, payloads) and updates the sample app to trigger identify events, replacing tracing hook and refactoring replay exporter. > > - **Session Replay (SDK)**: > - Add `SessionReplayHook` to emit identify on `afterIdentify` and wire into `SessionReplay` plugin. > - Enhance `ReplayInstrumentation` to log `event.domain` (`media`, `interaction`, `identify`), initialize exporter with initial identify payload, and add `identifySession(ldContext)`. > - Introduce `EventDomain`, `IdentifyItemPayload`, and `SessionReplayEventGenerator` (encapsulates RRWeb event generation incl. Identify custom event). > - Refactor exporter to `SessionReplayExporter` with GraphQL `SessionReplayApiService` (now supports identify via payload); handle identify logs and update identify state. > - **Observability**: > - Rename `TracingHook` to `ObservabilityHook` and update usage in `Observability`. > - Extend `ObservabilityOptions` with `contextFriendlyName`. > - **E2E App (UI/VM)**: > - Add Identify buttons (User/Multi/Anon) and corresponding `ViewModel` methods; style danger buttons. > - Import `SessionReplay` from `replay.plugin` and add app-specific colors. > - **Tests**: > - Update/rename tests to new hook and exporter APIs; add cases for identify flow and canvas buffer limits. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ed2340d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent d862a15 commit a421812

File tree

18 files changed

+688
-286
lines changed

18 files changed

+688
-286
lines changed

e2e/android/app/src/main/java/com/example/androidobservability/BaseApplication.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import com.launchdarkly.observability.client.TelemetryInspector
66
import com.launchdarkly.observability.plugin.Observability
77
import com.launchdarkly.observability.replay.PrivacyProfile
88
import com.launchdarkly.observability.replay.ReplayOptions
9-
import com.launchdarkly.observability.replay.SessionReplay
9+
import com.launchdarkly.observability.replay.plugin.SessionReplay
1010
import com.launchdarkly.sdk.ContextKind
1111
import com.launchdarkly.sdk.LDContext
1212
import com.launchdarkly.sdk.android.Components

e2e/android/app/src/main/java/com/example/androidobservability/MainActivity.kt

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.Arrangement
1818
import androidx.compose.foundation.rememberScrollState
1919
import androidx.compose.foundation.verticalScroll
2020
import androidx.compose.material3.Button
21+
import androidx.compose.material3.ButtonDefaults
2122
import androidx.compose.material3.OutlinedTextField
2223
import androidx.compose.material3.Scaffold
2324
import androidx.compose.material3.Text
@@ -36,12 +37,16 @@ import androidx.compose.foundation.layout.FlowRow
3637
import androidx.compose.foundation.layout.ExperimentalLayoutApi
3738
import androidx.compose.material3.HorizontalDivider
3839
import androidx.compose.ui.platform.LocalContext
40+
import androidx.compose.ui.graphics.Color
3941
import com.example.androidobservability.masking.ComposeMaskingActivity
4042
import com.example.androidobservability.masking.ComposeUserFormActivity
4143
import com.example.androidobservability.masking.XMLUserFormActivity
4244
import com.example.androidobservability.masking.XMLMaskingActivity
4345
import com.example.androidobservability.smoothie.SmoothieListActivity
4446
import com.example.androidobservability.ui.theme.AndroidObservabilityTheme
47+
import com.example.androidobservability.ui.theme.DangerRed
48+
import com.example.androidobservability.ui.theme.IdentifyBgColor
49+
import com.example.androidobservability.ui.theme.IdentifyTextColor
4550

4651
class MainActivity : ComponentActivity() {
4752

@@ -81,6 +86,8 @@ class MainActivity : ComponentActivity() {
8186
)
8287
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))
8388

89+
IdentifyButtons(viewModel = viewModel)
90+
8491
InstrumentationButtons(viewModel = viewModel)
8592

8693
MetricButtons(viewModel = viewModel)
@@ -184,7 +191,11 @@ private fun InstrumentationButtons(viewModel: ViewModel) {
184191
Button(
185192
onClick = {
186193
viewModel.triggerCrash()
187-
}
194+
},
195+
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
196+
containerColor = DangerRed,
197+
contentColor = Color.White
198+
)
188199
) {
189200
Text("Trigger Crash")
190201
}
@@ -316,12 +327,57 @@ private fun MaskingButtons() {
316327
}
317328
}
318329

330+
@Composable
331+
private fun IdentifyButtons(viewModel: ViewModel) {
332+
Spacer(modifier = Modifier.height(16.dp))
333+
334+
Text(
335+
text = "Identify:",
336+
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
337+
modifier = Modifier.padding(bottom = 8.dp, top = 8.dp)
338+
)
339+
340+
Row(
341+
modifier = Modifier
342+
.fillMaxWidth()
343+
.padding(start = 8.dp, bottom = 8.dp),
344+
horizontalArrangement = Arrangement.spacedBy(8.dp)
345+
) {
346+
Button(
347+
onClick = { viewModel.identifyUser() },
348+
colors = ButtonDefaults.buttonColors(
349+
containerColor = IdentifyBgColor,
350+
contentColor = IdentifyTextColor
351+
)
352+
) {
353+
Text("User")
354+
}
355+
Button(
356+
onClick = { viewModel.identifyMulti() },
357+
colors = ButtonDefaults.buttonColors(
358+
containerColor = IdentifyBgColor,
359+
contentColor = IdentifyTextColor
360+
)
361+
) {
362+
Text("Multi")
363+
}
364+
Button(
365+
onClick = { viewModel.identifyAnonymous() },
366+
colors = ButtonDefaults.buttonColors(
367+
containerColor = IdentifyBgColor,
368+
contentColor = IdentifyTextColor
369+
)
370+
) {
371+
Text("Anon")
372+
}
373+
}
374+
}
375+
319376
@Composable
320377
private fun CustomerApiButtons(viewModel: ViewModel) {
321378
var customLogText by remember { mutableStateOf("") }
322379
var customSpanText by remember { mutableStateOf("") }
323380
var flagKey by remember { mutableStateOf("") }
324-
var customContextKey by remember { mutableStateOf("") }
325381

326382
Text(
327383
text = "Customer API",
@@ -332,7 +388,11 @@ private fun CustomerApiButtons(viewModel: ViewModel) {
332388
Button(
333389
onClick = {
334390
viewModel.triggerError()
335-
}
391+
},
392+
colors = ButtonDefaults.buttonColors(
393+
containerColor = DangerRed,
394+
contentColor = Color.White
395+
)
336396
) {
337397
Text("Trigger Error")
338398
}
@@ -400,20 +460,5 @@ private fun CustomerApiButtons(viewModel: ViewModel) {
400460
Text("Evaluate boolean flag")
401461
}
402462

403-
Spacer(modifier = Modifier.height(16.dp))
404463

405-
OutlinedTextField(
406-
value = customContextKey,
407-
onValueChange = { customContextKey = it },
408-
label = { Text("LD context key") },
409-
modifier = Modifier.padding(8.dp)
410-
)
411-
Button(
412-
onClick = {
413-
viewModel.identifyLDContext(customContextKey)
414-
},
415-
modifier = Modifier.padding(8.dp)
416-
) {
417-
Text("Identify LD Context")
418-
}
419464
}

e2e/android/app/src/main/java/com/example/androidobservability/ViewModel.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,34 @@ class ViewModel(application: Application) : AndroidViewModel(application) {
125125
LDClient.get().identify(context)
126126
}
127127

128+
fun identifyUser() {
129+
val userContext = LDContext.builder(ContextKind.DEFAULT, "single-userkey")
130+
.name("Bob Bobberson")
131+
.build()
132+
133+
LDClient.get().identify(userContext)
134+
}
135+
136+
fun identifyAnonymous() {
137+
val anonContext = LDContext.builder(ContextKind.DEFAULT, "anonymous-userkey")
138+
.anonymous(true)
139+
.build()
140+
141+
LDClient.get().identify(anonContext)
142+
}
143+
144+
fun identifyMulti() {
145+
val userContext = LDContext.builder(ContextKind.DEFAULT, "multi-username")
146+
.name("multi-username")
147+
.build()
148+
val deviceContext = LDContext.builder(ContextKind.of("device"), "iphone")
149+
.name("iphone")
150+
.build()
151+
152+
val multiContext = LDContext.createMulti(userContext, deviceContext)
153+
LDClient.get().identify(multiContext)
154+
}
155+
128156
fun evaluateBooleanFlag(flagKey: String) {
129157
if (flagKey.isNotEmpty()) {
130158
val result = LDClient.get().boolVariation(flagKey, false)

e2e/android/app/src/main/java/com/example/androidobservability/ui/theme/Color.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ val Pink80 = Color(0xFFEFB8C8)
99
val Purple40 = Color(0xFF6650a4)
1010
val PurpleGrey40 = Color(0xFF625b71)
1111
val Pink40 = Color(0xFF7D5260)
12+
13+
// App-specific color constants
14+
val IdentifyTextColor = Color(0xFF8A9EFF)
15+
val IdentifyBgColor = Color(0xFF121D61)
16+
val DangerRed = Color(0xFFFF0000)

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/api/ObservabilityOptions.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ data class ObservabilityOptions(
3434
val serviceVersion: String = BuildConfig.OBSERVABILITY_SDK_VERSION,
3535
val otlpEndpoint: String = DEFAULT_OTLP_ENDPOINT,
3636
val backendUrl: String = DEFAULT_BACKEND_URL,
37+
val contextFriendlyName: String? = null,
3738
val resourceAttributes: Attributes = Attributes.empty(),
3839
val customHeaders: Map<String, String> = emptyMap(),
3940
val sessionBackgroundTimeout: Duration = 15.minutes,

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ class Observability(
8888

8989
override fun getHooks(metadata: EnvironmentMetadata?): MutableList<Hook> {
9090
return Collections.singletonList(
91-
TracingHook(withSpans = true, withValue = true) { observabilityClient?.getTracer() }
91+
ObservabilityHook(withSpans = true, withValue = true) { observabilityClient?.getTracer() }
9292
)
9393
}
9494

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/TracingHook.kt renamed to sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/ObservabilityHook.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ import io.opentelemetry.context.Scope
1717
* This class is a hook implementation for recording flag evaluation and identify events
1818
* on spans.
1919
*/
20-
class TracingHook
20+
class ObservabilityHook
2121

2222
/**
23-
* Creates an [TracingHook]
23+
* Creates an [ObservabilityHook]
2424
*
2525
* @param withSpans will include child spans for the various hook series when they happen
2626
* @param withValue will include the value of the feature flag in the recorded evaluation events
@@ -162,7 +162,7 @@ internal constructor(
162162

163163
companion object {
164164
const val PROVIDER_NAME: String = "LaunchDarkly"
165-
const val HOOK_NAME: String = "LaunchDarkly Evaluation Tracing Hook"
165+
const val HOOK_NAME: String = "Observability Hook"
166166
const val INSTRUMENTATION_NAME: String = "com.launchdarkly.observability"
167167
const val DATA_KEY_FEATURE_FLAG_SPAN: String = "variationSpan"
168168
const val FEATURE_FLAG_EVENT_NAME: String = "feature_flag"

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import com.launchdarkly.observability.client.ObservabilityContext
44
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
55
import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation
66
import com.launchdarkly.observability.replay.capture.CaptureSource
7+
import com.launchdarkly.observability.replay.exporter.EventDomain
8+
import com.launchdarkly.observability.replay.exporter.IdentifyItemPayload
9+
import com.launchdarkly.observability.replay.exporter.SessionReplayExporter
10+
import com.launchdarkly.sdk.LDContext
711
import io.opentelemetry.android.instrumentation.InstallationContext
12+
import io.opentelemetry.android.session.SessionManager
813
import io.opentelemetry.api.logs.Logger
914
import io.opentelemetry.sdk.logs.LogRecordProcessor
1015
import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor
@@ -68,16 +73,21 @@ class ReplayInstrumentation(
6873
private val observabilityContext: ObservabilityContext
6974
) : LDExtendedInstrumentation {
7075

76+
private var _exporter: SessionReplayExporter? = null
7177
private lateinit var _otelLogger: Logger
7278
private lateinit var _captureSource: CaptureSource
7379
private lateinit var _interactionSource: InteractionSource
80+
81+
private lateinit var _sessionManager: SessionManager
82+
7483
private var _captureJob: Job? = null
7584
private var _isPaused: Boolean = false
7685
private val _captureMutex = Mutex()
7786

7887
override val name: String = INSTRUMENTATION_SCOPE_NAME
7988

8089
override fun install(ctx: InstallationContext) {
90+
_sessionManager = ctx.sessionManager
8191
_otelLogger = ctx.openTelemetry.logsBridge.get(INSTRUMENTATION_SCOPE_NAME)
8292
_captureSource = CaptureSource(
8393
sessionManager = ctx.sessionManager,
@@ -91,7 +101,7 @@ class ReplayInstrumentation(
91101
GlobalScope.launch(DispatcherProviderHolder.current.default) {
92102
_captureSource.captureFlow.collect { capture ->
93103
_otelLogger.logRecordBuilder()
94-
.setAttribute("event.domain", "media")
104+
.setAttribute("event.domain", EventDomain.MEDIA.wireValue)
95105
.setAttribute("image.width", capture.origWidth.toLong())
96106
.setAttribute("image.height", capture.origHeight.toLong())
97107
.setAttribute("image.data", capture.imageBase64)
@@ -122,7 +132,7 @@ class ReplayInstrumentation(
122132
// Use the last position's timestamp for the log record timestamp
123133
val logTimestamp = interaction.positions.last().timestamp
124134
_otelLogger.logRecordBuilder()
125-
.setAttribute("event.domain", "interaction")
135+
.setAttribute("event.domain", EventDomain.INTERACTION.wireValue)
126136
.setAttribute("android.action", interaction.action)
127137
.setAttribute("screen.coords", positionsJson)
128138
.setAttribute("session.id", interaction.session)
@@ -184,12 +194,20 @@ class ReplayInstrumentation(
184194
override fun getLoggerScopeName(): String = INSTRUMENTATION_SCOPE_NAME
185195

186196
override fun getLogRecordProcessor(credential: String): LogRecordProcessor {
187-
val exporter = RRwebGraphQLReplayLogExporter(
197+
val initialIdentifyItemPayload = IdentifyItemPayload.from(
198+
contextFriendlyName = observabilityContext.options.contextFriendlyName,
199+
resourceAttributes = observabilityContext.options.resourceAttributes,
200+
sessionId = null //initial payload is not part SR RRWeb event
201+
)
202+
203+
val exporter = SessionReplayExporter(
188204
organizationVerboseId = credential, // the SDK credential is used as the organization ID intentionally
189205
backendUrl = observabilityContext.options.backendUrl,
190206
serviceName = observabilityContext.options.serviceName,
191207
serviceVersion = observabilityContext.options.serviceVersion,
208+
initialIdentifyItemPayload = initialIdentifyItemPayload
192209
)
210+
_exporter = exporter
193211

194212
return BatchLogRecordProcessor.builder(exporter)
195213
.setMaxQueueSize(BATCH_MAX_QUEUE_SIZE)
@@ -198,4 +216,32 @@ class ReplayInstrumentation(
198216
.setMaxExportBatchSize(BATCH_MAX_EXPORT_SIZE)
199217
.build()
200218
}
219+
220+
suspend fun identifySession(
221+
ldContext: LDContext,
222+
timestamp: Long = System.currentTimeMillis()
223+
) {
224+
if (!this::_sessionManager.isInitialized || !this::_otelLogger.isInitialized) {
225+
observabilityContext.logger.warn("identifySession called before ReplayInstrumentation was installed; skipping.")
226+
return
227+
}
228+
229+
val sessionId = _sessionManager.getSessionId()
230+
val event = IdentifyItemPayload.from(
231+
contextFriendlyName = observabilityContext.options.contextFriendlyName,
232+
resourceAttributes = observabilityContext.options.resourceAttributes,
233+
ldContext = ldContext,
234+
timestamp = timestamp,
235+
sessionId = sessionId
236+
)
237+
238+
_exporter?.identifyEventAndUpdate(event)
239+
240+
_otelLogger.logRecordBuilder()
241+
.setAllAttributes(observabilityContext.options.resourceAttributes)
242+
.setAttribute("event.domain", EventDomain.IDENTIFY.wireValue)
243+
.setAttribute("session.id", sessionId)
244+
.setTimestamp(timestamp, TimeUnit.MILLISECONDS)
245+
.emit()
246+
}
201247
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.launchdarkly.observability.replay.exporter
2+
3+
enum class EventDomain(val wireValue: String) {
4+
MEDIA("media"),
5+
INTERACTION("interaction"),
6+
IDENTIFY("identify");
7+
8+
companion object {
9+
fun fromString(value: String?): EventDomain? {
10+
return values().firstOrNull { it.wireValue == value }
11+
}
12+
}
13+
}
14+
15+

0 commit comments

Comments
 (0)