Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import com.launchdarkly.observability.client.TelemetryInspector
import com.launchdarkly.observability.plugin.Observability
import com.launchdarkly.observability.replay.PrivacyProfile
import com.launchdarkly.observability.replay.ReplayOptions
import com.launchdarkly.observability.replay.SessionReplay
import com.launchdarkly.observability.replay.plugin.SessionReplay
import com.launchdarkly.sdk.ContextKind
import com.launchdarkly.sdk.LDContext
import com.launchdarkly.sdk.android.Components
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
Expand All @@ -36,12 +37,16 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import com.example.androidobservability.masking.ComposeMaskingActivity
import com.example.androidobservability.masking.ComposeUserFormActivity
import com.example.androidobservability.masking.XMLUserFormActivity
import com.example.androidobservability.masking.XMLMaskingActivity
import com.example.androidobservability.smoothie.SmoothieListActivity
import com.example.androidobservability.ui.theme.AndroidObservabilityTheme
import com.example.androidobservability.ui.theme.DangerRed
import com.example.androidobservability.ui.theme.IdentifyBgColor
import com.example.androidobservability.ui.theme.IdentifyTextColor

class MainActivity : ComponentActivity() {

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

IdentifyButtons(viewModel = viewModel)

InstrumentationButtons(viewModel = viewModel)

MetricButtons(viewModel = viewModel)
Expand Down Expand Up @@ -184,7 +191,11 @@ private fun InstrumentationButtons(viewModel: ViewModel) {
Button(
onClick = {
viewModel.triggerCrash()
}
},
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = DangerRed,
contentColor = Color.White
)
) {
Text("Trigger Crash")
}
Expand Down Expand Up @@ -316,12 +327,57 @@ private fun MaskingButtons() {
}
}

@Composable
private fun IdentifyButtons(viewModel: ViewModel) {
Spacer(modifier = Modifier.height(16.dp))

Text(
text = "Identify:",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(bottom = 8.dp, top = 8.dp)
)

Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { viewModel.identifyUser() },
colors = ButtonDefaults.buttonColors(
containerColor = IdentifyBgColor,
contentColor = IdentifyTextColor
)
) {
Text("User")
}
Button(
onClick = { viewModel.identifyMulti() },
colors = ButtonDefaults.buttonColors(
containerColor = IdentifyBgColor,
contentColor = IdentifyTextColor
)
) {
Text("Multi")
}
Button(
onClick = { viewModel.identifyAnonymous() },
colors = ButtonDefaults.buttonColors(
containerColor = IdentifyBgColor,
contentColor = IdentifyTextColor
)
) {
Text("Anon")
}
}
}

