Skip to content

Commit a895c97

Browse files
authored
feat: O11Y-374 - Add identify span in TracingHook (#232)
## Summary This PR implements the beforeIdentify and afterIdentify functions from the Hook abstract class in `EvalTracingHook` for tracing identify events through an span. It also makes the OpenTelemetry instance global in `InstrumentationManager` to ensure it's accessible throughout the application. Additionally, the `BaseApplication.kt` in the e2e test app has been updated to call `LDClient.get().identify(context)` after initialization for testing purposes. ## How did you test this change? Unit test ## Are there any deployment considerations? No
1 parent 640aa3e commit a895c97

File tree

8 files changed

+305
-16
lines changed

8 files changed

+305
-16
lines changed

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ open class BaseApplication : Application() {
6262
.build()
6363

6464
LDClient.init(this@BaseApplication, ldConfig, context)
65-
6665
telemetryInspector = observabilityPlugin.getTelemetryInspector()
6766
}
6867
}

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,14 @@ class MainActivity : ComponentActivity() {
3838
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
3939
var customLogText by remember { mutableStateOf("") }
4040
var customSpanText by remember { mutableStateOf("") }
41+
var customContextKey by remember { mutableStateOf("") }
4142

4243
Column(
4344
modifier = Modifier
4445
.padding(innerPadding)
4546
.padding(16.dp)
4647
.verticalScroll(rememberScrollState())
4748
) {
48-
4949
Text(
5050
text = "Hello Telemetry",
5151
modifier = Modifier.padding(bottom = 16.dp)
@@ -137,6 +137,23 @@ class MainActivity : ComponentActivity() {
137137
) {
138138
Text("Send custom span")
139139
}
140+
141+
Spacer(modifier = Modifier.height(16.dp))
142+
143+
OutlinedTextField(
144+
value = customContextKey,
145+
onValueChange = { customContextKey = it },
146+
label = { Text("LD context key") },
147+
modifier = Modifier.padding(8.dp)
148+
)
149+
Button(
150+
onClick = {
151+
viewModel.identifyLDContext(customContextKey)
152+
},
153+
modifier = Modifier.padding(8.dp)
154+
) {
155+
Text("Identify LD Context")
156+
}
140157
}
141158
}
142159
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel
44
import androidx.lifecycle.viewModelScope
55
import com.launchdarkly.observability.interfaces.Metric
66
import com.launchdarkly.observability.sdk.LDObserve
7+
import com.launchdarkly.sdk.ContextKind
8+
import com.launchdarkly.sdk.LDContext
9+
import com.launchdarkly.sdk.android.LDClient
710
import io.opentelemetry.api.common.AttributeKey
811
import io.opentelemetry.api.common.Attributes
912
import io.opentelemetry.api.logs.Severity
@@ -94,6 +97,14 @@ class ViewModel : ViewModel() {
9497
}
9598
}
9699

