Skip to content

Commit b6e8084

Browse files
authored
refactor: O11Y-957 - Refactor error handling and state management in replay exporter (#348)
## Summary Replaces try/catch-based error handling in SessionReplayApiService with a throwOnErrors helper that throws a custom exception on GraphQL errors. Refactors SessionReplayEventGenerator to support state snapshot/restore for reliable retries, and updates SessionReplayExporter to snapshot and restore state on failure, ensuring event and payload ID consistency. Also improves naming and documentation for clarity. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Improves reliability and observability of replay and sampling flows by adding structured logging, centralized GraphQL error handling, and resilient state management with rollback on failures. > > - **`GraphQLClient`**: accepts `LDLogger`; logs GraphQL errors via `logErrors`; all call sites (e.g., `InstrumentationManager`, `SessionReplayExporter`) pass the logger > - **Sampling**: `SamplingApiService` minor cleanup (const path, remove local error printing) > - **Replay API**: replace try/catch logging with `throwOnErrors(...)` and a `SessionReplayApiException`; all operations now surface GraphQL errors > - **Event generation**: `SessionReplayEventGenerator` adds sid/canvas state snapshot/restore (`getState`/`restoreState`), renames fields for clarity > - **Exporter**: `SessionReplayExporter` now takes a logger, snapshots and restores internal state on exceptions (including payload IDs and generator state) for consistent retries; logs identify failures; minor naming tidy-ups > - **Replay instrumentation**: wires logger into `SessionReplayExporter` > - **Tests**: updated to accommodate logger in `GraphQLClient`/`SessionReplayExporter` and new behaviors > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8c79eda. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c9b0a94 commit b6e8084

9 files changed

Lines changed: 183 additions & 151 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ class InstrumentationManager(
7979
private var otelLogger: Logger
8080
private var otelTracer: Tracer
8181
private var customSampler = CustomSampler()
82-
private val graphqlClient = GraphQLClient(observabilityOptions.backendUrl)
82+
private val graphqlClient = GraphQLClient(
83+
endpoint = observabilityOptions.backendUrl,
84+
logger = logger
85+
)
8386
private val samplingApiService = SamplingApiService(graphqlClient)
8487
private var telemetryInspector: TelemetryInspector? = null
8588
private var spanProcessor: SpanProcessor? = null

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.launchdarkly.observability.network
22

3+
import com.launchdarkly.logging.LDLogger
34
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
45
import kotlinx.coroutines.withContext
56
import kotlinx.serialization.KSerializer
@@ -47,6 +48,7 @@ interface UrlConnectionProvider {
4748
class GraphQLClient(
4849
val endpoint: String,
4950
val headers: Map<String, String> = emptyMap(),
51+
private val logger: LDLogger,
5052
private val json: Json = Json {
5153
isLenient = true
5254
ignoreUnknownKeys = true
@@ -77,7 +79,7 @@ class GraphQLClient(
7779
compress: Boolean = true
7880
): GraphQLResponse<T> = withContext(DispatcherProviderHolder.current.io) {
7981
var connection: HttpURLConnection? = null
80-
try {
82+
val response: GraphQLResponse<T> = try {
8183
val query = loadQuery(queryFileName)
8284
val request = GraphQLRequest(
8385
query = query,
@@ -136,6 +138,9 @@ class GraphQLClient(
136138
} finally {
137139
connection?.disconnect()
138140
}
141+
142+
logErrors(response)
143+
response
139144
}
140145

141146
/**
@@ -156,4 +161,14 @@ class GraphQLClient(
156161
}
157162
return byteStream.toByteArray()
158163
}
164+
165+
private fun logErrors(response: GraphQLResponse<*>) {
166+
val errors = response.errors?.takeIf { it.isNotEmpty() } ?: return
167+
errors.forEach { error ->
168+
logger.error("GraphQLClient error: ${error.message}")
169+
error.locations?.forEach { location ->
170+
logger.error(" at line ${location.line}, column ${location.column}")
171+
}
172+
}
173+
}
159174
}

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/SamplingApiService.kt

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class SamplingApiService(
1111
) {
1212

1313
companion object {
14-
private val GET_SAMPLING_CONFIG_QUERY_FILE_PATH = "graphql/GetSamplingConfigQuery.graphql"
14+
private const val GET_SAMPLING_CONFIG_QUERY_FILE_PATH = "graphql/GetSamplingConfigQuery.graphql"
1515
}
1616

1717
/**
@@ -29,7 +29,6 @@ class SamplingApiService(
2929
)
3030

3131
if (response.errors?.isNotEmpty() == true) {
32-
printErrors(response)
3332
return null
3433
}
3534

@@ -40,12 +39,4 @@ class SamplingApiService(
4039
}
4140
}
4241

43-
private fun printErrors(response: GraphQLResponse<SamplingResponse>) {
44-
response.errors?.forEach { error ->
45-
println("GraphQL Error: ${error.message}")
46-
error.locations?.forEach { location ->
47-
println(" at line ${location.line}, column ${location.column}")
48-
}
49-
}
50-
}
5142
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ class ReplayInstrumentation(
102102
backendUrl = observabilityContext.options.backendUrl,
103103
serviceName = observabilityContext.options.serviceName,
104104
serviceVersion = observabilityContext.options.serviceVersion,
105-
initialIdentifyItemPayload = initialIdentifyItemPayload
105+
initialIdentifyItemPayload = initialIdentifyItemPayload,
106+
logger = logger
106107
)
107108
this@ReplayInstrumentation.exporter = exporter
108109
batchWorker.addExporter(exporter)
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.launchdarkly.observability.replay.exporter
22

3-
import android.util.Log
43
import com.launchdarkly.observability.BuildConfig
54
import com.launchdarkly.observability.network.GraphQLClient
65
import com.launchdarkly.observability.network.GraphQLResponse
@@ -16,7 +15,6 @@ import kotlinx.serialization.json.JsonNull
1615
import kotlinx.serialization.json.JsonObject
1716
import kotlinx.serialization.json.JsonPrimitive
1817

19-
// TODO: O11Y-627 - Refactor logging handling in this class
2018
class SessionReplayApiService(
2119
private val graphqlClient: GraphQLClient,
2220
val serviceName: String,
@@ -38,37 +36,29 @@ class SessionReplayApiService(
3836
* @param organizationVerboseId The organization verbose ID
3937
*/
4038
suspend fun initializeReplaySession(organizationVerboseId: String, sessionSecureId: String) {
41-
try {
42-
val variables = mapOf(
43-
"organization_verbose_id" to JsonPrimitive(organizationVerboseId),
44-
"session_secure_id" to JsonPrimitive(sessionSecureId),
45-
"enable_strict_privacy" to JsonPrimitive(false),
46-
"enable_recording_network_contents" to JsonPrimitive(false),
47-
"clientVersion" to JsonPrimitive(BuildConfig.OBSERVABILITY_SDK_VERSION),
48-
"firstloadVersion" to JsonPrimitive(BuildConfig.OBSERVABILITY_SDK_VERSION),
49-
"clientConfig" to JsonPrimitive("{}"), // TODO: O11Y-631 - remove hardcoded params
50-
"environment" to JsonPrimitive(""), // TODO: O11Y-631 - remove hardcoded params
51-
"appVersion" to JsonPrimitive(serviceVersion),
52-
"serviceName" to JsonPrimitive(serviceName),
53-
"fingerprint" to JsonPrimitive(""), // TODO: O11Y-631 - remove hardcoded params
54-
"client_id" to JsonPrimitive(""), // TODO: O11Y-631 - remove hardcoded params
55-
"network_recording_domains" to JsonArray(emptyList()),
56-
"privacy_setting" to JsonPrimitive("none"), // TODO: O11Y-631 - remove hardcoded params
57-
"id" to JsonPrimitive("") // TODO: O11Y-631 - remove hardcoded params
58-
)
59-
val response = graphqlClient.execute(
60-
queryFileName = INITIALIZE_REPLAY_SESSION_QUERY_FILE_PATH,
61-
variables = variables,
62-
dataSerializer = InitializeReplaySessionResponse.serializer()
63-
)
64-
65-
// TODO: O11Y-624 - check graphql requests can generate errors when necessary and add error handling
66-
if (response.errors?.isNotEmpty() == true) {
67-
printErrors(response)
68-
}
69-
} catch (e: Exception) {
70-
Log.e("SessionReplayApiService", "Error initializing replay session: ${e.message}")
71-
}
39+
val variables = mapOf(
40+
"organization_verbose_id" to JsonPrimitive(organizationVerboseId),
41+
"session_secure_id" to JsonPrimitive(sessionSecureId),
42+
"enable_strict_privacy" to JsonPrimitive(false),
43+
"enable_recording_network_contents" to JsonPrimitive(false),
44+
"clientVersion" to JsonPrimitive(BuildConfig.OBSERVABILITY_SDK_VERSION),
45+
"firstloadVersion" to JsonPrimitive(BuildConfig.OBSERVABILITY_SDK_VERSION),
46+
"clientConfig" to JsonPrimitive("{}"), // TODO: O11Y-631 - remove hardcoded params
47+
"environment" to JsonPrimitive(""), // TODO: O11Y-631 - remove hardcoded params
48+
"appVersion" to JsonPrimitive(serviceVersion),
49+
"serviceName" to JsonPrimitive(serviceName),
50+
"fingerprint" to JsonPrimitive(""), // TODO: O11Y-631 - remove hardcoded params
51+
"client_id" to JsonPrimitive(""), // TODO: O11Y-631 - remove hardcoded params
52+
"network_recording_domains" to JsonArray(emptyList()),
53+
"privacy_setting" to JsonPrimitive("none"), // TODO: O11Y-631 - remove hardcoded params
54+
"id" to JsonPrimitive("") // TODO: O11Y-631 - remove hardcoded params
55+
)
56+
val response = graphqlClient.execute(
57+
queryFileName = INITIALIZE_REPLAY_SESSION_QUERY_FILE_PATH,
58+
variables = variables,
59+
dataSerializer = InitializeReplaySessionResponse.serializer()
60+
)
61+
throwOnErrors(response, "initializeReplaySession")
7262
}
7363

7464
/**
@@ -82,25 +72,19 @@ class SessionReplayApiService(
8272
userIdentifier: String = "", // TODO: O11Y-631 - remove hardcoded params
8373
userObject: JsonElement = JsonNull
8474
) {
85-
try {
86-
val variables = mapOf(
87-
"session_secure_id" to JsonPrimitive(sessionSecureId),
88-
"user_identifier" to JsonPrimitive(userIdentifier),
89-
"user_object" to userObject
90-
)
75+
val variables = mapOf(
76+
"session_secure_id" to JsonPrimitive(sessionSecureId),
77+
"user_identifier" to JsonPrimitive(userIdentifier),
78+
"user_object" to userObject
79+
)
9180

92-
val response = graphqlClient.execute(
93-
queryFileName = IDENTIFY_REPLAY_SESSION_QUERY_FILE_PATH,
94-
variables = variables,
95-
dataSerializer = IdentifySessionResponse.serializer()
96-
)
81+
val response = graphqlClient.execute(
82+
queryFileName = IDENTIFY_REPLAY_SESSION_QUERY_FILE_PATH,
83+
variables = variables,
84+
dataSerializer = IdentifySessionResponse.serializer()
85+
)
9786

98-
if (response.errors?.isNotEmpty() == true) {
99-
printErrors(response)
100-
}
101-
} catch (e: Exception) {
102-
Log.e("SessionReplayApiService", "Error identifying replay session: ${e.message}")
103-
}
87+
throwOnErrors(response, "identifyReplaySession")
10488
}
10589

10690
/**
@@ -126,40 +110,33 @@ class SessionReplayApiService(
126110
* @param events The list of events to push
127111
*/
128112
suspend fun pushPayload(sessionSecureId: String, payloadId: String, events: List<Event>) {
129-
try {
130-
val variables = mapOf(
131-
"session_secure_id" to JsonPrimitive(sessionSecureId),
132-
"payload_id" to JsonPrimitive(payloadId),
133-
"events" to json.encodeToJsonElement(
134-
ReplayEventsInput.serializer(),
135-
ReplayEventsInput(events)
136-
),
137-
"messages" to JsonPrimitive("{\"messages\":[]}"),
138-
"resources" to JsonPrimitive("{\"resources\":[]}"),
139-
"web_socket_events" to JsonPrimitive("{\"webSocketEvents\":[]}"),
140-
"errors" to JsonArray(emptyList()),
141-
)
113+
val variables = mapOf(
114+
"session_secure_id" to JsonPrimitive(sessionSecureId),
115+
"payload_id" to JsonPrimitive(payloadId),
116+
"events" to json.encodeToJsonElement(
117+
ReplayEventsInput.serializer(),
118+
ReplayEventsInput(events)
119+
),
120+
"messages" to JsonPrimitive("{\"messages\":[]}"),
121+
"resources" to JsonPrimitive("{\"resources\":[]}"),
122+
"web_socket_events" to JsonPrimitive("{\"webSocketEvents\":[]}"),
123+
"errors" to JsonArray(emptyList()),
124+
)
142125

143-
val response = graphqlClient.execute(
144-
queryFileName = PUSH_PAYLOAD_QUERY_FILE_PATH,
145-
variables = variables,
146-
dataSerializer = PushPayloadResponse.serializer()
147-
)
126+
val response = graphqlClient.execute(
127+
queryFileName = PUSH_PAYLOAD_QUERY_FILE_PATH,
128+
variables = variables,
129+
dataSerializer = PushPayloadResponse.serializer()
130+
)
148131

149-
if (response.errors?.isNotEmpty() == true) {
150-
printErrors(response)
151-
}
152-
} catch (e: Exception) {
153-
Log.e("SessionReplayApiService", "Error pushing payload: ${e.message}")
154-
}
132+
throwOnErrors(response, "pushPayload")
155133
}
156134

157-
private fun <T> printErrors(response: GraphQLResponse<T>) {
158-
response.errors?.forEach { error ->
159-
Log.e("SessionReplayApiService", "GraphQL Error: ${error.message}")
160-
error.locations?.forEach { location ->
161-
Log.e("SessionReplayApiService", " at line ${location.line}, column ${location.column}")
162-
}
163-
}
135+
private fun <T> throwOnErrors(response: GraphQLResponse<T>, operation: String) {
136+
val errors = response.errors?.takeIf { it.isNotEmpty() } ?: return
137+
val message = errors.joinToString("; ") { it.message }
138+
throw SessionReplayApiException("$operation failed: $message")
164139
}
165140
}
141+
142+
internal class SessionReplayApiException(message: String) : RuntimeException(message)

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

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,32 @@ import kotlinx.serialization.json.put
2222
class SessionReplayEventGenerator(
2323
private val canvasDrawEntourage: Int
2424
) {
25-
private var sidCounter = 0
26-
var generatingCanvasSize: Int = 0
25+
/**
26+
* Sequence ID for the events being generated.
27+
* Each event in a session needs a unique, monotonically increasing "sid".
28+
* This is incremented by [nextSid] for each new event.
29+
*/
30+
private var lastSid = 0
31+
var accumulatedCanvasSize: Int = 0
32+
33+
data class State(
34+
val lastSid: Int = 0,
35+
val generatingCanvasSize: Int = 0,
36+
)
2737

2838
private fun nextSid(): Int {
29-
sidCounter++
30-
return sidCounter
39+
lastSid++
40+
return lastSid
41+
}
42+
43+
fun getState(): State = State(
44+
lastSid = lastSid,
45+
generatingCanvasSize = accumulatedCanvasSize,
46+
)
47+
48+
fun restoreState(state: State) {
49+
lastSid = state.lastSid
50+
accumulatedCanvasSize = state.generatingCanvasSize
3151
}
3252

3353
/**
@@ -47,7 +67,7 @@ class SessionReplayEventGenerator(
4767
)
4868
)
4969
)
50-
generatingCanvasSize += captureEvent.imageBase64.length + canvasDrawEntourage
70+
accumulatedCanvasSize += captureEvent.imageBase64.length + canvasDrawEntourage
5171
eventsBatch.add(incrementalEvent)
5272

5373
return eventsBatch
@@ -73,7 +93,7 @@ class SessionReplayEventGenerator(
7393
)
7494
eventBatch.add(metaEvent)
7595

76-
val snapShotEvent = Event(
96+
val snapshotEvent = Event(
7797
type = EventType.FULL_SNAPSHOT,
7898
timestamp = captureEvent.timestamp,
7999
sid = nextSid(),
@@ -128,8 +148,8 @@ class SessionReplayEventGenerator(
128148
)
129149

130150
// starting again canvas size
131-
generatingCanvasSize = captureEvent.imageBase64.length + canvasDrawEntourage
132-
eventBatch.add(snapShotEvent)
151+
accumulatedCanvasSize = captureEvent.imageBase64.length + canvasDrawEntourage
152+
eventBatch.add(snapshotEvent)
133153

134154
val viewportEvent = Event(
135155
type = EventType.CUSTOM,
@@ -238,8 +258,4 @@ class SessionReplayEventGenerator(
238258
data = EventDataUnion.CustomEventDataWrapper(customData)
239259
)
240260
}
241-
242-
243261
}
244-
245-

0 commit comments

Comments
 (0)