@Composable
private fun CustomerApiButtons(viewModel: ViewModel) {
var customLogText by remember { mutableStateOf("") }
var customSpanText by remember { mutableStateOf("") }
var flagKey by remember { mutableStateOf("") }
var customContextKey by remember { mutableStateOf("") }

Text(
text = "Customer API",
Expand All @@ -332,7 +388,11 @@ private fun CustomerApiButtons(viewModel: ViewModel) {
Button(
onClick = {
viewModel.triggerError()
}
},
colors = ButtonDefaults.buttonColors(
containerColor = DangerRed,
contentColor = Color.White
)
) {
Text("Trigger Error")
}
Expand Down Expand Up @@ -400,20 +460,5 @@ private fun CustomerApiButtons(viewModel: ViewModel) {
Text("Evaluate boolean flag")
}

Spacer(modifier = Modifier.height(16.dp))

OutlinedTextField(
value = customContextKey,
onValueChange = { customContextKey = it },
label = { Text("LD context key") },
modifier = Modifier.padding(8.dp)
)
Button(
onClick = {
viewModel.identifyLDContext(customContextKey)
},
modifier = Modifier.padding(8.dp)
) {
Text("Identify LD Context")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,34 @@ class ViewModel(application: Application) : AndroidViewModel(application) {
LDClient.get().identify(context)
}

fun identifyUser() {
val userContext = LDContext.builder(ContextKind.DEFAULT, "single-userkey")
.name("Bob Bobberson")
.build()

LDClient.get().identify(userContext)
}

fun identifyAnonymous() {
val anonContext = LDContext.builder(ContextKind.DEFAULT, "anonymous-userkey")
.anonymous(true)
.build()

LDClient.get().identify(anonContext)
}

fun identifyMulti() {
val userContext = LDContext.builder(ContextKind.DEFAULT, "multi-username")
.name("multi-username")
.build()
val deviceContext = LDContext.builder(ContextKind.of("device"), "iphone")
.name("iphone")
.build()

val multiContext = LDContext.createMulti(userContext, deviceContext)
LDClient.get().identify(multiContext)
}

fun evaluateBooleanFlag(flagKey: String) {
if (flagKey.isNotEmpty()) {
val result = LDClient.get().boolVariation(flagKey, false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

// App-specific color constants
val IdentifyTextColor = Color(0xFF8A9EFF)
val IdentifyBgColor = Color(0xFF121D61)
val DangerRed = Color(0xFFFF0000)
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ data class ObservabilityOptions(
val serviceVersion: String = BuildConfig.OBSERVABILITY_SDK_VERSION,
val otlpEndpoint: String = DEFAULT_OTLP_ENDPOINT,
val backendUrl: String = DEFAULT_BACKEND_URL,
val contextFriendlyName: String? = null,
val resourceAttributes: Attributes = Attributes.empty(),
val customHeaders: Map<String, String> = emptyMap(),
val sessionBackgroundTimeout: Duration = 15.minutes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ class Observability(

override fun getHooks(metadata: EnvironmentMetadata?): MutableList<Hook> {
return Collections.singletonList(
TracingHook(withSpans = true, withValue = true) { observabilityClient?.getTracer() }
ObservabilityHook(withSpans = true, withValue = true) { observabilityClient?.getTracer() }
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import io.opentelemetry.context.Scope
* This class is a hook implementation for recording flag evaluation and identify events
* on spans.
*/
class TracingHook
class ObservabilityHook

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

companion object {
const val PROVIDER_NAME: String = "LaunchDarkly"
const val HOOK_NAME: String = "LaunchDarkly Evaluation Tracing Hook"
const val HOOK_NAME: String = "Observability Hook"
const val INSTRUMENTATION_NAME: String = "com.launchdarkly.observability"
const val DATA_KEY_FEATURE_FLAG_SPAN: String = "variationSpan"
const val FEATURE_FLAG_EVENT_NAME: String = "feature_flag"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import com.launchdarkly.observability.client.ObservabilityContext
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
import com.launchdarkly.observability.interfaces.LDExtendedInstrumentation
import com.launchdarkly.observability.replay.capture.CaptureSource
import com.launchdarkly.observability.replay.exporter.EventDomain
import com.launchdarkly.observability.replay.exporter.IdentifyItemPayload
import com.launchdarkly.observability.replay.exporter.SessionReplayExporter
import com.launchdarkly.sdk.LDContext
import io.opentelemetry.android.instrumentation.InstallationContext
import io.opentelemetry.android.session.SessionManager
import io.opentelemetry.api.logs.Logger
import io.opentelemetry.sdk.logs.LogRecordProcessor
import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor
Expand Down Expand Up @@ -68,16 +73,21 @@ class ReplayInstrumentation(
private val observabilityContext: ObservabilityContext
) : LDExtendedInstrumentation {

private var _exporter: SessionReplayExporter? = null
private lateinit var _otelLogger: Logger
private lateinit var _captureSource: CaptureSource
private lateinit var _interactionSource: InteractionSource

private lateinit var _sessionManager: SessionManager

private var _captureJob: Job? = null
private var _isPaused: Boolean = false
private val _captureMutex = Mutex()

override val name: String = INSTRUMENTATION_SCOPE_NAME

override fun install(ctx: InstallationContext) {
_sessionManager = ctx.sessionManager
_otelLogger = ctx.openTelemetry.logsBridge.get(INSTRUMENTATION_SCOPE_NAME)
_captureSource = CaptureSource(
sessionManager = ctx.sessionManager,
Expand All @@ -91,7 +101,7 @@ class ReplayInstrumentation(
GlobalScope.launch(DispatcherProviderHolder.current.default) {
_captureSource.captureFlow.collect { capture ->
_otelLogger.logRecordBuilder()
.setAttribute("event.domain", "media")
.setAttribute("event.domain", EventDomain.MEDIA.wireValue)
.setAttribute("image.width", capture.origWidth.toLong())
.setAttribute("image.height", capture.origHeight.toLong())
.setAttribute("image.data", capture.imageBase64)
Expand Down Expand Up @@ -122,7 +132,7 @@ class ReplayInstrumentation(
// Use the last position's timestamp for the log record timestamp
val logTimestamp = interaction.positions.last().timestamp
_otelLogger.logRecordBuilder()
.setAttribute("event.domain", "interaction")
.setAttribute("event.domain", EventDomain.INTERACTION.wireValue)
.setAttribute("android.action", interaction.action)
.setAttribute("screen.coords", positionsJson)
.setAttribute("session.id", interaction.session)
Expand Down Expand Up @@ -184,12 +194,20 @@ class ReplayInstrumentation(
override fun getLoggerScopeName(): String = INSTRUMENTATION_SCOPE_NAME

override fun getLogRecordProcessor(credential: String): LogRecordProcessor {
val exporter = RRwebGraphQLReplayLogExporter(
val initialIdentifyItemPayload = IdentifyItemPayload.from(
contextFriendlyName = observabilityContext.options.contextFriendlyName,
resourceAttributes = observabilityContext.options.resourceAttributes,
sessionId = null //initial payload is not part SR RRWeb event
)

val exporter = SessionReplayExporter(
organizationVerboseId = credential, // the SDK credential is used as the organization ID intentionally
backendUrl = observabilityContext.options.backendUrl,
serviceName = observabilityContext.options.serviceName,
serviceVersion = observabilityContext.options.serviceVersion,
initialIdentifyItemPayload = initialIdentifyItemPayload
)
_exporter = exporter

return BatchLogRecordProcessor.builder(exporter)
.setMaxQueueSize(BATCH_MAX_QUEUE_SIZE)
Expand All @@ -198,4 +216,32 @@ class ReplayInstrumentation(
.setMaxExportBatchSize(BATCH_MAX_EXPORT_SIZE)
.build()
}

suspend fun identifySession(
ldContext: LDContext,
timestamp: Long = System.currentTimeMillis()
) {
if (!this::_sessionManager.isInitialized || !this::_otelLogger.isInitialized) {
observabilityContext.logger.warn("identifySession called before ReplayInstrumentation was installed; skipping.")
return
}

val sessionId = _sessionManager.getSessionId()
val event = IdentifyItemPayload.from(
contextFriendlyName = observabilityContext.options.contextFriendlyName,
resourceAttributes = observabilityContext.options.resourceAttributes,
ldContext = ldContext,
timestamp = timestamp,
sessionId = sessionId
)

_exporter?.identifyEventAndUpdate(event)

_otelLogger.logRecordBuilder()
.setAllAttributes(observabilityContext.options.resourceAttributes)
.setAttribute("event.domain", EventDomain.IDENTIFY.wireValue)
.setAttribute("session.id", sessionId)
.setTimestamp(timestamp, TimeUnit.MILLISECONDS)
.emit()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.launchdarkly.observability.replay.exporter

enum class EventDomain(val wireValue: String) {
MEDIA("media"),
INTERACTION("interaction"),
IDENTIFY("identify");

companion object {
fun fromString(value: String?): EventDomain? {
return values().firstOrNull { it.wireValue == value }
}
}
}


Loading
Loading