100+
fun identifyLDContext(contextKey: String = "test-context-key") {
101+
val context = LDContext.builder(ContextKind.DEFAULT, contextKey)
102+
.name("test-context-name")
103+
.build()
104+
105+
LDClient.get().identify(context)
106+
}
107+
97108
private fun sendOkHttpRequest() {
98109
// Create HTTP client
99110
val client = OkHttpClient()

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/InstrumentationManager.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,13 @@ class InstrumentationManager(
290290
*/
291291
fun getTelemetryInspector(): TelemetryInspector? = telemetryInspector
292292

293+
/**
294+
* Returns the tracer instance for creating spans.
295+
*
296+
* @return Tracer instance
297+
*/
298+
fun getTracer(): Tracer = otelTracer
299+
293300
/**
294301
* Flushes all pending telemetry data (traces, logs, metrics).
295302
* @return true if all flush operations succeeded, false otherwise

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityClient.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ class ObservabilityClient : Observe {
8787
return instrumentationManager.getTelemetryInspector()
8888
}
8989

90+
/**
91+
* Returns the tracer instance for creating spans.
92+
*
93+
* @return Tracer instance
94+
*/
95+
fun getTracer() = instrumentationManager.getTracer()
96+
9097
override fun flush(): Boolean {
9198
return instrumentationManager.flush()
9299
}

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
@@ -95,7 +95,7 @@ class Observability(
9595

9696
override fun getHooks(metadata: EnvironmentMetadata?): MutableList<Hook> {
9797
return Collections.singletonList(
98-
EvalTracingHook(true, true)
98+
TracingHook(withSpans = true, withValue = true) { observabilityClient?.getTracer() }
9999
)
100100
}
101101

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,47 @@ import com.launchdarkly.sdk.EvaluationDetail
44
import com.launchdarkly.sdk.LDValue
55
import com.launchdarkly.sdk.android.integrations.EvaluationSeriesContext
66
import com.launchdarkly.sdk.android.integrations.Hook
7+
import com.launchdarkly.sdk.android.integrations.IdentifySeriesContext
8+
import com.launchdarkly.sdk.android.integrations.IdentifySeriesResult
79
import io.opentelemetry.api.GlobalOpenTelemetry
810
import io.opentelemetry.api.common.Attributes
911
import io.opentelemetry.api.trace.Span
1012
import io.opentelemetry.api.trace.Tracer
1113
import io.opentelemetry.context.Context
14+
import io.opentelemetry.context.Scope
1215

1316
/**
14-
* This class is a hook implementation for recording flag evaluation events
17+
* This class is a hook implementation for recording flag evaluation and identify events
1518
* on spans.
1619
*/
17-
class EvalTracingHook
20+
class TracingHook
21+
1822
/**
19-
* Creates an [EvalTracingHook]
23+
* Creates an [TracingHook]
2024
*
2125
* @param withSpans will include child spans for the various hook series when they happen
2226
* @param withValue will include the value of the feature flag in the recorded evaluation events
23-
*/ internal constructor(private val withSpans: Boolean, private val withValue: Boolean) :
24-
Hook(HOOK_NAME) {
25-
// TODO: O11Y-374: add before/after identify support
27+
* @param tracerProvider optional tracer provider function, if not provided will use GlobalOpenTelemetry
28+
*/
29+
internal constructor(
30+
private val withSpans: Boolean,
31+
private val withValue: Boolean,
32+
private val tracerProvider: (() -> Tracer?)
33+
) : Hook(HOOK_NAME) {
34+
2635
override fun beforeEvaluation(
2736
seriesContext: EvaluationSeriesContext,
2837
seriesData: Map<String, Any>
2938
): Map<String, Any> {
39+
val tracer = tracerProvider.invoke() ?: GlobalOpenTelemetry.get().getTracer(INSTRUMENTATION_NAME)
3040
return beforeEvaluationInternal(
31-
GlobalOpenTelemetry.get().getTracer(INSTRUMENTATION_NAME),
41+
tracer,
3242
seriesContext,
3343
seriesData
3444
)
3545
}
3646

37-
fun beforeEvaluationInternal(
47+
private fun beforeEvaluationInternal(
3848
tracer: Tracer,
3949
seriesContext: EvaluationSeriesContext,
4050
seriesData: Map<String, Any>
@@ -52,7 +62,7 @@ class EvalTracingHook
5262
builder.setAllAttributes(attrBuilder.build())
5363
val span = builder.startSpan()
5464
val retSeriesData: MutableMap<String, Any> = HashMap(seriesData)
55-
retSeriesData[DATA_KEY_SPAN] = span
65+
retSeriesData[DATA_KEY_FEATURE_FLAG_SPAN] = span
5666
return retSeriesData
5767
}
5868

@@ -61,7 +71,7 @@ class EvalTracingHook
6171
seriesData: Map<String, Any>,
6272
evaluationDetail: EvaluationDetail<LDValue>
6373
): Map<String, Any> {
64-
val value = seriesData[DATA_KEY_SPAN]
74+
val value = seriesData[DATA_KEY_FEATURE_FLAG_SPAN]
6575
if (value is Span) {
6676
value.end()
6777
}
@@ -85,21 +95,90 @@ class EvalTracingHook
8595

8696
// Here we make best effort the log the event and let the library handle the "no current span" case; which at the
8797
// time of writing this, it does handle.
88-
Span.current().addEvent(EVENT_NAME, attrBuilder.build())
98+
Span.current().addEvent(FEATURE_FLAG_EVENT_NAME, attrBuilder.build())
99+
return seriesData
100+
}
101+
102+
override fun beforeIdentify(
103+
seriesContext: IdentifySeriesContext,
104+
seriesData: Map<String, Any>
105+
): Map<String, Any> {
106+
val tracer = tracerProvider.invoke() ?: GlobalOpenTelemetry.get().getTracer(INSTRUMENTATION_NAME)
107+
return beforeIdentifyInternal(
108+
tracer,
109+
seriesContext,
110+
seriesData
111+
)
112+
}
113+
114+
private fun beforeIdentifyInternal(
115+
tracer: Tracer,
116+
seriesContext: IdentifySeriesContext,
117+
seriesData: Map<String, Any>
118+
): Map<String, Any> {
119+
if (!withSpans) {
120+
return seriesData
121+
}
122+
123+
val spanBuilder = tracer.spanBuilder(SEMCONV_IDENTIFY_SPAN_NAME)
124+
.setParent(Context.current().with(Span.current()))
125+
val span = spanBuilder.startSpan()
126+
span.addEvent(IDENTIFY_EVENT_START)
127+
128+
val attrBuilder = Attributes.builder()
129+
attrBuilder.put(SEMCONV_IDENTIFY_CONTEXT_ID, seriesContext.context.fullyQualifiedKey)
130+
seriesContext.timeout?.let {
131+
attrBuilder.put(SEMCONV_IDENTIFY_TIMEOUT, it.toLong())
132+
}
133+
span.setAllAttributes(attrBuilder.build())
134+
135+
return HashMap(seriesData).apply {
136+
this[DATA_KEY_IDENTIFY_SPAN] = span
137+
this[DATA_KEY_IDENTIFY_SCOPE] = span.makeCurrent()
138+
}
139+
}
140+
141+
override fun afterIdentify(
142+
seriesContext: IdentifySeriesContext,
143+
seriesData: Map<String, Any>,
144+
result: IdentifySeriesResult
145+
): Map<String, Any> {
146+
val span = seriesData[DATA_KEY_IDENTIFY_SPAN] as? Span
147+
val scope = seriesData[DATA_KEY_IDENTIFY_SCOPE] as? Scope
148+
149+
span?.let {
150+
val attrBuilder = Attributes.builder()
151+
attrBuilder.put(SEMCONV_IDENTIFY_EVENT_RESULT_VALUE, result.status.name)
152+
153+
it.addEvent(IDENTIFY_EVENT_FINISH, attrBuilder.build())
154+
it.end()
155+
}
156+
157+
// Closes the current span scope and restores the previous span context
158+
scope?.close()
159+
89160
return seriesData
90161
}
91162

92163
companion object {
93164
const val PROVIDER_NAME: String = "LaunchDarkly"
94165
const val HOOK_NAME: String = "LaunchDarkly Evaluation Tracing Hook"
95166
const val INSTRUMENTATION_NAME: String = "com.launchdarkly.observability"
96-
const val DATA_KEY_SPAN: String = "variationSpan"
97-
const val EVENT_NAME: String = "feature_flag"
167+
const val DATA_KEY_FEATURE_FLAG_SPAN: String = "variationSpan"
168+
const val FEATURE_FLAG_EVENT_NAME: String = "feature_flag"
98169
const val SEMCONV_FEATURE_FLAG_CONTEXT_ID: String = "feature_flag.context.id"
99170
const val SEMCONV_FEATURE_FLAG_PROVIDER_NAME: String = "feature_flag.provider.name"
100171
const val SEMCONV_FEATURE_FLAG_KEY: String = "feature_flag.key"
101172
const val SEMCONV_FEATURE_FLAG_RESULT_VALUE: String = "feature_flag.result.value"
102173
const val CUSTOM_FEATURE_FLAG_RESULT_VARIATION_INDEX: String = "feature_flag.result.variationIndex"
103174
const val CUSTOM_FEATURE_FLAG_RESULT_REASON_IN_EXPERIMENT: String = "feature_flag.result.reason.inExperiment"
175+
const val SEMCONV_IDENTIFY_SPAN_NAME: String = "Identify"
176+
const val SEMCONV_IDENTIFY_CONTEXT_ID: String = "identify.context.id"
177+
const val SEMCONV_IDENTIFY_TIMEOUT: String = "identify.timeout"
178+
const val IDENTIFY_EVENT_START: String = "start"
179+
const val IDENTIFY_EVENT_FINISH: String = "finish"
180+
const val SEMCONV_IDENTIFY_EVENT_RESULT_VALUE: String = "result"
181+
const val DATA_KEY_IDENTIFY_SPAN: String = "identifySpan"
182+
const val DATA_KEY_IDENTIFY_SCOPE: String = "identifyScope"
104183
}
105184
}

0 commit comments

Comments
 (0)