diff --git a/sdk/@launchdarkly/observability-android/gradle/libs.versions.toml b/sdk/@launchdarkly/observability-android/gradle/libs.versions.toml index c391fdfc1e..a7a73252dd 100644 --- a/sdk/@launchdarkly/observability-android/gradle/libs.versions.toml +++ b/sdk/@launchdarkly/observability-android/gradle/libs.versions.toml @@ -3,4 +3,3 @@ [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.0.21" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.0.21" } diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index d3c4a6bdeb..7f803fc092 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -6,7 +6,6 @@ plugins { // Apply the Kotlin Android plugin for Android-compatible Kotlin support. alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.serialization) // Apply Dokka plugin for documentation generation id("org.jetbrains.dokka") version "2.1.0" @@ -73,9 +72,6 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") - // Kotlinx serialization for JSON parsing - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") - // TODO: revise these versions to be as old as usable for compatibility implementation("io.opentelemetry:opentelemetry-api:1.51.0") implementation("io.opentelemetry:opentelemetry-sdk:1.51.0") @@ -103,6 +99,8 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + // android.jar only ships org.json stubs, so JVM unit tests need a real implementation. + testImplementation("org.json:json:20260814") testImplementation("io.mockk:mockk:1.14.5") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/json/JsonByteWriter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/json/JsonByteWriter.kt new file mode 100644 index 0000000000..b9269b5bbc --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/json/JsonByteWriter.kt @@ -0,0 +1,377 @@ +package com.launchdarkly.observability.json + +/** + * Appends compact JSON, already UTF-8 encoded, to a byte array. Callers are responsible for well-formed + * nesting: every [name] is followed by exactly one value, and every container that is begun is ended. + * + * A string is escaped and encoded in one pass straight into the array that is sent, with no UTF-16 + * buffer, charset encoder or intermediate copies between a payload and its bytes. + * + * Not thread-safe. One writer serves one payload on one thread. + */ +internal class JsonByteWriter(initialCapacity: Int = DEFAULT_CAPACITY) { + private var bytes = ByteArray(maxOf(initialCapacity, MIN_CAPACITY)) + + var size: Int = 0 + private set + + /** + * Whether the next value written needs a comma in front of it. + * + * Writing a name clears it, so the value that follows the colon is not preceded by one; writing a + * value or closing a container sets it, so whatever comes next is. + */ + private var needsSeparator = false + + fun reset() { + size = 0 + needsSeparator = false + } + + /** A copy of everything written since the last [reset], independent of this writer's buffer. */ + fun toByteArray(): ByteArray = bytes.copyOf(size) + + /** A copy of the bytes written since [offset], independent of this writer's buffer. */ + fun bytesFrom(offset: Int): ByteArray = bytes.copyOfRange(offset, size) + + /** Everything written since the last [reset], decoded back into a string. */ + fun toUtf8String(): String = String(bytes, 0, size, Charsets.UTF_8) + + /** Appends JSON that was produced earlier, in the position a value would go. */ + fun writeRaw(raw: ByteArray): JsonByteWriter { + separate() + ensure(raw.size) + raw.copyInto(bytes, size) + size += raw.size + needsSeparator = true + return this + } + + // Structure + + fun beginObject(): JsonByteWriter { + separate() + append('{') + needsSeparator = false + return this + } + + fun endObject(): JsonByteWriter { + append('}') + needsSeparator = true + return this + } + + fun beginArray(): JsonByteWriter { + separate() + append('[') + needsSeparator = false + return this + } + + fun endArray(): JsonByteWriter { + append(']') + needsSeparator = true + return this + } + + fun name(name: String): JsonByteWriter { + separate() + writeQuoted(name) + append(':') + needsSeparator = false + return this + } + + // Values + + fun value(value: String?): JsonByteWriter { + if (value == null) { + return nullValue() + } + separate() + writeQuoted(value) + needsSeparator = true + return this + } + + fun value(value: Boolean): JsonByteWriter { + separate() + append(if (value) TRUE else FALSE) + needsSeparator = true + return this + } + + fun value(value: Int): JsonByteWriter = value(value.toLong()) + + fun value(value: Long): JsonByteWriter { + separate() + writeLong(value) + needsSeparator = true + return this + } + + /** Written as [Double.toString] spells it. JSON has no spelling for a non-finite number, so one is refused. */ + fun value(value: Double): JsonByteWriter { + require(value.isFinite()) { "Numeric values must be finite, but was $value" } + separate() + appendAscii(value.toString()) + needsSeparator = true + return this + } + + fun nullValue(): JsonByteWriter { + separate() + append(NULL) + needsSeparator = true + return this + } + + /** + * Writes a value whose type is only known at runtime: `null`, [String], [Boolean], any [Number], a + * [Map] (keys by their `toString`), an [Iterable] or [Array], or a [JsonWritable]. + */ + fun anyValue(value: Any?): JsonByteWriter { + when (value) { + null -> nullValue() + is String -> value(value) + is Boolean -> value(value) + is Int -> value(value.toLong()) + is Long -> value(value) + is Short -> value(value.toLong()) + is Byte -> value(value.toLong()) + is Double -> value(value) + is Float -> { + require(value.isFinite()) { "Numeric values must be finite, but was $value" } + separate() + appendAscii(value.toString()) + needsSeparator = true + } + is Number -> value(value.toDouble()) + is JsonWritable -> value.writeJson(this) + is Map<*, *> -> { + beginObject() + for ((key, member) in value) { + name(key.toString()) + anyValue(member) + } + endObject() + } + is Iterable<*> -> { + beginArray() + for (element in value) { + anyValue(element) + } + endArray() + } + is Array<*> -> { + beginArray() + for (element in value) { + anyValue(element) + } + endArray() + } + else -> throw IllegalArgumentException("Cannot write a ${value::class.java.name} as JSON") + } + return this + } + + // Optional members, left out entirely - name and all - when the value is null + + fun optional(name: String, value: String?): JsonByteWriter = + if (value == null) this else name(name).value(value) + + fun optional(name: String, value: Boolean?): JsonByteWriter = + if (value == null) this else name(name).value(value) + + fun optional(name: String, value: Int?): JsonByteWriter = + if (value == null) this else name(name).value(value) + + fun optional(name: String, value: Long?): JsonByteWriter = + if (value == null) this else name(name).value(value) + + fun optional(name: String, value: Double?): JsonByteWriter = + if (value == null) this else name(name).value(value) + + // Primitives + + private fun separate() { + if (needsSeparator) { + append(',') + } + } + + private fun append(ascii: Char) { + ensure(1) + bytes[size++] = ascii.code.toByte() + } + + private fun append(literal: ByteArray) { + ensure(literal.size) + literal.copyInto(bytes, size) + size += literal.size + } + + private fun appendAscii(ascii: String) { + val length = ascii.length + ensure(length) + for (i in 0 until length) { + bytes[size++] = ascii[i].code.toByte() + } + } + + /** Digits are written backwards and then flipped in place, so no scratch buffer is allocated per number. */ + private fun writeLong(value: Long) { + if (value == Long.MIN_VALUE) { + append(LONG_MIN_VALUE) + return + } + ensure(MAX_LONG_LENGTH) + var remaining = value + if (remaining < 0) { + bytes[size++] = '-'.code.toByte() + remaining = -remaining + } + if (remaining == 0L) { + bytes[size++] = '0'.code.toByte() + return + } + val start = size + while (remaining > 0) { + bytes[size++] = ('0'.code + (remaining % 10).toInt()).toByte() + remaining /= 10 + } + var lower = start + var upper = size - 1 + while (lower < upper) { + val swap = bytes[lower] + bytes[lower] = bytes[upper] + bytes[upper] = swap + lower++ + upper-- + } + } + + /** + * Escapes every control character, `"` and `\`, plus U+2028 and U+2029, which JavaScript parsers + * older than ES2019 reject inside a string literal. Encodes the rest as UTF-8, writing a surrogate + * without its other half as `?`, the replacement [String.toByteArray] would use. + */ + private fun writeQuoted(value: String) { + val length = value.length + // Room for the quotes and one byte per char is reserved up front, so the all-ASCII common case + // (base64 images included) never over-allocates. Anything wider reserves its extra bytes when + // it is reached, keeping the invariant that the rest of the string still fits at one byte each. + ensure(length + 2) + var b = bytes + var n = size + b[n++] = QUOTE + var i = 0 + while (i < length) { + val c = value[i].code + if (c >= 0x20 && c < 0x80 && c != '"'.code && c != '\\'.code) { + b[n++] = c.toByte() + i++ + continue + } + val remainingAfter = length - i - 1 + if (n + MAX_CHAR_BYTES + remainingAfter + 1 > b.size) { + size = n + ensure(MAX_CHAR_BYTES + remainingAfter + 1) + b = bytes + } + if (c < 0x80) { + n = writeEscape(b, n, c) + } else if (c < 0x800) { + b[n++] = (0xC0 or (c shr 6)).toByte() + b[n++] = (0x80 or (c and 0x3F)).toByte() + } else if (c in SURROGATE_RANGE) { + if (c <= MAX_HIGH_SURROGATE && i + 1 < length && value[i + 1].isLowSurrogate()) { + val codePoint = Character.toCodePoint(value[i], value[i + 1]) + i++ + b[n++] = (0xF0 or (codePoint shr 18)).toByte() + b[n++] = (0x80 or ((codePoint shr 12) and 0x3F)).toByte() + b[n++] = (0x80 or ((codePoint shr 6) and 0x3F)).toByte() + b[n++] = (0x80 or (codePoint and 0x3F)).toByte() + } else { + b[n++] = '?'.code.toByte() + } + } else if (c == LINE_SEPARATOR || c == PARAGRAPH_SEPARATOR) { + n = writeUnicodeEscape(b, n, c) + } else { + b[n++] = (0xE0 or (c shr 12)).toByte() + b[n++] = (0x80 or ((c shr 6) and 0x3F)).toByte() + b[n++] = (0x80 or (c and 0x3F)).toByte() + } + i++ + } + b[n++] = QUOTE + size = n + } + + private fun ensure(additional: Int) { + val required = size + additional + if (required > bytes.size) { + bytes = bytes.copyOf(maxOf(required, bytes.size * 2)) + } + } + + companion object { + const val DEFAULT_CAPACITY = 1024 + private const val MIN_CAPACITY = 16 + + /** The most bytes any one char can take: an escape such as `\u001f`. */ + private const val MAX_CHAR_BYTES = 6 + private const val MAX_LONG_LENGTH = 20 + private const val MAX_HIGH_SURROGATE = 0xDBFF + private const val LINE_SEPARATOR = 0x2028 + private const val PARAGRAPH_SEPARATOR = 0x2029 + private val SURROGATE_RANGE = 0xD800..0xDFFF + + private const val QUOTE = '"'.code.toByte() + private val TRUE = "true".toByteArray(Charsets.US_ASCII) + private val FALSE = "false".toByteArray(Charsets.US_ASCII) + private val NULL = "null".toByteArray(Charsets.US_ASCII) + private val LONG_MIN_VALUE = Long.MIN_VALUE.toString().toByteArray(Charsets.US_ASCII) + private val HEX_DIGITS = "0123456789abcdef".toByteArray(Charsets.US_ASCII) + + /** Writes one JSON document with a fresh writer and returns its bytes. */ + inline fun encode(initialCapacity: Int = DEFAULT_CAPACITY, write: JsonByteWriter.() -> Unit): ByteArray = + JsonByteWriter(initialCapacity).apply(write).toByteArray() + + /** Writes one JSON document with a fresh writer and returns it as a string. */ + inline fun encodeToString(initialCapacity: Int = DEFAULT_CAPACITY, write: JsonByteWriter.() -> Unit): String = + JsonByteWriter(initialCapacity).apply(write).toUtf8String() + + private fun writeEscape(b: ByteArray, n: Int, c: Int): Int { + val escaped = when (c) { + '"'.code -> '"' + '\\'.code -> '\\' + '\t'.code -> 't' + '\b'.code -> 'b' + '\n'.code -> 'n' + '\r'.code -> 'r' + 0x0C -> 'f' + else -> return writeUnicodeEscape(b, n, c) + } + b[n] = '\\'.code.toByte() + b[n + 1] = escaped.code.toByte() + return n + 2 + } + + private fun writeUnicodeEscape(b: ByteArray, n: Int, c: Int): Int { + b[n] = '\\'.code.toByte() + b[n + 1] = 'u'.code.toByte() + b[n + 2] = HEX_DIGITS[(c shr 12) and 0xF] + b[n + 3] = HEX_DIGITS[(c shr 8) and 0xF] + b[n + 4] = HEX_DIGITS[(c shr 4) and 0xF] + b[n + 5] = HEX_DIGITS[c and 0xF] + return n + 6 + } + } +} + +/** Something that writes itself as one JSON value, for use inside [JsonByteWriter.anyValue]. */ +internal fun interface JsonWritable { + fun writeJson(writer: JsonByteWriter) +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/logs/OtlpLogExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/logs/OtlpLogExporter.kt index 513d3b7efa..fdedc3272b 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/logs/OtlpLogExporter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/logs/OtlpLogExporter.kt @@ -3,7 +3,7 @@ package com.launchdarkly.observability.logs import com.launchdarkly.observability.otlp.OtlpConfiguration import com.launchdarkly.observability.otlp.OtlpHttpClient import com.launchdarkly.observability.otlp.json.logs.JsonLogRecordAdapter -import com.launchdarkly.observability.otlp.json.logs.OtlpJsonExportLogsServiceRequest +import com.launchdarkly.observability.otlp.json.toJsonBytes import com.launchdarkly.observability.replay.transport.EventExporting import com.launchdarkly.observability.replay.transport.EventQueueItem import io.opentelemetry.sdk.logs.data.LogRecordData @@ -30,9 +30,6 @@ class OtlpLogExporter( if (logRecords.isEmpty()) return val body = JsonLogRecordAdapter.toJsonRequest(logRecords) - httpClient.send( - body = body, - serializer = OtlpJsonExportLogsServiceRequest.serializer(), - ) + httpClient.send(body.toJsonBytes()) } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/metrics/OtlpMetricExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/metrics/OtlpMetricExporter.kt index 56435194c3..d01574c92c 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/metrics/OtlpMetricExporter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/metrics/OtlpMetricExporter.kt @@ -3,7 +3,7 @@ package com.launchdarkly.observability.metrics import com.launchdarkly.observability.otlp.OtlpConfiguration import com.launchdarkly.observability.otlp.OtlpHttpClient import com.launchdarkly.observability.otlp.json.metrics.JsonMetricsAdapter -import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonExportMetricsServiceRequest +import com.launchdarkly.observability.otlp.json.toJsonBytes import com.launchdarkly.observability.replay.transport.EventExporting import com.launchdarkly.observability.replay.transport.EventQueueItem import io.opentelemetry.sdk.metrics.data.MetricData @@ -30,9 +30,6 @@ class OtlpMetricExporter( if (metrics.isEmpty()) return val body = JsonMetricsAdapter.toJsonRequest(metrics) - httpClient.send( - body = body, - serializer = OtlpJsonExportMetricsServiceRequest.serializer(), - ) + httpClient.send(body.toJsonBytes()) } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt index 1963a91cb1..1901c9b16d 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/GraphQLClient.kt @@ -2,27 +2,32 @@ package com.launchdarkly.observability.network import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.coroutines.DispatcherProviderHolder +import com.launchdarkly.observability.json.JsonByteWriter import kotlinx.coroutines.withContext -import kotlinx.serialization.KSerializer -import kotlinx.serialization.json.Json -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement +import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL import kotlin.coroutines.cancellation.CancellationException -@Serializable +/** + * @property variables Written by [JsonByteWriter.anyValue], so each value may be `null`, a [String], + * [Boolean] or [Number], or a nested [Map] or [List] of those. + */ data class GraphQLRequest( val query: String, - val variables: Map = emptyMap() -) - -/** Standard GraphQL envelope: `{ data, errors }`. */ -@Serializable -private data class GraphQLResponse( - val data: T?, - val errors: List? = null -) + val variables: Map = emptyMap() +) { + internal fun toJsonBytes(): ByteArray = JsonByteWriter.encode { + beginObject() + name("query").value(query) + // No variables is the default, and defaults are not written. + if (variables.isNotEmpty()) { + name("variables") + anyValue(variables) + } + endObject() + } +} /** * Every way a [GraphQLClient.execute] call can fail, as the failure the caller can act on: @@ -55,18 +60,30 @@ sealed class GraphQLClientException(message: String, cause: Throwable? = null) : class Decoding(cause: Throwable) : GraphQLClientException("Decoding error: ${cause.message}", cause) } -@Serializable data class GraphQLError( val message: String, val locations: List? = null, val path: List? = null, val extensions: GraphQLErrorExtensions? = null -) +) { + internal companion object { + fun fromJson(json: JSONObject) = GraphQLError( + message = json.getString("message"), + locations = json.objectListOrNull("locations", GraphQLLocation::fromJson)?.filterNotNull(), + // GraphQL paths mix field names and list indices; both are kept as their string form. + path = json.listOrNull("path") { array, index -> array.get(index).toString() }, + extensions = json.objectOrNull("extensions", GraphQLErrorExtensions::fromJson) + ) + + /** The `errors` of a GraphQL envelope, or `null` when it has none. */ + fun listFromEnvelope(envelope: JSONObject): List? = + envelope.objectListOrNull("errors", ::fromJson)?.filterNotNull()?.takeIf { it.isNotEmpty() } + } +} /** * Server-supplied metadata about a [GraphQLError]. */ -@Serializable data class GraphQLErrorExtensions( /** Machine-readable error identifier, e.g. `SESSION_REPLAY_BLOCKED_IN_REGION`. */ val code: String? = null, @@ -75,22 +92,26 @@ data class GraphQLErrorExtensions( * any status-code based classification when present. */ val retryable: Boolean? = null -) - -/** - * Errors-only view of the GraphQL envelope. The public graph also returns this shape alongside a - * non-2xx status, where `data` is absent, so error metadata can still be read from the raw body. - */ -@Serializable -private data class GraphQLErrorEnvelope( - val errors: List? = null -) +) { + internal companion object { + fun fromJson(json: JSONObject) = GraphQLErrorExtensions( + code = json.stringOrNull("code"), + retryable = json.booleanOrNull("retryable") + ) + } +} -@Serializable data class GraphQLLocation( val line: Int, val column: Int -) +) { + internal companion object { + fun fromJson(json: JSONObject) = GraphQLLocation( + line = json.getInt("line"), + column = json.getInt("column") + ) + } +} interface UrlConnectionProvider { fun openConnection(url: String): HttpURLConnection @@ -103,10 +124,6 @@ class GraphQLClient( val endpoint: String, val headers: Map = emptyMap(), private val logger: ObserveLogger, - private val json: Json = Json { - isLenient = true - ignoreUnknownKeys = true - }, private val connectionProvider: UrlConnectionProvider = object : UrlConnectionProvider { override fun openConnection(url: String): HttpURLConnection { return URL(url).openConnection() as HttpURLConnection @@ -124,14 +141,15 @@ class GraphQLClient( * Executes a GraphQL query * @param query The GraphQL query string * @param variables Query variables - * @param dataSerializer Kotlinx serialization serializer for the expected response data type - * @return the deserialized `data` of the response + * @param dataParser Builds the expected response data type from the response's `data` object. + * It should throw when the object is not the shape the operation expects. + * @return the parsed `data` of the response * @throws GraphQLClientException for every failure, including a GraphQL `errors` response */ suspend fun execute( query: String, - variables: Map = emptyMap(), - dataSerializer: KSerializer, + variables: Map = emptyMap(), + dataParser: (JSONObject) -> T, compress: Boolean = true ): T = withContext(DispatcherProviderHolder.current.io) { var connection: HttpURLConnection? = null @@ -141,8 +159,7 @@ class GraphQLClient( variables = variables ) - val requestJson = json.encodeToString(GraphQLRequest.serializer(), request) - val requestBytes = requestJson.toByteArray(Charsets.UTF_8) + val requestBytes = request.toJsonBytes() val payloadBytes = if (compress) GzipUtil.gzip(requestBytes) else requestBytes val connectionLocal = connectionProvider.openConnection(endpoint).also { connection = it } @@ -178,15 +195,22 @@ class GraphQLClient( } val responseJson = connectionLocal.inputStream.bufferedReader().use { it.readText() } - val envelope = try { - json.decodeFromString(GraphQLResponse.serializer(dataSerializer), responseJson) + val (errors, data) = try { + val envelope = JSONObject(responseJson) + GraphQLError.listFromEnvelope(envelope) to envelope.objectOrNull("data") } catch (e: Exception) { throw GraphQLClientException.Decoding(e) } - envelope.errors?.takeIf { it.isNotEmpty() }?.let { throw GraphQLClientException.GraphQLErrors(it) } + errors?.let { throw GraphQLClientException.GraphQLErrors(it) } + + data ?: throw GraphQLClientException.MissingData() - envelope.data ?: throw GraphQLClientException.MissingData() + try { + dataParser(data) + } catch (e: Exception) { + throw GraphQLClientException.Decoding(e) + } } catch (e: GraphQLClientException) { logFailure(e) throw e @@ -206,7 +230,7 @@ class GraphQLClient( * `extensions` are more specific than the status code, so they are worth reading off a rejection. */ private fun errorsIn(body: String): List? = try { - json.decodeFromString(GraphQLErrorEnvelope.serializer(), body).errors?.takeIf { it.isNotEmpty() } + GraphQLError.listFromEnvelope(JSONObject(body)) } catch (_: Exception) { null } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/JsonObjectExtensions.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/JsonObjectExtensions.kt new file mode 100644 index 0000000000..2bfd8542b3 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/JsonObjectExtensions.kt @@ -0,0 +1,29 @@ +package com.launchdarkly.observability.network + +import org.json.JSONArray +import org.json.JSONObject + +// `opt*` would turn an explicit JSON null into "null" / 0 / false, so absent and null both map to Kotlin null +// here, and a present value of the wrong type throws instead of being coerced to a default. + +internal fun JSONObject.stringOrNull(name: String): String? = if (isNull(name)) null else getString(name) + +internal fun JSONObject.intOrNull(name: String): Int? = if (isNull(name)) null else getInt(name) + +internal fun JSONObject.longOrNull(name: String): Long? = if (isNull(name)) null else getLong(name) + +internal fun JSONObject.booleanOrNull(name: String): Boolean? = if (isNull(name)) null else getBoolean(name) + +internal fun JSONObject.objectOrNull(name: String): JSONObject? = if (isNull(name)) null else getJSONObject(name) + +internal inline fun JSONObject.objectOrNull(name: String, parse: (JSONObject) -> T): T? = + objectOrNull(name)?.let(parse) + +internal inline fun JSONObject.listOrNull(name: String, element: (JSONArray, Int) -> T): List? { + if (isNull(name)) return null + val array = getJSONArray(name) + return List(array.length()) { index -> element(array, index) } +} + +internal inline fun JSONObject.objectListOrNull(name: String, parse: (JSONObject) -> T): List? = + listOrNull(name) { array, index -> if (array.isNull(index)) null else parse(array.getJSONObject(index)) } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/SamplingApiService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/SamplingApiService.kt index 905573eece..407acf8472 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/SamplingApiService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/SamplingApiService.kt @@ -1,7 +1,6 @@ package com.launchdarkly.observability.network import com.launchdarkly.observability.sampling.SamplingConfig -import kotlinx.serialization.json.JsonPrimitive /** * Service for fetching sampling configuration @@ -75,12 +74,12 @@ class SamplingApiService( */ suspend fun getSamplingConfig(organizationVerboseId: String): SamplingConfig? { try { - val variables = mapOf("organization_verbose_id" to JsonPrimitive(organizationVerboseId)) + val variables = mapOf("organization_verbose_id" to organizationVerboseId) return graphqlClient.execute( query = GET_SAMPLING_CONFIG_QUERY, variables = variables, - dataSerializer = SamplingResponse.serializer() + dataParser = SamplingResponse::fromJson ).mapToEntity() } catch (e: Exception) { // Sampling falls back to "sample everything" when the config cannot be read, so every failure diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/SamplingResponse.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/SamplingResponse.kt index 02d832102d..0447aa39f3 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/SamplingResponse.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/network/SamplingResponse.kt @@ -6,19 +6,23 @@ import com.launchdarkly.observability.sampling.SpanEventMatchConfig import com.launchdarkly.observability.sampling.SpanSamplingConfig import com.launchdarkly.observability.sampling.LogSamplingConfig import com.launchdarkly.observability.sampling.SamplingConfig -import kotlinx.serialization.Serializable +import org.json.JSONObject /** * GraphQL response models for sampling configuration */ -@Serializable data class SamplingResponse( val sampling: SamplingConfigResponse? ) { fun mapToEntity(): SamplingConfig? = sampling?.mapToEntity() + + internal companion object { + fun fromJson(json: JSONObject) = SamplingResponse( + sampling = json.objectOrNull("sampling", SamplingConfigResponse::fromJson) + ) + } } -@Serializable data class SamplingConfigResponse( val spans: List? = null, val logs: List? = null @@ -27,9 +31,15 @@ data class SamplingConfigResponse( spans = spans?.mapNotNull { it?.mapToEntity() } ?: emptyList(), logs = logs?.mapNotNull { it?.mapToEntity() } ?: emptyList() ) + + internal companion object { + fun fromJson(json: JSONObject) = SamplingConfigResponse( + spans = json.objectListOrNull("spans", SpanSamplingConfigResponse::fromJson), + logs = json.objectListOrNull("logs", LogSamplingConfigResponse::fromJson) + ) + } } -@Serializable data class LogSamplingConfigResponse( val message: MatchConfigResponse? = null, val severityText: MatchConfigResponse? = null, @@ -44,9 +54,17 @@ data class LogSamplingConfigResponse( samplingRatio = samplingRatio ?: return null // If samplingRatio is null, mapping result will return null ) } + + internal companion object { + fun fromJson(json: JSONObject) = LogSamplingConfigResponse( + message = json.objectOrNull("message", MatchConfigResponse::fromJson), + severityText = json.objectOrNull("severityText", MatchConfigResponse::fromJson), + attributes = json.objectListOrNull("attributes", AttributeMatchConfigResponse::fromJson), + samplingRatio = json.intOrNull("samplingRatio") + ) + } } -@Serializable data class SpanSamplingConfigResponse( val name: MatchConfigResponse? = null, val attributes: List? = null, @@ -61,9 +79,17 @@ data class SpanSamplingConfigResponse( samplingRatio = samplingRatio ?: return null // If samplingRatio is null, mapping result will return null ) } + + internal companion object { + fun fromJson(json: JSONObject) = SpanSamplingConfigResponse( + name = json.objectOrNull("name", MatchConfigResponse::fromJson), + attributes = json.objectListOrNull("attributes", AttributeMatchConfigResponse::fromJson), + events = json.objectListOrNull("events", SpanEventMatchConfigResponse::fromJson), + samplingRatio = json.intOrNull("samplingRatio") + ) + } } -@Serializable data class SpanEventMatchConfigResponse( val name: MatchConfigResponse? = null, val attributes: List? = null @@ -72,9 +98,15 @@ data class SpanEventMatchConfigResponse( name = name?.mapToEntity(), attributes = attributes?.mapNotNull { it?.mapToEntity() } ?: emptyList() ) + + internal companion object { + fun fromJson(json: JSONObject) = SpanEventMatchConfigResponse( + name = json.objectOrNull("name", MatchConfigResponse::fromJson), + attributes = json.objectListOrNull("attributes", AttributeMatchConfigResponse::fromJson) + ) + } } -@Serializable data class AttributeMatchConfigResponse( val key: MatchConfigResponse, val attribute: MatchConfigResponse @@ -85,9 +117,15 @@ data class AttributeMatchConfigResponse( attribute = attribute.mapToEntity() ?: return null ) } + + internal companion object { + fun fromJson(json: JSONObject) = AttributeMatchConfigResponse( + key = MatchConfigResponse.fromJson(json.getJSONObject("key")), + attribute = MatchConfigResponse.fromJson(json.getJSONObject("attribute")) + ) + } } -@Serializable data class MatchConfigResponse( val regexValue: String? = null, val matchValue: String? = null @@ -97,4 +135,11 @@ data class MatchConfigResponse( matchValue != null -> MatchConfig.Value(matchValue) else -> null } + + internal companion object { + fun fromJson(json: JSONObject) = MatchConfigResponse( + regexValue = json.stringOrNull("regexValue"), + matchValue = json.stringOrNull("matchValue") + ) + } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/OtlpHttpClient.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/OtlpHttpClient.kt index 35f72db242..e7212c8ca2 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/OtlpHttpClient.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/OtlpHttpClient.kt @@ -5,15 +5,13 @@ import com.launchdarkly.observability.coroutines.DispatcherProviderHolder import com.launchdarkly.observability.network.GzipUtil import com.launchdarkly.observability.network.UrlConnectionProvider import kotlinx.coroutines.withContext -import kotlinx.serialization.KSerializer -import kotlinx.serialization.json.Json import java.io.IOException import java.net.HttpURLConnection import java.net.URL import kotlin.time.Duration /** - * Thin OTLP/HTTP+JSON client. Encodes a payload with kotlinx-serialization, optionally + * Thin OTLP/HTTP+JSON client. Takes an already encoded OTLP/JSON payload, optionally * gzip-compresses it, and POSTs it to the configured endpoint. * * Mirrors the Swift `OtlpHttpClient`. Non-2xx HTTP statuses surface as [IOException] so @@ -23,26 +21,22 @@ class OtlpHttpClient( private val endpoint: String, private val config: OtlpConfiguration = OtlpConfiguration(), private val connectionProvider: UrlConnectionProvider = DEFAULT_CONNECTION_PROVIDER, - private val json: Json = DEFAULT_JSON, ) { /** - * Encodes [body] as OTLP/JSON and sends it to the configured endpoint. + * Sends an OTLP/JSON payload to the configured endpoint. * - * @param body The payload to serialize and send. - * @param serializer Explicit serializer for [T] (required for generic `T`). + * @param body The UTF-8 encoded OTLP/JSON payload. * @param explicitTimeout Optional timeout override; the effective timeout is * `min(explicitTimeout, config.timeout)`. * * @throws IOException if the HTTP response status is not 2xx, or any IO error occurs. */ - suspend fun send( - body: T, - serializer: KSerializer, + suspend fun send( + body: ByteArray, explicitTimeout: Duration? = null, ) = withContext(DispatcherProviderHolder.current.io) { - val rawBytes = json.encodeToString(serializer, body).toByteArray(Charsets.UTF_8) val useGzip = config.compression == OtlpCompression.GZIP - val payloadBytes = if (useGzip) GzipUtil.gzip(rawBytes) else rawBytes + val payloadBytes = if (useGzip) GzipUtil.gzip(body) else body val effectiveTimeoutMs = minOf( explicitTimeout?.inWholeMilliseconds ?: Long.MAX_VALUE, @@ -96,11 +90,6 @@ class OtlpHttpClient( "OTel-OTLP-Exporter-Kotlin/$OTLP_VERSION LaunchDarkly-Observability-Android/$sdk" } - internal val DEFAULT_JSON: Json = Json { - encodeDefaults = false - explicitNulls = false - } - internal val DEFAULT_CONNECTION_PROVIDER: UrlConnectionProvider = object : UrlConnectionProvider { override fun openConnection(url: String): HttpURLConnection = diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/OtlpJsonEncoding.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/OtlpJsonEncoding.kt new file mode 100644 index 0000000000..0ad96510a8 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/OtlpJsonEncoding.kt @@ -0,0 +1,405 @@ +package com.launchdarkly.observability.otlp.json + +import com.launchdarkly.observability.json.JsonByteWriter +import com.launchdarkly.observability.otlp.json.common.JsonStringLong +import com.launchdarkly.observability.otlp.json.common.OtlpJsonAnyValue +import com.launchdarkly.observability.otlp.json.common.OtlpJsonInstrumentationScope +import com.launchdarkly.observability.otlp.json.common.OtlpJsonKeyValue +import com.launchdarkly.observability.otlp.json.common.OtlpJsonResource +import com.launchdarkly.observability.otlp.json.logs.OtlpJsonExportLogsServiceRequest +import com.launchdarkly.observability.otlp.json.logs.OtlpJsonLogRecord +import com.launchdarkly.observability.otlp.json.logs.OtlpJsonResourceLogs +import com.launchdarkly.observability.otlp.json.logs.OtlpJsonScopeLogs +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonExemplar +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonExponentialHistogramDataPoint +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonExportMetricsServiceRequest +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonHistogramDataPoint +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonMetric +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonNumberDataPoint +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonNumberValue +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonResourceMetrics +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonScopeMetrics +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonSummaryDataPoint +import com.launchdarkly.observability.otlp.json.traces.OtlpJsonExportTraceServiceRequest +import com.launchdarkly.observability.otlp.json.traces.OtlpJsonResourceSpans +import com.launchdarkly.observability.otlp.json.traces.OtlpJsonScopeSpans +import com.launchdarkly.observability.otlp.json.traces.OtlpJsonSpan +import com.launchdarkly.observability.otlp.json.traces.OtlpJsonStatus + +/* + * OTLP/JSON encoding of the wire-format models. Optional fields are left out when null rather than + * written as `null`, and fields are written in proto declaration order. + */ + +internal fun OtlpJsonExportTraceServiceRequest.toJsonBytes(): ByteArray = + JsonByteWriter.encode { write(this@toJsonBytes) } + +internal fun OtlpJsonExportLogsServiceRequest.toJsonBytes(): ByteArray = + JsonByteWriter.encode { write(this@toJsonBytes) } + +internal fun OtlpJsonExportMetricsServiceRequest.toJsonBytes(): ByteArray = + JsonByteWriter.encode { write(this@toJsonBytes) } + +// Common + +private inline fun JsonByteWriter.array(name: String, values: List, element: JsonByteWriter.(T) -> Unit) { + name(name).beginArray() + for (value in values) element(value) + endArray() +} + +private inline fun JsonByteWriter.optionalArray(name: String, values: List?, element: JsonByteWriter.(T) -> Unit) { + if (values != null) array(name, values, element) +} + +private fun JsonByteWriter.value(value: JsonStringLong) { + value(value.value.toString()) +} + +private fun JsonByteWriter.name(name: String, value: JsonStringLong) { + name(name).value(value) +} + +private fun JsonByteWriter.optional(name: String, value: JsonStringLong?) { + if (value != null) name(name, value) +} + +private fun JsonByteWriter.attributes(name: String, attributes: List?) { + optionalArray(name, attributes) { write(it) } +} + +private fun JsonByteWriter.write(resource: OtlpJsonResource) { + beginObject() + // An empty list is the default, and defaults are not written. + if (resource.attributes.isNotEmpty()) array("attributes", resource.attributes) { write(it) } + optional("droppedAttributesCount", resource.droppedAttributesCount) + endObject() +} + +private fun JsonByteWriter.write(scope: OtlpJsonInstrumentationScope) { + beginObject() + name("name").value(scope.name) + optional("version", scope.version) + attributes("attributes", scope.attributes) + optional("droppedAttributesCount", scope.droppedAttributesCount) + endObject() +} + +private fun JsonByteWriter.write(keyValue: OtlpJsonKeyValue) { + beginObject() + name("key").value(keyValue.key) + name("value") + write(keyValue.value) + endObject() +} + +/** Exactly one member, named for the variant, as the proto `oneof` is mapped to JSON. */ +private fun JsonByteWriter.write(value: OtlpJsonAnyValue) { + beginObject() + when (value) { + is OtlpJsonAnyValue.StringVal -> name("stringValue").value(value.value) + is OtlpJsonAnyValue.BoolVal -> name("boolValue").value(value.value) + is OtlpJsonAnyValue.IntVal -> name("intValue", value.value) + is OtlpJsonAnyValue.DoubleVal -> name("doubleValue").value(value.value) + is OtlpJsonAnyValue.ArrayVal -> { + name("arrayValue").beginObject() + if (value.value.values.isNotEmpty()) array("values", value.value.values) { write(it) } + endObject() + } + is OtlpJsonAnyValue.KvListVal -> { + name("kvlistValue").beginObject() + if (value.value.values.isNotEmpty()) array("values", value.value.values) { write(it) } + endObject() + } + is OtlpJsonAnyValue.BytesVal -> name("bytesValue").value(value.value) + } + endObject() +} + +private fun JsonByteWriter.optionalResource(resource: OtlpJsonResource?) { + if (resource != null) { + name("resource") + write(resource) + } +} + +private fun JsonByteWriter.optionalScope(scope: OtlpJsonInstrumentationScope?) { + if (scope != null) { + name("scope") + write(scope) + } +} + +// Traces + +private fun JsonByteWriter.write(request: OtlpJsonExportTraceServiceRequest) { + beginObject() + array("resourceSpans", request.resourceSpans) { write(it) } + endObject() +} + +private fun JsonByteWriter.write(resourceSpans: OtlpJsonResourceSpans) { + beginObject() + optionalResource(resourceSpans.resource) + array("scopeSpans", resourceSpans.scopeSpans) { write(it) } + optional("schemaUrl", resourceSpans.schemaUrl) + endObject() +} + +private fun JsonByteWriter.write(scopeSpans: OtlpJsonScopeSpans) { + beginObject() + optionalScope(scopeSpans.scope) + array("spans", scopeSpans.spans) { write(it) } + optional("schemaUrl", scopeSpans.schemaUrl) + endObject() +} + +private fun JsonByteWriter.write(span: OtlpJsonSpan) { + beginObject() + name("traceId").value(span.traceId) + name("spanId").value(span.spanId) + optional("traceState", span.traceState) + optional("parentSpanId", span.parentSpanId) + optional("flags", span.flags) + name("name").value(span.name) + name("kind").value(span.kind.name) + name("startTimeUnixNano", span.startTimeUnixNano) + name("endTimeUnixNano", span.endTimeUnixNano) + attributes("attributes", span.attributes) + optional("droppedAttributesCount", span.droppedAttributesCount) + optionalArray("events", span.events) { write(it) } + optional("droppedEventsCount", span.droppedEventsCount) + optionalArray("links", span.links) { write(it) } + optional("droppedLinksCount", span.droppedLinksCount) + span.status?.let { + name("status") + write(it) + } + endObject() +} + +private fun JsonByteWriter.write(event: OtlpJsonSpan.Event) { + beginObject() + name("timeUnixNano", event.timeUnixNano) + name("name").value(event.name) + attributes("attributes", event.attributes) + optional("droppedAttributesCount", event.droppedAttributesCount) + endObject() +} + +private fun JsonByteWriter.write(link: OtlpJsonSpan.Link) { + beginObject() + name("traceId").value(link.traceId) + name("spanId").value(link.spanId) + optional("traceState", link.traceState) + attributes("attributes", link.attributes) + optional("droppedAttributesCount", link.droppedAttributesCount) + optional("flags", link.flags) + endObject() +} + +private fun JsonByteWriter.write(status: OtlpJsonStatus) { + beginObject() + optional("message", status.message) + name("code").value(status.code.name) + endObject() +} + +// Logs + +private fun JsonByteWriter.write(request: OtlpJsonExportLogsServiceRequest) { + beginObject() + array("resourceLogs", request.resourceLogs) { write(it) } + endObject() +} + +private fun JsonByteWriter.write(resourceLogs: OtlpJsonResourceLogs) { + beginObject() + optionalResource(resourceLogs.resource) + array("scopeLogs", resourceLogs.scopeLogs) { write(it) } + optional("schemaUrl", resourceLogs.schemaUrl) + endObject() +} + +private fun JsonByteWriter.write(scopeLogs: OtlpJsonScopeLogs) { + beginObject() + optionalScope(scopeLogs.scope) + array("logRecords", scopeLogs.logRecords) { write(it) } + optional("schemaUrl", scopeLogs.schemaUrl) + endObject() +} + +private fun JsonByteWriter.write(record: OtlpJsonLogRecord) { + beginObject() + optional("timeUnixNano", record.timeUnixNano) + optional("observedTimeUnixNano", record.observedTimeUnixNano) + optional("severityNumber", record.severityNumber) + optional("severityText", record.severityText) + record.body?.let { + name("body") + write(it) + } + attributes("attributes", record.attributes) + optional("droppedAttributesCount", record.droppedAttributesCount) + optional("flags", record.flags) + optional("traceId", record.traceId) + optional("spanId", record.spanId) + optional("eventName", record.eventName) + endObject() +} + +// Metrics + +private fun JsonByteWriter.write(request: OtlpJsonExportMetricsServiceRequest) { + beginObject() + array("resourceMetrics", request.resourceMetrics) { write(it) } + endObject() +} + +private fun JsonByteWriter.write(resourceMetrics: OtlpJsonResourceMetrics) { + beginObject() + optionalResource(resourceMetrics.resource) + array("scopeMetrics", resourceMetrics.scopeMetrics) { write(it) } + optional("schemaUrl", resourceMetrics.schemaUrl) + endObject() +} + +private fun JsonByteWriter.write(scopeMetrics: OtlpJsonScopeMetrics) { + beginObject() + optionalScope(scopeMetrics.scope) + array("metrics", scopeMetrics.metrics) { write(it) } + optional("schemaUrl", scopeMetrics.schemaUrl) + endObject() +} + +/** The proto `data` oneof is written as the one member named for its variant. */ +private fun JsonByteWriter.write(metric: OtlpJsonMetric) { + beginObject() + name("name").value(metric.name) + optional("description", metric.description) + optional("unit", metric.unit) + when (val data = metric.data) { + is OtlpJsonMetric.Data.Gauge -> { + name("gauge").beginObject() + array("dataPoints", data.value.dataPoints) { write(it) } + endObject() + } + is OtlpJsonMetric.Data.Sum -> { + name("sum").beginObject() + array("dataPoints", data.value.dataPoints) { write(it) } + name("aggregationTemporality").value(data.value.aggregationTemporality.name) + name("isMonotonic").value(data.value.isMonotonic) + endObject() + } + is OtlpJsonMetric.Data.Histogram -> { + name("histogram").beginObject() + array("dataPoints", data.value.dataPoints) { write(it) } + name("aggregationTemporality").value(data.value.aggregationTemporality.name) + endObject() + } + is OtlpJsonMetric.Data.ExponentialHistogram -> { + name("exponentialHistogram").beginObject() + array("dataPoints", data.value.dataPoints) { write(it) } + name("aggregationTemporality").value(data.value.aggregationTemporality.name) + endObject() + } + is OtlpJsonMetric.Data.Summary -> { + name("summary").beginObject() + array("dataPoints", data.value.dataPoints) { write(it) } + endObject() + } + } + endObject() +} + +private fun JsonByteWriter.numberValue(value: OtlpJsonNumberValue) { + when (value) { + is OtlpJsonNumberValue.Int -> name("asInt", JsonStringLong(value.value)) + is OtlpJsonNumberValue.Double -> name("asDouble").value(value.value) + } +} + +private fun JsonByteWriter.write(point: OtlpJsonNumberDataPoint) { + beginObject() + attributes("attributes", point.attributes) + name("startTimeUnixNano", point.startTimeUnixNano) + name("timeUnixNano", point.timeUnixNano) + numberValue(point.value) + optionalArray("exemplars", point.exemplars) { write(it) } + optional("flags", point.flags) + endObject() +} + +private fun JsonByteWriter.write(point: OtlpJsonHistogramDataPoint) { + beginObject() + attributes("attributes", point.attributes) + name("startTimeUnixNano", point.startTimeUnixNano) + name("timeUnixNano", point.timeUnixNano) + name("count", point.count) + optional("sum", point.sum) + optionalArray("bucketCounts", point.bucketCounts) { value(it) } + optionalArray("explicitBounds", point.explicitBounds) { value(it) } + optionalArray("exemplars", point.exemplars) { write(it) } + optional("flags", point.flags) + optional("min", point.min) + optional("max", point.max) + endObject() +} + +private fun JsonByteWriter.write(point: OtlpJsonExponentialHistogramDataPoint) { + beginObject() + attributes("attributes", point.attributes) + name("startTimeUnixNano", point.startTimeUnixNano) + name("timeUnixNano", point.timeUnixNano) + name("count", point.count) + optional("sum", point.sum) + name("scale").value(point.scale) + optional("zeroCount", point.zeroCount) + point.positive?.let { + name("positive") + write(it) + } + point.negative?.let { + name("negative") + write(it) + } + optional("flags", point.flags) + optionalArray("exemplars", point.exemplars) { write(it) } + optional("min", point.min) + optional("max", point.max) + endObject() +} + +private fun JsonByteWriter.write(buckets: OtlpJsonExponentialHistogramDataPoint.Buckets) { + beginObject() + name("offset").value(buckets.offset) + array("bucketCounts", buckets.bucketCounts) { value(it) } + endObject() +} + +private fun JsonByteWriter.write(point: OtlpJsonSummaryDataPoint) { + beginObject() + attributes("attributes", point.attributes) + name("startTimeUnixNano", point.startTimeUnixNano) + name("timeUnixNano", point.timeUnixNano) + name("count", point.count) + name("sum").value(point.sum) + optionalArray("quantileValues", point.quantileValues) { + beginObject() + name("quantile").value(it.quantile) + name("value").value(it.value) + endObject() + } + optional("flags", point.flags) + endObject() +} + +private fun JsonByteWriter.write(exemplar: OtlpJsonExemplar) { + beginObject() + attributes("filteredAttributes", exemplar.filteredAttributes) + name("timeUnixNano", exemplar.timeUnixNano) + numberValue(exemplar.value) + optional("traceId", exemplar.traceId) + optional("spanId", exemplar.spanId) + endObject() +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/common/OtlpJsonCommonModels.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/common/OtlpJsonCommonModels.kt index 0a59376fd0..300f407c78 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/common/OtlpJsonCommonModels.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/common/OtlpJsonCommonModels.kt @@ -1,14 +1,5 @@ package com.launchdarkly.observability.otlp.json.common -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - /** * OTLP/JSON wire-format types shared by every signal (logs, traces, metrics). * @@ -29,28 +20,13 @@ import kotlinx.serialization.encoding.Encoder * 64-bit integer that serializes as a JSON string, as required by the proto3 JSON mapping * for `int64` / `uint64` / `fixed64` fields. */ -@Serializable(with = JsonStringLongSerializer::class) data class JsonStringLong(val value: Long) -object JsonStringLongSerializer : KSerializer { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("JsonStringLong", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: JsonStringLong) { - encoder.encodeString(value.value.toString()) - } - - override fun deserialize(decoder: Decoder): JsonStringLong = - JsonStringLong(decoder.decodeString().toLong()) -} - -@Serializable data class OtlpJsonResource( val attributes: List = emptyList(), val droppedAttributesCount: Int? = null, ) -@Serializable data class OtlpJsonInstrumentationScope( val name: String, val version: String? = null, @@ -58,7 +34,6 @@ data class OtlpJsonInstrumentationScope( val droppedAttributesCount: Int? = null, ) -@Serializable data class OtlpJsonKeyValue( val key: String, val value: OtlpJsonAnyValue, @@ -66,36 +41,26 @@ data class OtlpJsonKeyValue( /** * Mirrors `opentelemetry.proto.common.v1.AnyValue`. Exactly one associated value is encoded - * per instance. The serializer emits the proto3-JSON shape + * per instance, in the proto3-JSON shape * `{"stringValue": "..."} / {"intValue": "42"} / ...`. */ -@Serializable(with = OtlpJsonAnyValueSerializer::class) sealed class OtlpJsonAnyValue { - @Serializable - data class StringVal(@SerialName("stringValue") val value: String) : OtlpJsonAnyValue() + data class StringVal(val value: String) : OtlpJsonAnyValue() - @Serializable - data class BoolVal(@SerialName("boolValue") val value: Boolean) : OtlpJsonAnyValue() + data class BoolVal(val value: Boolean) : OtlpJsonAnyValue() - @Serializable - data class IntVal(@SerialName("intValue") val value: JsonStringLong) : OtlpJsonAnyValue() + data class IntVal(val value: JsonStringLong) : OtlpJsonAnyValue() - @Serializable - data class DoubleVal(@SerialName("doubleValue") val value: Double) : OtlpJsonAnyValue() + data class DoubleVal(val value: Double) : OtlpJsonAnyValue() - @Serializable - data class ArrayVal(@SerialName("arrayValue") val value: ArrayWrapper) : OtlpJsonAnyValue() + data class ArrayVal(val value: ArrayWrapper) : OtlpJsonAnyValue() - @Serializable - data class KvListVal(@SerialName("kvlistValue") val value: KvListWrapper) : OtlpJsonAnyValue() + data class KvListVal(val value: KvListWrapper) : OtlpJsonAnyValue() - @Serializable - data class BytesVal(@SerialName("bytesValue") val value: String) : OtlpJsonAnyValue() + data class BytesVal(val value: String) : OtlpJsonAnyValue() - @Serializable data class ArrayWrapper(val values: List = emptyList()) - @Serializable data class KvListWrapper(val values: List = emptyList()) companion object { @@ -108,45 +73,3 @@ sealed class OtlpJsonAnyValue { fun bytes(base64: String): OtlpJsonAnyValue = BytesVal(base64) } } - -object OtlpJsonAnyValueSerializer : KSerializer { - override val descriptor: SerialDescriptor = OtlpJsonAnyValueSurrogate.serializer().descriptor - - override fun serialize(encoder: Encoder, value: OtlpJsonAnyValue) { - val surrogate = when (value) { - is OtlpJsonAnyValue.StringVal -> OtlpJsonAnyValueSurrogate(stringValue = value.value) - is OtlpJsonAnyValue.BoolVal -> OtlpJsonAnyValueSurrogate(boolValue = value.value) - is OtlpJsonAnyValue.IntVal -> OtlpJsonAnyValueSurrogate(intValue = value.value) - is OtlpJsonAnyValue.DoubleVal -> OtlpJsonAnyValueSurrogate(doubleValue = value.value) - is OtlpJsonAnyValue.ArrayVal -> OtlpJsonAnyValueSurrogate(arrayValue = value.value) - is OtlpJsonAnyValue.KvListVal -> OtlpJsonAnyValueSurrogate(kvlistValue = value.value) - is OtlpJsonAnyValue.BytesVal -> OtlpJsonAnyValueSurrogate(bytesValue = value.value) - } - encoder.encodeSerializableValue(OtlpJsonAnyValueSurrogate.serializer(), surrogate) - } - - override fun deserialize(decoder: Decoder): OtlpJsonAnyValue { - val surrogate = decoder.decodeSerializableValue(OtlpJsonAnyValueSurrogate.serializer()) - return when { - surrogate.stringValue != null -> OtlpJsonAnyValue.StringVal(surrogate.stringValue) - surrogate.boolValue != null -> OtlpJsonAnyValue.BoolVal(surrogate.boolValue) - surrogate.intValue != null -> OtlpJsonAnyValue.IntVal(surrogate.intValue) - surrogate.doubleValue != null -> OtlpJsonAnyValue.DoubleVal(surrogate.doubleValue) - surrogate.arrayValue != null -> OtlpJsonAnyValue.ArrayVal(surrogate.arrayValue) - surrogate.kvlistValue != null -> OtlpJsonAnyValue.KvListVal(surrogate.kvlistValue) - surrogate.bytesValue != null -> OtlpJsonAnyValue.BytesVal(surrogate.bytesValue) - else -> OtlpJsonAnyValue.StringVal("") - } - } -} - -@Serializable -internal data class OtlpJsonAnyValueSurrogate( - val stringValue: String? = null, - val boolValue: Boolean? = null, - val intValue: JsonStringLong? = null, - val doubleValue: Double? = null, - val arrayValue: OtlpJsonAnyValue.ArrayWrapper? = null, - val kvlistValue: OtlpJsonAnyValue.KvListWrapper? = null, - val bytesValue: String? = null, -) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/logs/OtlpJsonLogModels.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/logs/OtlpJsonLogModels.kt index 5a4fa40618..27dd904e25 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/logs/OtlpJsonLogModels.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/logs/OtlpJsonLogModels.kt @@ -5,7 +5,6 @@ import com.launchdarkly.observability.otlp.json.common.OtlpJsonAnyValue import com.launchdarkly.observability.otlp.json.common.OtlpJsonInstrumentationScope import com.launchdarkly.observability.otlp.json.common.OtlpJsonKeyValue import com.launchdarkly.observability.otlp.json.common.OtlpJsonResource -import kotlinx.serialization.Serializable /** * OTLP/JSON wire-format types for the logs signal. @@ -13,26 +12,22 @@ import kotlinx.serialization.Serializable * Mirrors the Swift `OtlpJsonLogModels.swift`. */ -@Serializable data class OtlpJsonExportLogsServiceRequest( val resourceLogs: List, ) -@Serializable data class OtlpJsonResourceLogs( val resource: OtlpJsonResource? = null, val scopeLogs: List, val schemaUrl: String? = null, ) -@Serializable data class OtlpJsonScopeLogs( val scope: OtlpJsonInstrumentationScope? = null, val logRecords: List, val schemaUrl: String? = null, ) -@Serializable data class OtlpJsonLogRecord( val timeUnixNano: JsonStringLong? = null, val observedTimeUnixNano: JsonStringLong? = null, diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/metrics/OtlpJsonMetricModels.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/metrics/OtlpJsonMetricModels.kt index 9915ba2bc1..48015fbf63 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/metrics/OtlpJsonMetricModels.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/metrics/OtlpJsonMetricModels.kt @@ -4,12 +4,6 @@ import com.launchdarkly.observability.otlp.json.common.JsonStringLong import com.launchdarkly.observability.otlp.json.common.OtlpJsonInstrumentationScope import com.launchdarkly.observability.otlp.json.common.OtlpJsonKeyValue import com.launchdarkly.observability.otlp.json.common.OtlpJsonResource -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder /** * OTLP/JSON wire-format types for the metrics signal. @@ -17,19 +11,16 @@ import kotlinx.serialization.encoding.Encoder * Mirrors the Swift `OtlpJsonMetricModels.swift`. */ -@Serializable data class OtlpJsonExportMetricsServiceRequest( val resourceMetrics: List, ) -@Serializable data class OtlpJsonResourceMetrics( val resource: OtlpJsonResource? = null, val scopeMetrics: List, val schemaUrl: String? = null, ) -@Serializable data class OtlpJsonScopeMetrics( val scope: OtlpJsonInstrumentationScope? = null, val metrics: List, @@ -40,7 +31,6 @@ data class OtlpJsonScopeMetrics( * Mirrors `opentelemetry.proto.metrics.v1.Metric`. The proto `data` field is a `oneof`, so we * encode exactly one of `gauge` / `sum` / `histogram` / `exponentialHistogram` / `summary`. */ -@Serializable(with = OtlpJsonMetricSerializer::class) data class OtlpJsonMetric( val name: String, val description: String? = null, @@ -56,85 +46,30 @@ data class OtlpJsonMetric( } } -object OtlpJsonMetricSerializer : KSerializer { - override val descriptor: SerialDescriptor = OtlpJsonMetricSurrogate.serializer().descriptor - - override fun serialize(encoder: Encoder, value: OtlpJsonMetric) { - val surrogate = OtlpJsonMetricSurrogate( - name = value.name, - description = value.description, - unit = value.unit, - gauge = (value.data as? OtlpJsonMetric.Data.Gauge)?.value, - sum = (value.data as? OtlpJsonMetric.Data.Sum)?.value, - histogram = (value.data as? OtlpJsonMetric.Data.Histogram)?.value, - exponentialHistogram = (value.data as? OtlpJsonMetric.Data.ExponentialHistogram)?.value, - summary = (value.data as? OtlpJsonMetric.Data.Summary)?.value, - ) - encoder.encodeSerializableValue(OtlpJsonMetricSurrogate.serializer(), surrogate) - } - - override fun deserialize(decoder: Decoder): OtlpJsonMetric { - val surrogate = decoder.decodeSerializableValue(OtlpJsonMetricSurrogate.serializer()) - val data: OtlpJsonMetric.Data = when { - surrogate.gauge != null -> OtlpJsonMetric.Data.Gauge(surrogate.gauge) - surrogate.sum != null -> OtlpJsonMetric.Data.Sum(surrogate.sum) - surrogate.histogram != null -> OtlpJsonMetric.Data.Histogram(surrogate.histogram) - surrogate.exponentialHistogram != null -> - OtlpJsonMetric.Data.ExponentialHistogram(surrogate.exponentialHistogram) - surrogate.summary != null -> OtlpJsonMetric.Data.Summary(surrogate.summary) - else -> OtlpJsonMetric.Data.Gauge(OtlpJsonGauge(dataPoints = emptyList())) - } - return OtlpJsonMetric( - name = surrogate.name, - description = surrogate.description, - unit = surrogate.unit, - data = data, - ) - } -} - -@Serializable -internal data class OtlpJsonMetricSurrogate( - val name: String, - val description: String? = null, - val unit: String? = null, - val gauge: OtlpJsonGauge? = null, - val sum: OtlpJsonSum? = null, - val histogram: OtlpJsonHistogram? = null, - val exponentialHistogram: OtlpJsonExponentialHistogram? = null, - val summary: OtlpJsonSummary? = null, -) - -@Serializable data class OtlpJsonGauge( val dataPoints: List, ) -@Serializable data class OtlpJsonSum( val dataPoints: List, val aggregationTemporality: OtlpJsonAggregationTemporality, val isMonotonic: Boolean, ) -@Serializable data class OtlpJsonHistogram( val dataPoints: List, val aggregationTemporality: OtlpJsonAggregationTemporality, ) -@Serializable data class OtlpJsonExponentialHistogram( val dataPoints: List, val aggregationTemporality: OtlpJsonAggregationTemporality, ) -@Serializable data class OtlpJsonSummary( val dataPoints: List, ) -@Serializable(with = OtlpJsonNumberDataPointSerializer::class) data class OtlpJsonNumberDataPoint( val attributes: List? = null, val startTimeUnixNano: JsonStringLong, @@ -144,54 +79,6 @@ data class OtlpJsonNumberDataPoint( val flags: Int? = null, ) -object OtlpJsonNumberDataPointSerializer : KSerializer { - override val descriptor: SerialDescriptor = OtlpJsonNumberDataPointSurrogate.serializer().descriptor - - override fun serialize(encoder: Encoder, value: OtlpJsonNumberDataPoint) { - val asInt = (value.value as? OtlpJsonNumberValue.Int)?.value?.let { JsonStringLong(it) } - val asDouble = (value.value as? OtlpJsonNumberValue.Double)?.value - val surrogate = OtlpJsonNumberDataPointSurrogate( - attributes = value.attributes, - startTimeUnixNano = value.startTimeUnixNano, - timeUnixNano = value.timeUnixNano, - asInt = asInt, - asDouble = asDouble, - exemplars = value.exemplars, - flags = value.flags, - ) - encoder.encodeSerializableValue(OtlpJsonNumberDataPointSurrogate.serializer(), surrogate) - } - - override fun deserialize(decoder: Decoder): OtlpJsonNumberDataPoint { - val s = decoder.decodeSerializableValue(OtlpJsonNumberDataPointSurrogate.serializer()) - val value: OtlpJsonNumberValue = when { - s.asInt != null -> OtlpJsonNumberValue.Int(s.asInt.value) - s.asDouble != null -> OtlpJsonNumberValue.Double(s.asDouble) - else -> OtlpJsonNumberValue.Double(0.0) - } - return OtlpJsonNumberDataPoint( - attributes = s.attributes, - startTimeUnixNano = s.startTimeUnixNano, - timeUnixNano = s.timeUnixNano, - value = value, - exemplars = s.exemplars, - flags = s.flags, - ) - } -} - -@Serializable -internal data class OtlpJsonNumberDataPointSurrogate( - val attributes: List? = null, - val startTimeUnixNano: JsonStringLong, - val timeUnixNano: JsonStringLong, - val asInt: JsonStringLong? = null, - val asDouble: Double? = null, - val exemplars: List? = null, - val flags: Int? = null, -) - -@Serializable data class OtlpJsonHistogramDataPoint( val attributes: List? = null, val startTimeUnixNano: JsonStringLong, @@ -208,7 +95,6 @@ data class OtlpJsonHistogramDataPoint( val max: Double? = null, ) -@Serializable data class OtlpJsonExponentialHistogramDataPoint( val attributes: List? = null, val startTimeUnixNano: JsonStringLong, @@ -224,14 +110,12 @@ data class OtlpJsonExponentialHistogramDataPoint( val min: Double? = null, val max: Double? = null, ) { - @Serializable data class Buckets( val offset: Int, val bucketCounts: List, ) } -@Serializable data class OtlpJsonSummaryDataPoint( val attributes: List? = null, val startTimeUnixNano: JsonStringLong, @@ -241,14 +125,12 @@ data class OtlpJsonSummaryDataPoint( val quantileValues: List? = null, val flags: Int? = null, ) { - @Serializable data class ValueAtQuantile( val quantile: Double, val value: Double, ) } -@Serializable(with = OtlpJsonExemplarSerializer::class) data class OtlpJsonExemplar( val filteredAttributes: List? = null, val timeUnixNano: JsonStringLong, @@ -259,58 +141,13 @@ data class OtlpJsonExemplar( val spanId: String? = null, ) -object OtlpJsonExemplarSerializer : KSerializer { - override val descriptor: SerialDescriptor = OtlpJsonExemplarSurrogate.serializer().descriptor - - override fun serialize(encoder: Encoder, value: OtlpJsonExemplar) { - val asInt = (value.value as? OtlpJsonNumberValue.Int)?.value?.let { JsonStringLong(it) } - val asDouble = (value.value as? OtlpJsonNumberValue.Double)?.value - val surrogate = OtlpJsonExemplarSurrogate( - filteredAttributes = value.filteredAttributes, - timeUnixNano = value.timeUnixNano, - asInt = asInt, - asDouble = asDouble, - traceId = value.traceId, - spanId = value.spanId, - ) - encoder.encodeSerializableValue(OtlpJsonExemplarSurrogate.serializer(), surrogate) - } - - override fun deserialize(decoder: Decoder): OtlpJsonExemplar { - val s = decoder.decodeSerializableValue(OtlpJsonExemplarSurrogate.serializer()) - val value: OtlpJsonNumberValue = when { - s.asInt != null -> OtlpJsonNumberValue.Int(s.asInt.value) - s.asDouble != null -> OtlpJsonNumberValue.Double(s.asDouble) - else -> OtlpJsonNumberValue.Double(0.0) - } - return OtlpJsonExemplar( - filteredAttributes = s.filteredAttributes, - timeUnixNano = s.timeUnixNano, - value = value, - traceId = s.traceId, - spanId = s.spanId, - ) - } -} - -@Serializable -internal data class OtlpJsonExemplarSurrogate( - val filteredAttributes: List? = null, - val timeUnixNano: JsonStringLong, - val asInt: JsonStringLong? = null, - val asDouble: Double? = null, - val traceId: String? = null, - val spanId: String? = null, -) - /** Mirrors the `oneof` numeric value used by both `NumberDataPoint` and `Exemplar`. */ sealed class OtlpJsonNumberValue { - data class Int(@SerialName("asInt") val value: Long) : OtlpJsonNumberValue() - data class Double(@SerialName("asDouble") val value: kotlin.Double) : OtlpJsonNumberValue() + data class Int(val value: Long) : OtlpJsonNumberValue() + data class Double(val value: kotlin.Double) : OtlpJsonNumberValue() } /** Encoded as the proto-JSON enum string form. */ -@Serializable enum class OtlpJsonAggregationTemporality { AGGREGATION_TEMPORALITY_UNSPECIFIED, AGGREGATION_TEMPORALITY_DELTA, diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/traces/OtlpJsonTraceModels.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/traces/OtlpJsonTraceModels.kt index 481b91d3b6..e99f1dd714 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/traces/OtlpJsonTraceModels.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/otlp/json/traces/OtlpJsonTraceModels.kt @@ -4,7 +4,6 @@ import com.launchdarkly.observability.otlp.json.common.JsonStringLong import com.launchdarkly.observability.otlp.json.common.OtlpJsonInstrumentationScope import com.launchdarkly.observability.otlp.json.common.OtlpJsonKeyValue import com.launchdarkly.observability.otlp.json.common.OtlpJsonResource -import kotlinx.serialization.Serializable /** * OTLP/JSON wire-format types for the traces signal. @@ -12,26 +11,22 @@ import kotlinx.serialization.Serializable * Mirrors the Swift `OtlpJsonTraceModels.swift`. */ -@Serializable data class OtlpJsonExportTraceServiceRequest( val resourceSpans: List, ) -@Serializable data class OtlpJsonResourceSpans( val resource: OtlpJsonResource? = null, val scopeSpans: List, val schemaUrl: String? = null, ) -@Serializable data class OtlpJsonScopeSpans( val scope: OtlpJsonInstrumentationScope? = null, val spans: List, val schemaUrl: String? = null, ) -@Serializable data class OtlpJsonSpan( /** Lowercase hex string (32 chars), per OTLP/JSON spec deviation. */ val traceId: String, @@ -53,7 +48,6 @@ data class OtlpJsonSpan( val droppedLinksCount: Int? = null, val status: OtlpJsonStatus? = null, ) { - @Serializable data class Event( val timeUnixNano: JsonStringLong, val name: String, @@ -61,7 +55,6 @@ data class OtlpJsonSpan( val droppedAttributesCount: Int? = null, ) - @Serializable data class Link( /** Lowercase hex string (32 chars). */ val traceId: String, @@ -77,7 +70,6 @@ data class OtlpJsonSpan( /** * Encoded as the proto-JSON enum string form (e.g. `"SPAN_KIND_CLIENT"`). */ -@Serializable enum class OtlpJsonSpanKind { SPAN_KIND_UNSPECIFIED, SPAN_KIND_INTERNAL, @@ -87,13 +79,11 @@ enum class OtlpJsonSpanKind { SPAN_KIND_CONSUMER, } -@Serializable data class OtlpJsonStatus( val message: String? = null, val code: OtlpJsonStatusCode, ) -@Serializable enum class OtlpJsonStatusCode { STATUS_CODE_UNSET, STATUS_CODE_OK, diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayEventEncoding.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayEventEncoding.kt new file mode 100644 index 0000000000..5eb5debd61 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayEventEncoding.kt @@ -0,0 +1,106 @@ +package com.launchdarkly.observability.replay + +import com.launchdarkly.observability.json.JsonByteWriter +import com.launchdarkly.observability.json.JsonWritable + +/* + * rrweb JSON encoding of the replay models. Optional fields are left out when null rather than written + * as `null`, and fields are written in declaration order. + */ + +internal fun ReplayEventsInput.asJsonWritable(): JsonWritable = JsonWritable { writer -> + writer.beginObject() + writer.name("events").beginArray() + for (event in events) writer.write(event) + writer.endArray() + writer.endObject() +} + +internal fun JsonByteWriter.write(event: Event) { + beginObject() + name("type").value(event.type.value) + name("data") + when (val data = event.data) { + is EventDataUnion.StandardEventData -> write(data.data) + is EventDataUnion.CustomEventDataWrapper -> anyValue(data.data) + } + name("timestamp").value(event.timestamp) + name("_sid").value(event.sid) + endObject() +} + +private fun JsonByteWriter.write(data: EventData) { + beginObject() + data.source?.let { name("source").value(it.value) } + data.type?.let { name("type").value(it.value) } + data.texts?.let { texts -> + name("texts").beginArray() + for (text in texts) value(text) + endArray() + } + data.attributes?.let { attributes -> + name("attributes").beginArray() + for (attribute in attributes) write(attribute) + endArray() + } + optional("href", data.href) + optional("width", data.width) + optional("height", data.height) + data.node?.let { + name("node") + write(it) + } + data.removes?.let { removes -> + name("removes").beginArray() + for (removal in removes) { + beginObject() + name("parentId").value(removal.parentId) + name("id").value(removal.id) + endObject() + } + endArray() + } + data.adds?.let { adds -> + name("adds").beginArray() + for (addition in adds) { + beginObject() + name("parentId").value(addition.parentId) + optional("nextId", addition.nextId) + name("node") + write(addition.node) + endObject() + } + endArray() + } + optional("id", data.id) + optional("x", data.x) + optional("y", data.y) + endObject() +} + +private fun JsonByteWriter.write(node: EventNode) { + beginObject() + name("type").value(node.type.value) + optional("name", node.name) + optional("tagName", node.tagName) + node.attributes?.let { stringMap("attributes", it) } + name("childNodes").beginArray() + for (child in node.childNodes) write(child) + endArray() + optional("rootId", node.rootId) + optional("id", node.id) + endObject() +} + +private fun JsonByteWriter.write(attributes: Attributes) { + beginObject() + optional("id", attributes.id) + attributes.attributes?.let { stringMap("attributes", it) } + endObject() +} + +private fun JsonByteWriter.stringMap(name: String, map: Map) { + name(name).beginObject() + for ((key, value) in map) name(key).value(value) + endObject() +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplaySessionProtocol.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplaySessionProtocol.kt index 0de48865e1..4a53b165da 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplaySessionProtocol.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplaySessionProtocol.kt @@ -1,31 +1,31 @@ package com.launchdarkly.observability.replay import com.launchdarkly.observability.network.SamplingConfigResponse +import com.launchdarkly.observability.network.intOrNull +import com.launchdarkly.observability.network.objectOrNull +import com.launchdarkly.observability.network.stringOrNull import com.launchdarkly.observability.sampling.SamplingConfig -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlin.reflect.KClass - -@Serializable +import org.json.JSONObject + data class InitializeReplaySessionResponse( val initializeSession: InitializeSessionResponse? -) +) { + internal companion object { + fun fromJson(json: JSONObject) = InitializeReplaySessionResponse( + initializeSession = json.objectOrNull("initializeSession", InitializeSessionResponse::fromJson) + ) + } +} -@Serializable data class IdentifySessionResponse( val identifySession: String? = null -) +) { + internal companion object { + fun fromJson(json: JSONObject) = IdentifySessionResponse( + identifySession = json.stringOrNull("identifySession") + ) + } +} data class SessionInitializationEntity( val secureId: String?, @@ -33,11 +33,8 @@ data class SessionInitializationEntity( val sampling: SamplingConfig? ) -@Serializable data class InitializeSessionResponse( - @SerialName("secure_id") val secureId: String? = null, - @SerialName("project_id") val projectId: String? = null, val sampling: SamplingConfigResponse? = null ) { @@ -48,9 +45,17 @@ data class InitializeSessionResponse( sampling = sampling?.mapToEntity() ) } + + internal companion object { + fun fromJson(json: JSONObject) = InitializeSessionResponse( + secureId = json.stringOrNull("secure_id"), + projectId = json.stringOrNull("project_id"), + sampling = json.objectOrNull("sampling", SamplingConfigResponse::fromJson) + ) + } } -@Serializable(with = EventTypeSerializer::class) +/** Written as its [value]. */ enum class EventType(val value: Int) { DOM_CONTENT_LOADED(0), LOAD(1), @@ -61,9 +66,7 @@ enum class EventType(val value: Int) { PLUGIN(6) } -object EventTypeSerializer : IntEnumSerializer(EventType::class, "EventType", EventType::value) - -@Serializable(with = NodeTypeSerializer::class) +/** Written as its [value]. */ enum class NodeType(val value: Int) { DOCUMENT(0), DOCUMENT_TYPE(1), @@ -73,9 +76,7 @@ enum class NodeType(val value: Int) { COMMENT(5) } -object NodeTypeSerializer : IntEnumSerializer(NodeType::class, "NodeType", NodeType::value) - -@Serializable(with = IncrementalSourceSerializer::class) +/** Written as its [value]. */ enum class IncrementalSource(val value: Int) { MUTATION(0), MOUSE_MOVE(1), @@ -96,9 +97,7 @@ enum class IncrementalSource(val value: Int) { CUSTOM_ELEMENT(16) } -object IncrementalSourceSerializer : IntEnumSerializer(IncrementalSource::class, "IncrementalSource", IncrementalSource::value) - -@Serializable(with = MouseInteractionsSerializer::class) +/** Written as its [value]. */ enum class MouseInteractions(val value: Int) { MOUSE_UP(0), MOUSE_DOWN(1), @@ -113,63 +112,33 @@ enum class MouseInteractions(val value: Int) { TOUCH_CANCEL(10) } -object MouseInteractionsSerializer : IntEnumSerializer(MouseInteractions::class, "MouseInteractions", MouseInteractions::value) - -open class IntEnumSerializer>( - enumClass: KClass, - private val serialName: String, - private val valueSelector: (T) -> Int -) : KSerializer { - private val entries: List = enumClass.java.enumConstants?.toList() ?: emptyList() - private val lookup: Map = entries.associateBy(valueSelector) - - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(serialName, PrimitiveKind.INT) - - override fun serialize(encoder: Encoder, value: T) { - encoder.encodeInt(valueSelector(value)) - } - - override fun deserialize(decoder: Decoder): T { - val intValue = decoder.decodeInt() - // TODO: O11Y-624 - determine better error handling - return lookup[intValue] - ?: throw IllegalArgumentException("Unknown $serialName value: $intValue") - } -} - -@OptIn(ExperimentalSerializationApi::class) -@Serializable data class EventNode( val type: NodeType, val name: String? = null, val tagName: String? = null, val attributes: Map? = null, - // This EncodeDefault is needed as a workaround, rrweb replay is expecting childNodes to be present even when empty list - @EncodeDefault val childNodes: List = emptyList(), + /** Always written, even when empty: rrweb replay expects every node to carry it. */ + val childNodes: List = emptyList(), val rootId: Int? = null, val id: Int? = null ) -@Serializable data class Attributes( val id: Int? = null, val attributes: Map? = null ) -@Serializable data class Removal( val parentId: Int, val id: Int ) -@Serializable data class Addition( val parentId: Int, val nextId: Int? = null, val node: EventNode ) -@Serializable data class EventData( val source: IncrementalSource? = null, val type: MouseInteractions? = null, @@ -186,68 +155,34 @@ data class EventData( val y: Double? = null, ) -object EventDataUnionSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("EventDataUnion") - - override fun serialize(encoder: Encoder, value: EventDataUnion) { - when (value) { - is EventDataUnion.StandardEventData -> { - encoder.encodeSerializableValue(EventData.serializer(), value.data) - } - is EventDataUnion.CustomEventDataWrapper -> { - encoder.encodeSerializableValue(JsonElement.serializer(), value.data) - } - } - } - - override fun deserialize(decoder: Decoder): EventDataUnion { - // For deserialization, we need to determine the type based on the content - // This is a simplified implementation - in practice, you might need more sophisticated logic - // to determine whether the data should be StandardEventData or CustomEventDataWrapper - val jsonElement = decoder.decodeSerializableValue(JsonElement.serializer()) - - // Try to deserialize as StandardEventData first - return try { - val eventData = Json.decodeFromJsonElement(EventData.serializer(), jsonElement) - EventDataUnion.StandardEventData(eventData) - } catch (e: Exception) { - // If that fails, treat as CustomEventDataWrapper with JsonElement - EventDataUnion.CustomEventDataWrapper(jsonElement) - } - } -} - -@Serializable(with = EventDataUnionSerializer::class) sealed class EventDataUnion { - @Serializable data class StandardEventData(val data: EventData) : EventDataUnion() - @Serializable - data class CustomEventDataWrapper(val data: JsonElement) : EventDataUnion() + /** + * Event data with no fixed shape, written as a JSON object. Values may be `null`, [String], [Boolean], + * any [Number], or a nested [Map] or [List] of those. + */ + data class CustomEventDataWrapper(val data: Map) : EventDataUnion() } -@Serializable data class Event( val type: EventType, val data: EventDataUnion, val timestamp: Long, - @SerialName("_sid") + /** Written as `_sid`. */ val sid: Int ) -@Serializable data class ReplayEventsInput( val events: List ) -@Serializable -data class ErrorObjectInput( - val message: String? = null, - val stack: String? = null, - val timestamp: Long? = null -) - -@Serializable data class PushPayloadResponse( val pushPayload: Int? = null -) +) { + internal companion object { + fun fromJson(json: JSONObject) = PushPayloadResponse( + pushPayload = json.intOrNull("pushPayload") + ) + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayInitializationStore.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayInitializationStore.kt index 9c1ec597d3..c8cad04579 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayInitializationStore.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/SessionReplayInitializationStore.kt @@ -1,8 +1,8 @@ package com.launchdarkly.observability.replay import android.content.SharedPreferences -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json +import com.launchdarkly.observability.json.JsonByteWriter +import org.json.JSONObject import java.security.MessageDigest /** @@ -24,7 +24,6 @@ sealed interface SessionReplayInitializationVerdict { /** * The last unrecoverable failure, as persisted between launches. */ -@Serializable internal data class SessionReplayInitializationFailure( val reason: String, val timestamp: Long, @@ -73,13 +72,19 @@ internal class SessionReplayInitializationStore( */ const val MAX_REASON_LENGTH = 512 - private val json = Json { ignoreUnknownKeys = true } - - fun encode(failure: SessionReplayInitializationFailure): String = - json.encodeToString(SessionReplayInitializationFailure.serializer(), failure) + fun encode(failure: SessionReplayInitializationFailure): String = JsonByteWriter.encodeToString(initialCapacity = 128) { + beginObject() + name("reason").value(failure.reason) + name("timestamp").value(failure.timestamp) + endObject() + } fun decode(stored: String): SessionReplayInitializationFailure? = try { - json.decodeFromString(SessionReplayInitializationFailure.serializer(), stored) + val decoded = JSONObject(stored) + SessionReplayInitializationFailure( + reason = decoded.getString("reason"), + timestamp = decoded.getLong("timestamp"), + ) } catch (_: Exception) { null } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGenerator.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGenerator.kt index 001eb392e1..b78b3e160c 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGenerator.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGenerator.kt @@ -17,12 +17,7 @@ import com.launchdarkly.observability.replay.RRWebIncrementalSource import com.launchdarkly.observability.replay.RRWebMouseInteraction import com.launchdarkly.observability.replay.capture.ExportFrame import com.launchdarkly.observability.replay.capture.ImageSignature -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonArray -import kotlinx.serialization.json.putJsonObject +import com.launchdarkly.observability.json.JsonByteWriter /** * Generates RRWeb-compatible events for session replay. @@ -266,18 +261,18 @@ class RRWebEventGenerator( timestamp = exportFrame.timestamp, sid = nextSid(), data = EventDataUnion.CustomEventDataWrapper( - buildJsonObject { - put("tag", RRWebCustomDataTag.VIEWPORT.wireValue) - putJsonObject("payload") { - put("width", exportFrame.originalSize.width) - put("height", exportFrame.originalSize.height) - put("availWidth", exportFrame.originalSize.width) - put("availHeight", exportFrame.originalSize.height) - put("colorDepth", 30) - put("pixelDepth", 30) - put("orientation", exportFrame.orientation) - } - } + mapOf( + "tag" to RRWebCustomDataTag.VIEWPORT.wireValue, + "payload" to mapOf( + "width" to exportFrame.originalSize.width, + "height" to exportFrame.originalSize.height, + "availWidth" to exportFrame.originalSize.width, + "availHeight" to exportFrame.originalSize.height, + "colorDepth" to 30, + "pixelDepth" to 30, + "orientation" to exportFrame.orientation, + ), + ) ) ) eventBatch.add(viewportEvent) @@ -301,14 +296,7 @@ class RRWebEventGenerator( timestamp = firstPosition.timestamp, sid = nextSid(), data = EventDataUnion.CustomEventDataWrapper( - buildJsonObject { - put("source", RRWebIncrementalSource.MOUSE_INTERACTION.code) - putJsonArray("texts") {} - put("type", RRWebMouseInteraction.TOUCH_START.code) - imageNodeId?.let { put("id", it) } - put("x", firstPosition.x + RRWEB_DOCUMENT_PADDING) - put("y", firstPosition.y + RRWEB_DOCUMENT_PADDING) - } + touchData(RRWebMouseInteraction.TOUCH_START, firstPosition.x, firstPosition.y) ) ) ) @@ -325,14 +313,7 @@ class RRWebEventGenerator( timestamp = lastPosition.timestamp, sid = nextSid(), data = EventDataUnion.CustomEventDataWrapper( - buildJsonObject { - put("source", RRWebIncrementalSource.MOUSE_INTERACTION.code) - putJsonArray("texts") {} - put("type", RRWebMouseInteraction.TOUCH_END.code) - imageNodeId?.let { put("id", it) } - put("x", lastPosition.x + RRWEB_DOCUMENT_PADDING) - put("y", lastPosition.y + RRWEB_DOCUMENT_PADDING) - } + touchData(RRWebMouseInteraction.TOUCH_END, lastPosition.x, lastPosition.y) ) ) ) @@ -347,19 +328,17 @@ class RRWebEventGenerator( timestamp = position.timestamp, sid = nextSid(), data = EventDataUnion.CustomEventDataWrapper( - buildJsonObject { - put("source", RRWebIncrementalSource.TOUCH_MOVE.code) - put("positions", buildJsonArray { - add( - buildJsonObject { - imageNodeId?.let { put("id", it) } - put("timeOffset", 0) - put("x", position.x + RRWEB_DOCUMENT_PADDING) - put("y", position.y + RRWEB_DOCUMENT_PADDING) - } - ) - }) - } + mapOf( + "source" to RRWebIncrementalSource.TOUCH_MOVE.code, + "positions" to listOf( + buildMap { + imageNodeId?.let { put("id", it) } + put("timeOffset", 0) + put("x", position.x + RRWEB_DOCUMENT_PADDING) + put("y", position.y + RRWEB_DOCUMENT_PADDING) + } + ), + ) ) ) ) @@ -370,24 +349,33 @@ class RRWebEventGenerator( return events } + private fun touchData(interaction: RRWebMouseInteraction, x: Int, y: Int): Map = buildMap { + put("source", RRWebIncrementalSource.MOUSE_INTERACTION.code) + put("texts", emptyList()) + put("type", interaction.code) + imageNodeId?.let { put("id", it) } + put("x", x + RRWEB_DOCUMENT_PADDING) + put("y", y + RRWEB_DOCUMENT_PADDING) + } + + /** A compact JSON string, for the custom events whose payload is stringified JSON. */ + private fun jsonString(value: Map): String = JsonByteWriter.encodeToString { anyValue(value) } + /** * Payload is a JSON string representing the user attributes map. */ fun generateIdentifyEvent(identify: IdentifyItemPayload): Event? { val userJSONString = try { - // Encode attributes map into a compact JSON string without requiring serializers - buildJsonObject { - identify.attributes.forEach { (k, v) -> put(k, v) } - }.toString() + jsonString(identify.attributes) } catch (_: Exception) { return null } - val customData = buildJsonObject { - put("tag", JsonPrimitive(RRWebCustomDataTag.IDENTIFY.wireValue)) + val customData = mapOf( + "tag" to RRWebCustomDataTag.IDENTIFY.wireValue, // Payload must be a JSON string per rrweb Custom event contract used by Swift - put("payload", JsonPrimitive(userJSONString)) - } + "payload" to userJSONString, + ) return Event( type = EventType.CUSTOM, @@ -403,22 +391,22 @@ class RRWebEventGenerator( */ fun generateTrackEvent(track: TrackItemPayload): Event? { val payloadJSONString = try { - buildJsonObject { - put("event", track.name) - track.metricValue?.let { put("value", it) } - putJsonObject("data") { - track.attributes.forEach { (k, v) -> put(k, v) } + jsonString( + buildMap { + put("event", track.name) + track.metricValue?.let { put("value", it) } + put("data", track.attributes) } - }.toString() + ) } catch (_: Exception) { return null } - val customData = buildJsonObject { - put("tag", JsonPrimitive(RRWebCustomDataTag.TRACK.wireValue)) + val customData = mapOf( + "tag" to RRWebCustomDataTag.TRACK.wireValue, // Payload must be a JSON string per rrweb Custom event contract used by web / Swift. - put("payload", JsonPrimitive(payloadJSONString)) - } + "payload" to payloadJSONString, + ) return Event( type = EventType.CUSTOM, @@ -433,10 +421,10 @@ class RRWebEventGenerator( * (here the screen name) as a plain string. */ fun generateNavigateEvent(navigate: NavigateItemPayload): Event { - val customData = buildJsonObject { - put("tag", JsonPrimitive(RRWebCustomDataTag.NAVIGATE.wireValue)) - put("payload", JsonPrimitive(navigate.name)) - } + val customData = mapOf( + "tag" to RRWebCustomDataTag.NAVIGATE.wireValue, + "payload" to navigate.name, + ) return Event( type = EventType.CUSTOM, @@ -457,16 +445,16 @@ class RRWebEventGenerator( * - `clickSelector`: stable id else class name (web: `#id` else tag) */ fun generateClickEvent(click: ClickItemPayload): Event { - val customData = buildJsonObject { - put("tag", RRWebCustomDataTag.CLICK.wireValue) - putJsonObject("payload") { + val customData = mapOf( + "tag" to RRWebCustomDataTag.CLICK.wireValue, + "payload" to buildMap { put("clickTarget", click.target ?: "") put("clickTextContent", click.text ?: "") put("clickSelector", click.id ?: click.target ?: CLICK_SELECTOR_FALLBACK) click.screenId?.let { put("screenId", it) } click.screenName?.let { put("screenName", it) } - } - } + }, + ) return Event( type = EventType.CUSTOM, @@ -483,17 +471,19 @@ class RRWebEventGenerator( */ fun generateAppLifecycleEvent(payload: AppLifecycleItemPayload): Event? { val payloadJSONString = try { - buildJsonObject { - payload.lifecycleState?.let { put("lifecycle_state", it) } - }.toString() + jsonString( + buildMap { + payload.lifecycleState?.let { put("lifecycle_state", it) } + } + ) } catch (_: Exception) { return null } - val customData = buildJsonObject { - put("tag", JsonPrimitive(payload.tag.wireValue)) - put("payload", JsonPrimitive(payloadJSONString)) - } + val customData = mapOf( + "tag" to payload.tag.wireValue, + "payload" to payloadJSONString, + ) return Event( type = EventType.CUSTOM, @@ -509,20 +499,22 @@ class RRWebEventGenerator( */ fun generateAppLaunchEvent(payload: AppLaunchItemPayload): Event? { val payloadJSONString = try { - buildJsonObject { - payload.launchType?.let { put("launch_type", it) } - payload.version?.let { put("version", it) } - payload.build?.let { put("build", it) } - payload.previousVersion?.let { put("previous_version", it) } - }.toString() + jsonString( + buildMap { + payload.launchType?.let { put("launch_type", it) } + payload.version?.let { put("version", it) } + payload.build?.let { put("build", it) } + payload.previousVersion?.let { put("previous_version", it) } + } + ) } catch (_: Exception) { return null } - val customData = buildJsonObject { - put("tag", JsonPrimitive(payload.tag.wireValue)) - put("payload", JsonPrimitive(payloadJSONString)) - } + val customData = mapOf( + "tag" to payload.tag.wireValue, + "payload" to payloadJSONString, + ) return Event( type = EventType.CUSTOM, @@ -553,10 +545,10 @@ class RRWebEventGenerator( } private fun generateReloadEvent(timestamp: Long): Event { - val customData = buildJsonObject { - put("tag", JsonPrimitive(RRWebCustomDataTag.RELOAD.wireValue)) - put("payload", JsonPrimitive(title)) - } + val customData = mapOf( + "tag" to RRWebCustomDataTag.RELOAD.wireValue, + "payload" to title, + ) return Event( type = EventType.CUSTOM, timestamp = timestamp, @@ -571,14 +563,14 @@ class RRWebEventGenerator( id: Int, timestamp: Long ): Event { - val customData = buildJsonObject { - put("source", RRWebIncrementalSource.MOUSE_INTERACTION.code) - putJsonArray("texts") {} - put("type", interactionType.code) - put("id", id) - put("x", RRWEB_DOCUMENT_PADDING) - put("y", RRWEB_DOCUMENT_PADDING) - } + val customData = mapOf( + "source" to RRWebIncrementalSource.MOUSE_INTERACTION.code, + "texts" to emptyList(), + "type" to interactionType.code, + "id" to id, + "x" to RRWEB_DOCUMENT_PADDING, + "y" to RRWEB_DOCUMENT_PADDING, + ) return Event( type = eventType, timestamp = timestamp, diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayApiService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayApiService.kt index 6b6d5e2ebb..1fa58938c0 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayApiService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/exporter/SessionReplayApiService.kt @@ -10,24 +10,14 @@ import com.launchdarkly.observability.replay.IdentifySessionResponse import com.launchdarkly.observability.replay.InitializeReplaySessionResponse import com.launchdarkly.observability.replay.PushPayloadResponse import com.launchdarkly.observability.replay.ReplayEventsInput -import kotlinx.serialization.KSerializer -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive +import com.launchdarkly.observability.replay.asJsonWritable +import org.json.JSONObject class SessionReplayApiService( private val graphqlClient: GraphQLClient, val serviceName: String, val serviceVersion: String, ) { - private val json: Json = Json { - isLenient = true - ignoreUnknownKeys = true - } - companion object { private val INITIALIZE_REPLAY_SESSION_QUERY = """ fragment MatchParts on MatchConfig { @@ -162,27 +152,27 @@ class SessionReplayApiService( */ suspend fun initializeReplaySession(organizationVerboseId: String, sessionSecureId: String) { val variables = mapOf( - "organization_verbose_id" to JsonPrimitive(organizationVerboseId), - "session_secure_id" to JsonPrimitive(sessionSecureId), - "enable_strict_privacy" to JsonPrimitive(false), - "enable_recording_network_contents" to JsonPrimitive(false), - "clientVersion" to JsonPrimitive(BuildConfig.OBSERVABILITY_SDK_VERSION), - "firstloadVersion" to JsonPrimitive(BuildConfig.OBSERVABILITY_SDK_VERSION), - "clientConfig" to JsonPrimitive("{}"), // TODO: O11Y-631 - remove hardcoded params - "environment" to JsonPrimitive(""), // TODO: O11Y-631 - remove hardcoded params - "appVersion" to JsonPrimitive(serviceVersion), - "serviceName" to JsonPrimitive(serviceName), - "fingerprint" to JsonPrimitive(""), // TODO: O11Y-631 - remove hardcoded params - "client_id" to JsonPrimitive("observability-android"), - "network_recording_domains" to JsonArray(emptyList()), - "privacy_setting" to JsonPrimitive("none"), // TODO: O11Y-631 - remove hardcoded params - "id" to JsonPrimitive("") // TODO: O11Y-631 - remove hardcoded params + "organization_verbose_id" to organizationVerboseId, + "session_secure_id" to sessionSecureId, + "enable_strict_privacy" to false, + "enable_recording_network_contents" to false, + "clientVersion" to BuildConfig.OBSERVABILITY_SDK_VERSION, + "firstloadVersion" to BuildConfig.OBSERVABILITY_SDK_VERSION, + "clientConfig" to "{}", // TODO: O11Y-631 - remove hardcoded params + "environment" to "", // TODO: O11Y-631 - remove hardcoded params + "appVersion" to serviceVersion, + "serviceName" to serviceName, + "fingerprint" to "", // TODO: O11Y-631 - remove hardcoded params + "client_id" to "observability-android", + "network_recording_domains" to emptyList(), + "privacy_setting" to "none", // TODO: O11Y-631 - remove hardcoded params + "id" to "" // TODO: O11Y-631 - remove hardcoded params ) execute( operation = "initializeReplaySession", query = INITIALIZE_REPLAY_SESSION_QUERY, variables = variables, - dataSerializer = InitializeReplaySessionResponse.serializer() + dataParser = InitializeReplaySessionResponse::fromJson ) } @@ -195,18 +185,18 @@ class SessionReplayApiService( suspend fun identifyReplaySession( sessionSecureId: String, userIdentifier: String = "", // TODO: O11Y-631 - remove hardcoded params - userObject: JsonElement = JsonNull + userObject: Map? = null ) { val variables = mapOf( - "session_secure_id" to JsonPrimitive(sessionSecureId), - "user_identifier" to JsonPrimitive(userIdentifier), + "session_secure_id" to sessionSecureId, + "user_identifier" to userIdentifier, "user_object" to userObject ) execute( operation = "identifyReplaySession", query = IDENTIFY_REPLAY_SESSION_QUERY, variables = variables, - dataSerializer = IdentifySessionResponse.serializer() + dataParser = IdentifySessionResponse::fromJson ) } @@ -218,7 +208,7 @@ class SessionReplayApiService( identifyEvent: IdentifyItemPayload ) { val userIdentifier = identifyEvent.attributes["key"] ?: "unknown" - val userObject = JsonObject(identifyEvent.attributes.mapValues { JsonPrimitive(it.value) }) + val userObject = identifyEvent.attributes identifyReplaySession( sessionSecureId = sessionSecureId, userIdentifier = userIdentifier, @@ -235,23 +225,20 @@ class SessionReplayApiService( suspend fun pushPayload(sessionSecureId: String, payloadId: String, events: List) { val events = events.sortedBy { it.timestamp } val variables = mapOf( - "session_secure_id" to JsonPrimitive(sessionSecureId), - "payload_id" to JsonPrimitive(payloadId), - "events" to json.encodeToJsonElement( - ReplayEventsInput.serializer(), - ReplayEventsInput(events) - ), - "messages" to JsonPrimitive("{\"messages\":[]}"), - "resources" to JsonPrimitive("{\"resources\":[]}"), - "web_socket_events" to JsonPrimitive("{\"webSocketEvents\":[]}"), - "errors" to JsonArray(emptyList()), + "session_secure_id" to sessionSecureId, + "payload_id" to payloadId, + "events" to ReplayEventsInput(events).asJsonWritable(), + "messages" to "{\"messages\":[]}", + "resources" to "{\"resources\":[]}", + "web_socket_events" to "{\"webSocketEvents\":[]}", + "errors" to emptyList(), ) execute( operation = "pushPayload", query = PUSH_PAYLOAD_QUERY, variables = variables, - dataSerializer = PushPayloadResponse.serializer() + dataParser = PushPayloadResponse::fromJson ) } @@ -262,11 +249,11 @@ class SessionReplayApiService( private suspend fun execute( operation: String, query: String, - variables: Map, - dataSerializer: KSerializer, + variables: Map, + dataParser: (JSONObject) -> T, ) { try { - graphqlClient.execute(query = query, variables = variables, dataSerializer = dataSerializer) + graphqlClient.execute(query = query, variables = variables, dataParser = dataParser) } catch (e: GraphQLClientException) { throw SessionReplayApiException(operation, e) } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/traces/OtlpTraceExporter.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/traces/OtlpTraceExporter.kt index 3ce3fdfbd4..3a1ae7826d 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/traces/OtlpTraceExporter.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/traces/OtlpTraceExporter.kt @@ -3,7 +3,7 @@ package com.launchdarkly.observability.traces import com.launchdarkly.observability.otlp.OtlpConfiguration import com.launchdarkly.observability.otlp.OtlpHttpClient import com.launchdarkly.observability.otlp.json.traces.JsonSpanAdapter -import com.launchdarkly.observability.otlp.json.traces.OtlpJsonExportTraceServiceRequest +import com.launchdarkly.observability.otlp.json.toJsonBytes import com.launchdarkly.observability.replay.transport.EventExporting import com.launchdarkly.observability.replay.transport.EventQueueItem import io.opentelemetry.sdk.trace.data.SpanData @@ -30,9 +30,6 @@ class OtlpTraceExporter( if (spans.isEmpty()) return val body = JsonSpanAdapter.toJsonRequest(spans) - httpClient.send( - body = body, - serializer = OtlpJsonExportTraceServiceRequest.serializer(), - ) + httpClient.send(body.toJsonBytes()) } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/json/JsonByteWriterTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/json/JsonByteWriterTest.kt new file mode 100644 index 0000000000..b715cccd79 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/json/JsonByteWriterTest.kt @@ -0,0 +1,309 @@ +package com.launchdarkly.observability.json + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import kotlin.random.Random + +class JsonByteWriterTest { + + private fun write(block: JsonByteWriter.() -> Unit): String = JsonByteWriter.encodeToString(write = block) + + private fun quoted(value: String): String = write { value(value) } + + @Nested + inner class Structure { + + @Test + fun `empty containers`() { + assertEquals("{}", write { beginObject().endObject() }) + assertEquals("[]", write { beginArray().endArray() }) + } + + @Test + fun `members and elements are separated by commas, names by colons`() { + val json = write { + beginObject() + name("a").value(1) + name("b").beginArray().value("x").value(true).nullValue().endArray() + name("c").beginObject().name("d").value(false).endObject() + name("e").beginArray().endArray() + endObject() + } + assertEquals("""{"a":1,"b":["x",true,null],"c":{"d":false},"e":[]}""", json) + } + + @Test + fun `consecutive containers in an array are separated`() { + val json = write { + beginArray() + beginObject().endObject() + beginObject().name("k").value(1).endObject() + beginArray().endArray() + endArray() + } + assertEquals("""[{},{"k":1},[]]""", json) + } + + @Test + fun `top-level values with no container`() { + assertEquals("\"s\"", write { value("s") }) + assertEquals("42", write { value(42) }) + assertEquals("null", write { nullValue() }) + } + } + + @Nested + inner class Numbers { + + @ParameterizedTest + @ValueSource(longs = [0, 1, -1, 9, 10, 99, 100, 1234567890, -1234567890, Long.MAX_VALUE, Long.MIN_VALUE, Long.MIN_VALUE + 1]) + fun `longs are written as Long toString spells them`(value: Long) { + assertEquals(value.toString(), write { value(value) }) + } + + @Test + fun `ints widen to the same digits`() { + assertEquals("-2147483648", write { value(Int.MIN_VALUE) }) + assertEquals("2147483647", write { value(Int.MAX_VALUE) }) + } + + @ParameterizedTest + @ValueSource(doubles = [0.0, -0.0, 1.0, -1.5, 0.1, 9.99, 1.0E20, 1.0E-7, 123456.789, Double.MAX_VALUE, Double.MIN_VALUE]) + fun `doubles are written as Double toString spells them`(value: Double) { + assertEquals(value.toString(), write { value(value) }) + } + + @ParameterizedTest + @ValueSource(doubles = [Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY]) + fun `non-finite doubles are refused`(value: Double) { + assertThrows { write { value(value) } } + } + } + + @Nested + inner class Strings { + + @Test + fun `quotes and backslashes are escaped`() { + assertEquals(""""a\"b\\c"""", quoted("a\"b\\c")) + } + + @Test + fun `control characters use short escapes where JSON has one`() { + assertEquals(""""\t\b\n\r\f"""", quoted("\t\b\n\r\u000C")) + } + + @Test + fun `other control characters use unicode escapes`() { + assertEquals(""""\u0000\u0001\u001f"""", quoted("\u0000\u0001\u001F")) + } + + @Test + fun `DEL and printable ASCII are written as they are`() { + val printable = (0x20 until 0x80).map { it.toChar() }.filter { it != '"' && it != '\\' }.joinToString("") + assertEquals("\"$printable\"", quoted(printable)) + } + + @Test + fun `HTML characters are not escaped`() { + assertEquals(""""&="""", quoted("&=")) + } + + @Test + fun `line and paragraph separators are escaped`() { + assertEquals(""""a\u2028b\u2029c"""", quoted("a\u2028b\u2029c")) + } + + @Test + fun `non-ASCII text is UTF-8 encoded`() { + val text = "é€\uD83D\uDE00日本" + val bytes = JsonByteWriter.encode { value(text) } + assertArrayEquals("\"$text\"".toByteArray(Charsets.UTF_8), bytes) + } + + @Test + fun `boundaries of each UTF-8 width`() { + val text = "\u007F\u0080\u07FF\u0800\uFFFF\uD800\uDC00\uDBFF\uDFFF" + assertArrayEquals("\"$text\"".toByteArray(Charsets.UTF_8), JsonByteWriter.encode { value(text) }) + } + + @Test + fun `a surrogate without its other half is written as a question mark`() { + assertEquals("\"a?b\"", quoted("a\uD800b")) + assertEquals("\"a?b\"", quoted("a\uDC00b")) + assertEquals("\"a?\"", quoted("a\uD800")) + assertEquals("\"??\"", quoted("\uDC00\uD800")) + } + + @Test + fun `a null string is written as null`() { + assertEquals("""{"k":null}""", write { beginObject().name("k").value(null as String?).endObject() }) + } + + @Test + fun `names are escaped like values`() { + assertEquals("""{"a\"\n€":1}""", write { beginObject().name("a\"\n€").value(1).endObject() }) + } + } + + @Nested + inner class Optional { + + @Test + fun `null optional members are left out, name and all`() { + val json = write { + beginObject() + optional("s", null as String?) + optional("b", null as Boolean?) + optional("i", null as Int?) + optional("l", null as Long?) + optional("d", null as Double?) + name("kept").value(1) + endObject() + } + assertEquals("""{"kept":1}""", json) + } + + @Test + fun `present optional members are written`() { + val json = write { + beginObject() + optional("s", "x") + optional("b", false) + optional("i", 1) + optional("l", 2L) + optional("d", 3.5) + endObject() + } + assertEquals("""{"s":"x","b":false,"i":1,"l":2,"d":3.5}""", json) + } + } + + @Nested + inner class AnyValue { + + @Test + fun `maps, lists and primitives are written by their runtime type`() { + val value = linkedMapOf( + "s" to "x", + "i" to 1, + "l" to 2L, + "d" to 1.5, + "f" to 2.5f, + "b" to true, + "n" to null, + "list" to listOf(1, "two", null, listOf()), + "array" to arrayOf("a"), + "map" to mapOf(1 to "keyed by toString"), + ) + assertEquals( + """{"s":"x","i":1,"l":2,"d":1.5,"f":2.5,"b":true,"n":null,"list":[1,"two",null,[]],"array":["a"],"map":{"1":"keyed by toString"}}""", + write { anyValue(value) } + ) + } + + @Test + fun `floats are written as Float toString spells them`() { + assertEquals("0.1", write { anyValue(0.1f) }) + } + + @Test + fun `a JsonWritable writes itself`() { + val writable = JsonWritable { it.beginObject().name("own").value(true).endObject() } + assertEquals("""[{"own":true},1]""", write { anyValue(listOf(writable, 1)) }) + } + + @Test + fun `unsupported types are refused`() { + assertThrows { write { anyValue(Any()) } } + } + + @Test + fun `non-finite numbers are refused`() { + assertThrows { write { anyValue(Float.NaN) } } + assertThrows { write { anyValue(Double.POSITIVE_INFINITY) } } + } + } + + @Nested + inner class Buffer { + + @Test + fun `raw JSON is placed where a value goes`() { + val raw = JsonByteWriter.encode { beginObject().name("x").value(1).endObject() } + assertEquals("""[1,{"x":1},2]""", write { beginArray().value(1).writeRaw(raw).value(2).endArray() }) + } + + @Test + fun `reset starts a new document in the same buffer`() { + val writer = JsonByteWriter() + writer.beginArray().value(1).endArray() + writer.reset() + writer.value(2) + assertEquals("2", writer.toUtf8String()) + assertEquals(1, writer.size) + } + + @Test + fun `copies are independent of the buffer`() { + val writer = JsonByteWriter() + writer.beginArray().value("a") + val offset = writer.size + writer.value("b").endArray() + val copy = writer.toByteArray() + val tail = writer.bytesFrom(offset) + writer.reset() + writer.value("overwritten") + assertEquals("""["a","b"]""", String(copy, Charsets.UTF_8)) + assertEquals(""","b"]""", String(tail, Charsets.UTF_8)) + } + + @Test + fun `the buffer grows from a tiny capacity`() { + val writer = JsonByteWriter(initialCapacity = 1) + writer.beginArray() + repeat(1_000) { writer.value(it).value("€$it") } + writer.endArray() + val parsed = JSONArray(writer.toUtf8String()) + assertEquals(2_000, parsed.length()) + assertEquals("€999", parsed.getString(1_999)) + } + + @Test + fun `a long ASCII string does not reserve room for escapes`() { + val writer = JsonByteWriter(initialCapacity = 16) + val ascii = "A".repeat(100_000) + writer.value(ascii) + assertEquals(100_002, writer.size) + } + + @Test + fun `wide characters late in a long string still fit`() { + val text = "A".repeat(10_000) + "\u0001€\uD83D\uDE00\u2028" + val writer = JsonByteWriter(initialCapacity = 16) + writer.value(text) + assertEquals(text, JSONArray("[${writer.toUtf8String()}]").getString(0)) + } + } + + @Test + fun `random strings survive a round trip through a JSON parser`() { + val random = Random(42) + val alphabet = listOf("a", "Z", " ", "\"", "\\", "\n", "\u0000", "\u001F", "\u007F", "é", "€", "\u2028", "日", "\uD83D\uDE00") + repeat(500) { + val text = buildString { + repeat(random.nextInt(0, 64)) { append(alphabet[random.nextInt(alphabet.size)]) } + } + val json = write { beginObject().name(text).value(text).endObject() } + val parsed = JSONObject(json) + assertEquals(text, parsed.getString(text)) + } + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/network/GraphQLClientTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/network/GraphQLClientTest.kt index c7958fed38..16cabc7861 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/network/GraphQLClientTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/network/GraphQLClientTest.kt @@ -5,8 +5,7 @@ import io.mockk.mockk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.test.runTest -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonPrimitive +import org.json.JSONObject import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull @@ -15,13 +14,17 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.IOException import java.net.HttpURLConnection class GraphQLClientTest { - @Serializable - data class TestData(val id: String, val name: String) + data class TestData(val id: String, val name: String) { + companion object { + fun fromJson(json: JSONObject) = TestData(id = json.getString("id"), name = json.getString("name")) + } + } private val testQuery = """ query EmptyQuery { @@ -63,8 +66,8 @@ class GraphQLClientTest { val result = graphQLClient.execute( query = testQuery, - variables = mapOf("test_variable" to JsonPrimitive("567")), - dataSerializer = TestData.serializer() + variables = mapOf("test_variable" to "567"), + dataParser = TestData::fromJson ) assertEquals("123", result.id) @@ -185,10 +188,48 @@ class GraphQLClientTest { } } + @Test + fun `request body leaves out variables when there are none`() = runTest { + val body = captureRequestBody() + respondWith("""{"data": {"id": "1", "name": "n"}}""") + + graphQLClient.execute(query = "query Q { f }", dataParser = TestData::fromJson, compress = false) + + assertEquals("""{"query":"query Q { f }"}""", body.toString(Charsets.UTF_8.name())) + } + + @Test + fun `request body writes variables by their runtime type`() = runTest { + val body = captureRequestBody() + respondWith("""{"data": {"id": "1", "name": "n"}}""") + + graphQLClient.execute( + query = "q", + variables = linkedMapOf( + "s" to "a\"b", + "b" to false, + "n" to null, + "i" to 1, + "list" to emptyList(), + "obj" to mapOf("k" to "v"), + ), + dataParser = TestData::fromJson, + compress = false, + ) + + assertEquals( + """{"query":"q","variables":{"s":"a\"b","b":false,"n":null,"i":1,"list":[],"obj":{"k":"v"}}}""", + body.toString(Charsets.UTF_8.name()) + ) + } + + private fun captureRequestBody(): ByteArrayOutputStream = + ByteArrayOutputStream().also { every { mockConnection.outputStream } returns it } + private fun respondWith(responseJson: String) { every { mockConnection.inputStream } returns ByteArrayInputStream(responseJson.toByteArray()) } private suspend fun execute(): TestData = - graphQLClient.execute(query = testQuery, dataSerializer = TestData.serializer()) + graphQLClient.execute(query = testQuery, dataParser = TestData::fromJson) } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/network/SamplingApiServiceTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/network/SamplingApiServiceTest.kt index 989df0f3d9..e6721d3e90 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/network/SamplingApiServiceTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/network/SamplingApiServiceTest.kt @@ -4,7 +4,6 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.JsonPrimitive import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -48,10 +47,10 @@ class SamplingApiServiceTest { ) ) coEvery { - mockGraphqlClient.execute( + mockGraphqlClient.execute( any(), - mapOf("organization_verbose_id" to JsonPrimitive(organizationId)), - SamplingResponse.serializer() + mapOf("organization_verbose_id" to organizationId), + any() ) } returns samplingResponse @@ -61,10 +60,10 @@ class SamplingApiServiceTest { assertEquals(samplingResponse.mapToEntity(), result) coVerify(exactly = 1) { - mockGraphqlClient.execute( + mockGraphqlClient.execute( any(), - mapOf("organization_verbose_id" to JsonPrimitive(organizationId)), - SamplingResponse.serializer() + mapOf("organization_verbose_id" to organizationId), + any() ) } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/network/SamplingResponseTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/network/SamplingResponseTest.kt index 01a9425fe2..4230a31642 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/network/SamplingResponseTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/network/SamplingResponseTest.kt @@ -11,6 +11,9 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.assertThrows +import org.json.JSONException +import org.json.JSONObject class SamplingResponseTest { @@ -311,4 +314,80 @@ class SamplingResponseTest { assertEquals("exact", (result as MatchConfig.Value).value) } } + + @Nested + @DisplayName("SamplingResponse parsing") + inner class SamplingResponseParsingTests { + + @Test + fun `should parse a full sampling response`() { + val response = SamplingResponse.fromJson( + JSONObject( + """ + {"sampling": { + "spans": [{ + "name": {"regexValue": ".*http.*", "matchValue": null}, + "attributes": [{"key": {"matchValue": "service"}, "attribute": {"matchValue": "api"}}], + "events": [{"name": {"matchValue": "click"}, "attributes": []}], + "samplingRatio": 25 + }, null], + "logs": [{ + "message": {"matchValue": "error"}, + "severityText": {"regexValue": "ERROR|WARN"}, + "attributes": null, + "samplingRatio": 75, + "unknownField": true + }] + }} + """.trimIndent() + ) + ) + + val expected = SamplingResponse( + sampling = SamplingConfigResponse( + spans = listOf( + SpanSamplingConfigResponse( + name = MatchConfigResponse(regexValue = ".*http.*"), + attributes = listOf( + AttributeMatchConfigResponse( + key = MatchConfigResponse(matchValue = "service"), + attribute = MatchConfigResponse(matchValue = "api") + ) + ), + events = listOf( + SpanEventMatchConfigResponse( + name = MatchConfigResponse(matchValue = "click"), + attributes = emptyList() + ) + ), + samplingRatio = 25 + ), + null + ), + logs = listOf( + LogSamplingConfigResponse( + message = MatchConfigResponse(matchValue = "error"), + severityText = MatchConfigResponse(regexValue = "ERROR|WARN"), + attributes = null, + samplingRatio = 75 + ) + ) + ) + ) + assertEquals(expected, response) + } + + @Test + fun `should treat a null or missing sampling as absent`() { + assertNull(SamplingResponse.fromJson(JSONObject("""{"sampling": null}""")).sampling) + assertNull(SamplingResponse.fromJson(JSONObject("{}")).sampling) + } + + @Test + fun `should fail when a required attribute matcher is missing`() { + assertThrows { + AttributeMatchConfigResponse.fromJson(JSONObject("""{"key": {"matchValue": "service"}}""")) + } + } + } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/OtlpHttpClientTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/OtlpHttpClientTest.kt index 8ea9e49d60..cc09ee2b85 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/OtlpHttpClientTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/OtlpHttpClientTest.kt @@ -6,7 +6,6 @@ import io.mockk.mockk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.test.runTest -import kotlinx.serialization.Serializable import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows @@ -22,8 +21,7 @@ import kotlin.time.Duration.Companion.seconds class OtlpHttpClientTest { - @Serializable - data class FakeBody(val a: String, val b: Int) + private fun body(json: String): ByteArray = json.toByteArray(Charsets.UTF_8) private lateinit var mockConnection: HttpURLConnection private lateinit var capturedBody: ByteArrayOutputStream @@ -65,10 +63,7 @@ class OtlpHttpClientTest { fun `send gzips body and sets the expected headers`() = runTest { val client = clientWith() - client.send( - body = FakeBody(a = "hi", b = 7), - serializer = FakeBody.serializer(), - ) + client.send(body = body("""{"a":"hi","b":7}""")) assertEquals("https://otlp.example.com/v1/logs", openConnectionCalledWith) verify { @@ -88,10 +83,7 @@ class OtlpHttpClientTest { fun `send without gzip sends the raw body and omits Content-Encoding`() = runTest { val client = clientWith(OtlpConfiguration(compression = OtlpCompression.NONE)) - client.send( - body = FakeBody(a = "ok", b = 1), - serializer = FakeBody.serializer(), - ) + client.send(body = body("""{"a":"ok","b":1}""")) assertEquals("""{"a":"ok","b":1}""", capturedBody.toByteArray().toString(Charsets.UTF_8)) verify(exactly = 0) { mockConnection.setRequestProperty("Content-Encoding", any()) } @@ -106,7 +98,7 @@ class OtlpHttpClientTest { ) ) - client.send(FakeBody("x", 0), FakeBody.serializer()) + client.send(body("{}")) verify { mockConnection.setRequestProperty("X-Api-Key", "sdk-token") @@ -119,8 +111,7 @@ class OtlpHttpClientTest { val client = clientWith(OtlpConfiguration(timeout = 30.seconds)) client.send( - body = FakeBody("x", 0), - serializer = FakeBody.serializer(), + body = body("{}"), explicitTimeout = 5.seconds, ) @@ -137,7 +128,7 @@ class OtlpHttpClientTest { val client = clientWith() val error = assertThrows(IOException::class.java) { - runTest { client.send(FakeBody("x", 0), FakeBody.serializer()) } + runTest { client.send(body("{}")) } } assertTrue(error.message!!.contains("503"), "message should include status: ${error.message}") assertTrue(error.message!!.contains("oops"), "message should include body: ${error.message}") @@ -149,7 +140,7 @@ class OtlpHttpClientTest { every { mockConnection.errorStream } returns null val client = clientWith() - runCatching { client.send(FakeBody("x", 0), FakeBody.serializer()) } + runCatching { client.send(body("{}")) } verify { mockConnection.disconnect() } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/JsonTestHelpers.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/JsonTestHelpers.kt index d5250038fc..9724006e16 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/JsonTestHelpers.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/JsonTestHelpers.kt @@ -1,74 +1,74 @@ package com.launchdarkly.observability.otlp.json -import kotlinx.serialization.KSerializer -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.double -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +import org.json.JSONArray +import org.json.JSONObject +import org.json.JSONTokener + +/** A parsed JSON object, with nested objects as [JsonTree] and arrays as [List]. Explicit `null`s stay as `null` values. */ +typealias JsonTree = Map /** * Helpers mirroring the Swift `JsonTestHelpers` for poking at the OTLP/JSON tree produced by the - * per-signal adapters. All parsing is done through kotlinx-serialization (no reflection), so the - * resulting tree faithfully represents what the HTTP client will actually send on the wire. + * per-signal adapters. The tree is parsed from the exact bytes the HTTP client sends, so it faithfully + * represents what goes on the wire. */ object JsonTestHelpers { - private val ENCODER: Json = Json { encodeDefaults = false; explicitNulls = false } - fun encodeToTree(value: T, serializer: KSerializer): JsonObject { - val text = ENCODER.encodeToString(serializer, value) - return ENCODER.parseToJsonElement(text).jsonObject + fun encodeToTree(bytes: ByteArray): JsonTree = obj(parse(String(bytes, Charsets.UTF_8))) + + fun parse(json: String): Any? = toTree(JSONTokener(json).nextValue()) + + private fun toTree(value: Any?): Any? = when (value) { + JSONObject.NULL, null -> null + is JSONObject -> value.keys().asSequence().associateWith { toTree(value.get(it)) } + is JSONArray -> List(value.length()) { toTree(value.get(it)) } + else -> value } - fun obj(element: JsonElement?): JsonObject { + @Suppress("UNCHECKED_CAST") + fun obj(element: Any?): JsonTree { checkNotNull(element) { "expected object, got null" } - check(element is JsonObject) { "expected object, got $element" } - return element + check(element is Map<*, *>) { "expected object, got $element" } + return element as JsonTree } - fun array(element: JsonElement?): JsonArray { + fun array(element: Any?): List { checkNotNull(element) { "expected array, got null" } - check(element is JsonArray) { "expected array, got $element" } + check(element is List<*>) { "expected array, got $element" } return element } - fun stringOrNull(element: JsonElement?): String? { - if (element == null || element is JsonNull) return null - val primitive = element as? JsonPrimitive ?: error("expected primitive, got $element") - if (!primitive.isString) error("expected string, got $primitive") - return primitive.content + fun stringOrNull(element: Any?): String? { + if (element == null) return null + check(element is String) { "expected string, got $element" } + return element } - fun string(element: JsonElement?): String = + fun string(element: Any?): String = stringOrNull(element) ?: error("expected string, got null") - fun intOrNull(element: JsonElement?): Int? { - if (element == null || element is JsonNull) return null - val primitive = element as? JsonPrimitive ?: error("expected primitive, got $element") - return primitive.content.toIntOrNull() - ?: error("expected int, got ${primitive.content}") + fun intOrNull(element: Any?): Int? = when (element) { + null -> null + is Number -> element.toInt().also { check(it.toDouble() == element.toDouble()) { "expected int, got $element" } } + is String -> element.toIntOrNull() ?: error("expected int, got $element") + else -> error("expected int, got $element") } - fun int(element: JsonElement?): Int = + fun int(element: Any?): Int = intOrNull(element) ?: error("expected int, got null") - fun doubleOrNull(element: JsonElement?): Double? { - if (element == null || element is JsonNull) return null - val primitive = element as? JsonPrimitive ?: error("expected primitive, got $element") - return primitive.double + fun doubleOrNull(element: Any?): Double? = when (element) { + null -> null + is Number -> element.toDouble() + is String -> element.toDoubleOrNull() ?: error("expected double, got $element") + else -> error("expected double, got $element") } - fun double(element: JsonElement?): Double = + fun double(element: Any?): Double = doubleOrNull(element) ?: error("expected double, got null") - fun boolean(element: JsonElement?): Boolean { - val primitive = element as? JsonPrimitive ?: error("expected primitive, got $element") - return primitive.boolean + fun boolean(element: Any?): Boolean { + check(element is Boolean) { "expected boolean, got $element" } + return element } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/OtlpJsonEncodingTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/OtlpJsonEncodingTest.kt new file mode 100644 index 0000000000..2c3ab5a3ad --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/OtlpJsonEncodingTest.kt @@ -0,0 +1,227 @@ +package com.launchdarkly.observability.otlp.json + +import com.launchdarkly.observability.otlp.json.common.JsonStringLong +import com.launchdarkly.observability.otlp.json.common.OtlpJsonAnyValue +import com.launchdarkly.observability.otlp.json.common.OtlpJsonInstrumentationScope +import com.launchdarkly.observability.otlp.json.common.OtlpJsonKeyValue +import com.launchdarkly.observability.otlp.json.common.OtlpJsonResource +import com.launchdarkly.observability.otlp.json.logs.OtlpJsonExportLogsServiceRequest +import com.launchdarkly.observability.otlp.json.logs.OtlpJsonLogRecord +import com.launchdarkly.observability.otlp.json.logs.OtlpJsonResourceLogs +import com.launchdarkly.observability.otlp.json.logs.OtlpJsonScopeLogs +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonAggregationTemporality +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonExemplar +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonExponentialHistogram +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonExponentialHistogramDataPoint +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonExportMetricsServiceRequest +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonGauge +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonHistogram +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonHistogramDataPoint +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonMetric +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonNumberDataPoint +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonNumberValue +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonResourceMetrics +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonScopeMetrics +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonSum +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonSummary +import com.launchdarkly.observability.otlp.json.metrics.OtlpJsonSummaryDataPoint +import com.launchdarkly.observability.otlp.json.traces.OtlpJsonExportTraceServiceRequest +import com.launchdarkly.observability.otlp.json.traces.OtlpJsonResourceSpans +import com.launchdarkly.observability.otlp.json.traces.OtlpJsonScopeSpans +import com.launchdarkly.observability.otlp.json.traces.OtlpJsonSpan +import com.launchdarkly.observability.otlp.json.traces.OtlpJsonSpanKind +import com.launchdarkly.observability.otlp.json.traces.OtlpJsonStatus +import com.launchdarkly.observability.otlp.json.traces.OtlpJsonStatusCode +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** Exact OTLP/JSON output: field order, left-out optional fields, and the proto3 JSON spellings. */ +class OtlpJsonEncodingTest { + private val ts = JsonStringLong(1_700_000_000_000_000_000) + + private fun logs(record: OtlpJsonLogRecord, resource: OtlpJsonResource? = null, scope: OtlpJsonInstrumentationScope? = null): String = + String( + OtlpJsonExportLogsServiceRequest(listOf(OtlpJsonResourceLogs(resource, listOf(OtlpJsonScopeLogs(scope, listOf(record)))))).toJsonBytes(), + Charsets.UTF_8 + ) + + private fun metric(metric: OtlpJsonMetric): String { + val json = String( + OtlpJsonExportMetricsServiceRequest(listOf(OtlpJsonResourceMetrics(scopeMetrics = listOf(OtlpJsonScopeMetrics(metrics = listOf(metric)))))).toJsonBytes(), + Charsets.UTF_8 + ) + return json.removePrefix("""{"resourceMetrics":[{"scopeMetrics":[{"metrics":[""").removeSuffix("]}]}]}") + } + + @Test + fun `a log record with nothing set is an empty object`() { + assertEquals("""{"resourceLogs":[{"scopeLogs":[{"logRecords":[{}]}]}]}""", logs(OtlpJsonLogRecord())) + } + + @Test + fun `a full log record keeps proto field order and writes 64-bit integers as strings`() { + val record = OtlpJsonLogRecord( + timeUnixNano = ts, + observedTimeUnixNano = JsonStringLong(2), + severityNumber = 9, + severityText = "INFO", + body = OtlpJsonAnyValue.string("hello"), + attributes = emptyList(), + droppedAttributesCount = 1, + flags = 1, + traceId = "t", + spanId = "s", + eventName = "e", + ) + assertEquals( + """{"resourceLogs":[{"scopeLogs":[{"logRecords":[{"timeUnixNano":"1700000000000000000","observedTimeUnixNano":"2",""" + + """"severityNumber":9,"severityText":"INFO","body":{"stringValue":"hello"},"attributes":[],"droppedAttributesCount":1,""" + + """"flags":1,"traceId":"t","spanId":"s","eventName":"e"}]}]}]}""", + logs(record) + ) + } + + @Test + fun `a resource leaves out an empty attribute list, a scope keeps one`() { + val json = logs( + OtlpJsonLogRecord(), + resource = OtlpJsonResource(), + scope = OtlpJsonInstrumentationScope(name = "n", attributes = emptyList()), + ) + assertEquals( + """{"resourceLogs":[{"resource":{},"scopeLogs":[{"scope":{"name":"n","attributes":[]},"logRecords":[{}]}]}]}""", + json + ) + } + + @Test + fun `each AnyValue is one member named for its variant`() { + val values = listOf( + OtlpJsonAnyValue.string("s"), + OtlpJsonAnyValue.bool(false), + OtlpJsonAnyValue.int(-42), + OtlpJsonAnyValue.double(1.5), + OtlpJsonAnyValue.array(listOf(OtlpJsonAnyValue.int(1))), + OtlpJsonAnyValue.array(emptyList()), + OtlpJsonAnyValue.kvlist(listOf(OtlpJsonKeyValue("k", OtlpJsonAnyValue.bool(true)))), + OtlpJsonAnyValue.kvlist(emptyList()), + OtlpJsonAnyValue.bytes("AAE="), + ) + val bodies = values.map { logs(OtlpJsonLogRecord(body = it)).substringAfter("\"body\":").removeSuffix("}]}]}]}") } + assertEquals( + listOf( + """{"stringValue":"s"}""", + """{"boolValue":false}""", + """{"intValue":"-42"}""", + """{"doubleValue":1.5}""", + """{"arrayValue":{"values":[{"intValue":"1"}]}}""", + """{"arrayValue":{}}""", + """{"kvlistValue":{"values":[{"key":"k","value":{"boolValue":true}}]}}""", + """{"kvlistValue":{}}""", + """{"bytesValue":"AAE="}""", + ), + bodies + ) + } + + @Test + fun `a span writes enums by name and leaves out what is unset`() { + val span = OtlpJsonSpan( + traceId = "t", + spanId = "s", + name = "n", + kind = OtlpJsonSpanKind.SPAN_KIND_CLIENT, + startTimeUnixNano = ts, + endTimeUnixNano = JsonStringLong(0), + events = listOf(OtlpJsonSpan.Event(timeUnixNano = JsonStringLong(1), name = "e")), + links = listOf(OtlpJsonSpan.Link(traceId = "lt", spanId = "ls", flags = 1)), + status = OtlpJsonStatus(code = OtlpJsonStatusCode.STATUS_CODE_ERROR), + ) + val json = String( + OtlpJsonExportTraceServiceRequest(listOf(OtlpJsonResourceSpans(scopeSpans = listOf(OtlpJsonScopeSpans(spans = listOf(span)), OtlpJsonScopeSpans(spans = emptyList(), schemaUrl = "u"))))).toJsonBytes(), + Charsets.UTF_8 + ) + assertEquals( + """{"resourceSpans":[{"scopeSpans":[{"spans":[{"traceId":"t","spanId":"s","name":"n","kind":"SPAN_KIND_CLIENT",""" + + """"startTimeUnixNano":"1700000000000000000","endTimeUnixNano":"0","events":[{"timeUnixNano":"1","name":"e"}],""" + + """"links":[{"traceId":"lt","spanId":"ls","flags":1}],"status":{"code":"STATUS_CODE_ERROR"}}]},{"spans":[],"schemaUrl":"u"}]}]}""", + json + ) + } + + @Test + fun `gauge and sum points carry their value as asInt or asDouble`() { + val points = listOf( + OtlpJsonNumberDataPoint(startTimeUnixNano = JsonStringLong(1), timeUnixNano = JsonStringLong(2), value = OtlpJsonNumberValue.Int(3)), + OtlpJsonNumberDataPoint( + startTimeUnixNano = JsonStringLong(1), + timeUnixNano = JsonStringLong(2), + value = OtlpJsonNumberValue.Double(0.5), + exemplars = listOf(OtlpJsonExemplar(timeUnixNano = JsonStringLong(4), value = OtlpJsonNumberValue.Int(5), traceId = "t")), + ), + ) + assertEquals( + """{"name":"g","gauge":{"dataPoints":[{"startTimeUnixNano":"1","timeUnixNano":"2","asInt":"3"},""" + + """{"startTimeUnixNano":"1","timeUnixNano":"2","asDouble":0.5,"exemplars":[{"timeUnixNano":"4","asInt":"5","traceId":"t"}]}]}}""", + metric(OtlpJsonMetric("g", data = OtlpJsonMetric.Data.Gauge(OtlpJsonGauge(points)))) + ) + assertEquals( + """{"name":"s","description":"d","unit":"ms","sum":{"dataPoints":[],"aggregationTemporality":"AGGREGATION_TEMPORALITY_DELTA","isMonotonic":true}}""", + metric( + OtlpJsonMetric( + "s", "d", "ms", + OtlpJsonMetric.Data.Sum(OtlpJsonSum(emptyList(), OtlpJsonAggregationTemporality.AGGREGATION_TEMPORALITY_DELTA, true)) + ) + ) + ) + } + + @Test + fun `histogram points write counts as strings and bounds as numbers`() { + val point = OtlpJsonHistogramDataPoint( + startTimeUnixNano = JsonStringLong(1), + timeUnixNano = JsonStringLong(2), + count = JsonStringLong(7), + sum = 12.5, + bucketCounts = listOf(JsonStringLong(1), JsonStringLong(6)), + explicitBounds = listOf(10.0), + min = -1.0, + max = 99.0, + ) + assertEquals( + """{"name":"h","histogram":{"dataPoints":[{"startTimeUnixNano":"1","timeUnixNano":"2","count":"7","sum":12.5,""" + + """"bucketCounts":["1","6"],"explicitBounds":[10.0],"min":-1.0,"max":99.0}],"aggregationTemporality":"AGGREGATION_TEMPORALITY_CUMULATIVE"}}""", + metric(OtlpJsonMetric("h", data = OtlpJsonMetric.Data.Histogram(OtlpJsonHistogram(listOf(point), OtlpJsonAggregationTemporality.AGGREGATION_TEMPORALITY_CUMULATIVE)))) + ) + } + + @Test + fun `exponential histogram and summary points`() { + val exponential = OtlpJsonExponentialHistogramDataPoint( + startTimeUnixNano = JsonStringLong(1), + timeUnixNano = JsonStringLong(2), + count = JsonStringLong(3), + scale = -2, + zeroCount = JsonStringLong(1), + positive = OtlpJsonExponentialHistogramDataPoint.Buckets(offset = -1, bucketCounts = listOf(JsonStringLong(2))), + ) + assertEquals( + """{"name":"e","exponentialHistogram":{"dataPoints":[{"startTimeUnixNano":"1","timeUnixNano":"2","count":"3","scale":-2,""" + + """"zeroCount":"1","positive":{"offset":-1,"bucketCounts":["2"]}}],"aggregationTemporality":"AGGREGATION_TEMPORALITY_DELTA"}}""", + metric(OtlpJsonMetric("e", data = OtlpJsonMetric.Data.ExponentialHistogram(OtlpJsonExponentialHistogram(listOf(exponential), OtlpJsonAggregationTemporality.AGGREGATION_TEMPORALITY_DELTA)))) + ) + + val summary = OtlpJsonSummaryDataPoint( + startTimeUnixNano = JsonStringLong(1), + timeUnixNano = JsonStringLong(2), + count = JsonStringLong(2), + sum = 4.0, + quantileValues = listOf(OtlpJsonSummaryDataPoint.ValueAtQuantile(quantile = 0.5, value = 1.0)), + ) + assertEquals( + """{"name":"q","summary":{"dataPoints":[{"startTimeUnixNano":"1","timeUnixNano":"2","count":"2","sum":4.0,""" + + """"quantileValues":[{"quantile":0.5,"value":1.0}]}]}}""", + metric(OtlpJsonMetric("q", data = OtlpJsonMetric.Data.Summary(OtlpJsonSummary(listOf(summary))))) + ) + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/logs/JsonLogRecordAdapterTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/logs/JsonLogRecordAdapterTest.kt index 3b233a1b11..1c3ebbb7d7 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/logs/JsonLogRecordAdapterTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/logs/JsonLogRecordAdapterTest.kt @@ -2,6 +2,8 @@ package com.launchdarkly.observability.otlp.json.logs import com.launchdarkly.observability.otlp.json.JsonTestHelpers.array import com.launchdarkly.observability.otlp.json.JsonTestHelpers.encodeToTree +import com.launchdarkly.observability.otlp.json.JsonTree +import com.launchdarkly.observability.otlp.json.toJsonBytes import com.launchdarkly.observability.otlp.json.JsonTestHelpers.int import com.launchdarkly.observability.otlp.json.JsonTestHelpers.obj import com.launchdarkly.observability.otlp.json.JsonTestHelpers.string @@ -16,7 +18,6 @@ import io.opentelemetry.api.trace.TraceState import io.opentelemetry.sdk.common.InstrumentationScopeInfo import io.opentelemetry.sdk.resources.Resource import io.opentelemetry.sdk.testing.logs.TestLogRecordData -import kotlinx.serialization.json.JsonObject import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -180,12 +181,12 @@ class JsonLogRecordAdapterTest { assertEquals(Severity.ERROR.severityNumber, int(logRecord["severityNumber"])) } - private fun encode(record: io.opentelemetry.sdk.logs.data.LogRecordData): JsonObject { + private fun encode(record: io.opentelemetry.sdk.logs.data.LogRecordData): JsonTree { val request = JsonLogRecordAdapter.toJsonRequest(listOf(record)) - return encodeToTree(request, OtlpJsonExportLogsServiceRequest.serializer()) + return encodeToTree(request.toJsonBytes()) } - private fun drillToLogRecord(tree: JsonObject): JsonObject { + private fun drillToLogRecord(tree: JsonTree): JsonTree { val resourceLog = obj(array(tree["resourceLogs"])[0]) val scopeLog = obj(array(resourceLog["scopeLogs"])[0]) return obj(array(scopeLog["logRecords"])[0]) diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/metrics/JsonMetricsAdapterTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/metrics/JsonMetricsAdapterTest.kt index c201fd4020..a487a9dd32 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/metrics/JsonMetricsAdapterTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/metrics/JsonMetricsAdapterTest.kt @@ -4,6 +4,8 @@ import com.launchdarkly.observability.otlp.json.JsonTestHelpers.array import com.launchdarkly.observability.otlp.json.JsonTestHelpers.boolean import com.launchdarkly.observability.otlp.json.JsonTestHelpers.double import com.launchdarkly.observability.otlp.json.JsonTestHelpers.encodeToTree +import com.launchdarkly.observability.otlp.json.JsonTree +import com.launchdarkly.observability.otlp.json.toJsonBytes import com.launchdarkly.observability.otlp.json.JsonTestHelpers.obj import com.launchdarkly.observability.otlp.json.JsonTestHelpers.string import io.opentelemetry.api.common.AttributeKey @@ -23,7 +25,6 @@ import io.opentelemetry.sdk.metrics.internal.data.ImmutableLongPointData import io.opentelemetry.sdk.metrics.internal.data.ImmutableSumData import io.opentelemetry.sdk.resources.Resource import io.opentelemetry.sdk.testing.metrics.TestMetricData -import kotlinx.serialization.json.JsonObject import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue @@ -227,9 +228,9 @@ class JsonMetricsAdapterTest { ) } - private fun drillToMetric(metric: io.opentelemetry.sdk.metrics.data.MetricData): JsonObject { + private fun drillToMetric(metric: io.opentelemetry.sdk.metrics.data.MetricData): JsonTree { val request = JsonMetricsAdapter.toJsonRequest(listOf(metric)) - val tree = encodeToTree(request, OtlpJsonExportMetricsServiceRequest.serializer()) + val tree = encodeToTree(request.toJsonBytes()) val resourceMetric = obj(array(tree["resourceMetrics"])[0]) val scopeMetric = obj(array(resourceMetric["scopeMetrics"])[0]) diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/traces/JsonSpanAdapterTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/traces/JsonSpanAdapterTest.kt index 294a1fd3a6..7597e22876 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/traces/JsonSpanAdapterTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/otlp/json/traces/JsonSpanAdapterTest.kt @@ -2,6 +2,8 @@ package com.launchdarkly.observability.otlp.json.traces import com.launchdarkly.observability.otlp.json.JsonTestHelpers.array import com.launchdarkly.observability.otlp.json.JsonTestHelpers.encodeToTree +import com.launchdarkly.observability.otlp.json.JsonTree +import com.launchdarkly.observability.otlp.json.toJsonBytes import com.launchdarkly.observability.otlp.json.JsonTestHelpers.int import com.launchdarkly.observability.otlp.json.JsonTestHelpers.obj import com.launchdarkly.observability.otlp.json.JsonTestHelpers.string @@ -20,7 +22,6 @@ import io.opentelemetry.sdk.trace.data.EventData import io.opentelemetry.sdk.trace.data.LinkData import io.opentelemetry.sdk.trace.data.SpanData import io.opentelemetry.sdk.trace.data.StatusData -import kotlinx.serialization.json.JsonObject import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test @@ -217,12 +218,12 @@ class JsonSpanAdapterTest { assertNull(JsonSpanAdapter.encodeTraceState(TraceState.getDefault())) } - private fun encode(span: SpanData): JsonObject { + private fun encode(span: SpanData): JsonTree { val request = JsonSpanAdapter.toJsonRequest(listOf(span)) - return encodeToTree(request, OtlpJsonExportTraceServiceRequest.serializer()) + return encodeToTree(request.toJsonBytes()) } - private fun drillToSpan(tree: JsonObject): JsonObject { + private fun drillToSpan(tree: JsonTree): JsonTree { val resourceSpan = obj(array(tree["resourceSpans"])[0]) val scopeSpan = obj(array(resourceSpan["scopeSpans"])[0]) return obj(array(scopeSpan["spans"])[0]) diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/ReplayEventEncodingTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/ReplayEventEncodingTest.kt new file mode 100644 index 0000000000..d4c2f2dedd --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/ReplayEventEncodingTest.kt @@ -0,0 +1,99 @@ +package com.launchdarkly.observability.replay + +import com.launchdarkly.observability.json.JsonByteWriter +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** Exact rrweb JSON output: field order, left-out optional fields, and the always-present `childNodes`. */ +class ReplayEventEncodingTest { + + private fun encode(event: Event): String = JsonByteWriter.encodeToString { write(event) } + + @Test + fun `an event with empty standard data`() { + assertEquals( + """{"type":1,"data":{},"timestamp":0,"_sid":0}""", + encode(Event(EventType.LOAD, EventDataUnion.StandardEventData(EventData()), 0L, 0)) + ) + } + + @Test + fun `standard data writes every set field in declaration order, enums by value`() { + val data = EventData( + source = IncrementalSource.INPUT, + type = MouseInteractions.DBL_CLICK, + texts = listOf("a", ""), + attributes = listOf(Attributes(), Attributes(id = 1, attributes = linkedMapOf("k" to "v"))), + href = "h", + width = 1, + height = 2, + node = EventNode(type = NodeType.TEXT, name = "n", rootId = 9, id = 10), + removes = emptyList(), + adds = listOf(Addition(parentId = 3, node = EventNode(type = NodeType.ELEMENT, tagName = "img"))), + id = 3, + x = 1.5, + y = -0.0, + ) + assertEquals( + """{"type":3,"data":{"source":5,"type":4,"texts":["a",""],"attributes":[{},{"id":1,"attributes":{"k":"v"}}],""" + + """"href":"h","width":1,"height":2,"node":{"type":3,"name":"n","childNodes":[],"rootId":9,"id":10},"removes":[],""" + + """"adds":[{"parentId":3,"node":{"type":2,"tagName":"img","childNodes":[]}}],"id":3,"x":1.5,"y":-0.0},""" + + """"timestamp":9223372036854775807,"_sid":99}""", + encode(Event(EventType.INCREMENTAL_SNAPSHOT, EventDataUnion.StandardEventData(data), Long.MAX_VALUE, 99)) + ) + } + + @Test + fun `nodes nest and always carry childNodes`() { + val node = EventNode( + type = NodeType.DOCUMENT, + id = 1, + childNodes = listOf( + EventNode(type = NodeType.ELEMENT, tagName = "html", attributes = emptyMap(), id = 2), + ), + ) + assertEquals( + """{"type":2,"data":{"node":{"type":0,"childNodes":[{"type":2,"tagName":"html","attributes":{},"childNodes":[],"id":2}],"id":1}},"timestamp":5,"_sid":1}""", + encode(Event(EventType.FULL_SNAPSHOT, EventDataUnion.StandardEventData(EventData(node = node)), 5L, 1)) + ) + } + + @Test + fun `removals and additions`() { + val data = EventData( + source = IncrementalSource.MUTATION, + removes = listOf(Removal(parentId = 1, id = 2)), + adds = listOf(Addition(parentId = 1, nextId = 4, node = EventNode(type = NodeType.ELEMENT, id = 5))), + ) + assertEquals( + """{"type":3,"data":{"source":0,"removes":[{"parentId":1,"id":2}],"adds":[{"parentId":1,"nextId":4,"node":{"type":2,"childNodes":[],"id":5}}]},"timestamp":1,"_sid":1}""", + encode(Event(EventType.INCREMENTAL_SNAPSHOT, EventDataUnion.StandardEventData(data), 1L, 1)) + ) + } + + @Test + fun `custom data is written as given, nulls included`() { + val data = linkedMapOf( + "tag" to "Viewport", + "payload" to linkedMapOf("width" to 1, "height" to 2.5, "flag" to true, "none" to null, "list" to listOf(1, "a")), + ) + assertEquals( + """{"type":5,"data":{"tag":"Viewport","payload":{"width":1,"height":2.5,"flag":true,"none":null,"list":[1,"a"]}},"timestamp":7,"_sid":3}""", + encode(Event(EventType.CUSTOM, EventDataUnion.CustomEventDataWrapper(data), 7L, 3)) + ) + } + + @Test + fun `events input wraps the events in an object`() { + val input = ReplayEventsInput( + listOf( + Event(EventType.LOAD, EventDataUnion.StandardEventData(EventData()), 0L, 0), + Event(EventType.CUSTOM, EventDataUnion.CustomEventDataWrapper(mapOf("tag" to "Reload")), 1L, 1), + ) + ) + assertEquals( + """{"events":[{"type":1,"data":{},"timestamp":0,"_sid":0},{"type":5,"data":{"tag":"Reload"},"timestamp":1,"_sid":1}]}""", + JsonByteWriter.encodeToString { anyValue(input.asJsonWritable()) } + ) + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/ReplaySessionResponseParsingTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/ReplaySessionResponseParsingTest.kt new file mode 100644 index 0000000000..0d76d8bbd0 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/ReplaySessionResponseParsingTest.kt @@ -0,0 +1,56 @@ +package com.launchdarkly.observability.replay + +import com.launchdarkly.observability.network.MatchConfigResponse +import com.launchdarkly.observability.network.SamplingConfigResponse +import com.launchdarkly.observability.network.SpanSamplingConfigResponse +import org.json.JSONObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class ReplaySessionResponseParsingTest { + + @Test + fun `initialize session response reads snake_case ids and sampling`() { + val response = InitializeReplaySessionResponse.fromJson( + JSONObject( + """ + {"initializeSession": { + "secure_id": "secure-123", + "project_id": "42", + "sampling": {"spans": [{"name": {"matchValue": "span"}, "samplingRatio": 10}], "logs": null} + }} + """.trimIndent() + ) + ) + + assertEquals( + InitializeReplaySessionResponse( + initializeSession = InitializeSessionResponse( + secureId = "secure-123", + projectId = "42", + sampling = SamplingConfigResponse( + spans = listOf( + SpanSamplingConfigResponse(name = MatchConfigResponse(matchValue = "span"), samplingRatio = 10) + ), + logs = null + ) + ) + ), + response + ) + } + + @Test + fun `initialize session response tolerates a null session`() { + assertNull(InitializeReplaySessionResponse.fromJson(JSONObject("""{"initializeSession": null}""")).initializeSession) + } + + @Test + fun `identify and push payload responses read their scalar results`() { + assertEquals("secure-123", IdentifySessionResponse.fromJson(JSONObject("""{"identifySession": "secure-123"}""")).identifySession) + assertNull(IdentifySessionResponse.fromJson(JSONObject("""{"identifySession": null}""")).identifySession) + assertEquals(7, PushPayloadResponse.fromJson(JSONObject("""{"pushPayload": 7}""")).pushPayload) + assertNull(PushPayloadResponse.fromJson(JSONObject("{}")).pushPayload) + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGeneratorTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGeneratorTest.kt index 845db65dd4..0ff96b72fc 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGeneratorTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/exporter/RRWebEventGeneratorTest.kt @@ -15,11 +15,7 @@ import com.launchdarkly.observability.replay.capture.ImageSignature import com.launchdarkly.observability.replay.capture.IntRect import com.launchdarkly.observability.replay.capture.IntSize import com.launchdarkly.observability.replay.capture.TileSignature -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.double -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +import org.json.JSONObject import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -159,15 +155,15 @@ class RRWebEventGeneratorTest { assertEquals(EventType.CUSTOM, event.type) val custom = event.data as EventDataUnion.CustomEventDataWrapper - val obj = custom.data.jsonObject - assertEquals("Track", obj["tag"]!!.jsonPrimitive.content) + val obj = custom.data + assertEquals("Track", obj["tag"]) // payload is a stringified JSON, matching the web `addCustomEvent('Track', stringify(...))` - val payloadJson = Json.parseToJsonElement(obj["payload"]!!.jsonPrimitive.content).jsonObject - assertEquals("purchase", payloadJson["event"]!!.jsonPrimitive.content) - assertEquals(9.99, payloadJson["value"]!!.jsonPrimitive.double) - val data = payloadJson["data"]!!.jsonObject - assertEquals("USD", data["currency"]!!.jsonPrimitive.content) - assertEquals("2", data["count"]!!.jsonPrimitive.content) + val payloadJson = JSONObject(obj["payload"] as String) + assertEquals("purchase", payloadJson.getString("event")) + assertEquals(9.99, payloadJson.getDouble("value")) + val data = payloadJson.getJSONObject("data") + assertEquals("USD", data.getString("currency")) + assertEquals("2", data.getString("count")) } @Test @@ -184,9 +180,9 @@ class RRWebEventGeneratorTest { val event = generator.generateTrackEvent(payload)!! val custom = event.data as EventDataUnion.CustomEventDataWrapper - val payloadJson = Json.parseToJsonElement(custom.data.jsonObject["payload"]!!.jsonPrimitive.content).jsonObject - assertEquals("login", payloadJson["event"]!!.jsonPrimitive.content) - assertFalse(payloadJson.containsKey("value")) + val payloadJson = JSONObject(custom.data["payload"] as String) + assertEquals("login", payloadJson.getString("event")) + assertFalse(payloadJson.has("value")) } @Test @@ -203,10 +199,10 @@ class RRWebEventGeneratorTest { assertEquals(EventType.CUSTOM, event.type) assertEquals(7L, event.timestamp) val custom = event.data as EventDataUnion.CustomEventDataWrapper - val obj = custom.data.jsonObject - assertEquals("Navigate", obj["tag"]!!.jsonPrimitive.content) + val obj = custom.data + assertEquals("Navigate", obj["tag"]) // payload is the route as a plain string, matching web `addCustomEvent('Navigate', url)` - assertEquals("Profile", obj["payload"]!!.jsonPrimitive.content) + assertEquals("Profile", obj["payload"]) } @Test @@ -224,11 +220,11 @@ class RRWebEventGeneratorTest { assertEquals(EventType.CUSTOM, event.type) assertEquals(11L, event.timestamp) val custom = event.data as EventDataUnion.CustomEventDataWrapper - val obj = custom.data.jsonObject - assertEquals("Foreground", obj["tag"]!!.jsonPrimitive.content) + val obj = custom.data + assertEquals("Foreground", obj["tag"]) // payload is a stringified JSON object, matching the web/Swift rrweb Custom event contract. - val payloadJson = Json.parseToJsonElement(obj["payload"]!!.jsonPrimitive.content).jsonObject - assertEquals("foreground", payloadJson["lifecycle_state"]!!.jsonPrimitive.content) + val payloadJson = JSONObject(obj["payload"] as String) + assertEquals("foreground", payloadJson.getString("lifecycle_state")) } @Test @@ -244,10 +240,10 @@ class RRWebEventGeneratorTest { val event = generator.generateAppLifecycleEvent(payload)!! val custom = event.data as EventDataUnion.CustomEventDataWrapper - val obj = custom.data.jsonObject - assertEquals("Background", obj["tag"]!!.jsonPrimitive.content) - val payloadJson = Json.parseToJsonElement(obj["payload"]!!.jsonPrimitive.content).jsonObject - assertEquals("background", payloadJson["lifecycle_state"]!!.jsonPrimitive.content) + val obj = custom.data + assertEquals("Background", obj["tag"]) + val payloadJson = JSONObject(obj["payload"] as String) + assertEquals("background", payloadJson.getString("lifecycle_state")) } @Test @@ -264,10 +260,10 @@ class RRWebEventGeneratorTest { // The trail snapshot replay needs to draw the pointer is untouched. assertEquals(1, events.size) assertEquals(EventType.INCREMENTAL_SNAPSHOT, events.single().type) - val data = (events.single().data as EventDataUnion.CustomEventDataWrapper).data.jsonObject + val data = (events.single().data as EventDataUnion.CustomEventDataWrapper).data assertEquals( RRWebMouseInteraction.TOUCH_START.code, - data["type"]!!.jsonPrimitive.int, + data["type"], ) // A touch-down is not a click: it may still become a drag or a long press, and this stream // cannot see clicks an embedder resolves in its own UI tree. Clicks come from the funnel. @@ -294,15 +290,15 @@ class RRWebEventGeneratorTest { // asynchronous bridge and must still order against the touch snapshots around it. assertEquals(21L, event.timestamp) val custom = event.data as EventDataUnion.CustomEventDataWrapper - val obj = custom.data.jsonObject - assertEquals("Click", obj["tag"]!!.jsonPrimitive.content) - val clickPayload = obj["payload"]!!.jsonObject - assertEquals("ElevatedButton", clickPayload["clickTarget"]!!.jsonPrimitive.content) - assertEquals("Pay", clickPayload["clickTextContent"]!!.jsonPrimitive.content) + val obj = custom.data + assertEquals("Click", obj["tag"]) + val clickPayload = obj["payload"] as Map<*, *> + assertEquals("ElevatedButton", clickPayload["clickTarget"]) + assertEquals("Pay", clickPayload["clickTextContent"]) // Prefers the stable id, mirroring the web `#id` selector. - assertEquals("checkout.pay", clickPayload["clickSelector"]!!.jsonPrimitive.content) - assertEquals("cart-1", clickPayload["screenId"]!!.jsonPrimitive.content) - assertEquals("Cart", clickPayload["screenName"]!!.jsonPrimitive.content) + assertEquals("checkout.pay", clickPayload["clickSelector"]) + assertEquals("cart-1", clickPayload["screenId"]) + assertEquals("Cart", clickPayload["screenName"]) } @Test @@ -321,9 +317,9 @@ class RRWebEventGeneratorTest { val event = generator.generateClickEvent(payload) val custom = event.data as EventDataUnion.CustomEventDataWrapper - val clickPayload = custom.data.jsonObject["payload"]!!.jsonObject - assertEquals("InkWell", clickPayload["clickSelector"]!!.jsonPrimitive.content) - assertEquals("", clickPayload["clickTextContent"]!!.jsonPrimitive.content) + val clickPayload = custom.data["payload"] as Map<*, *> + assertEquals("InkWell", clickPayload["clickSelector"]) + assertEquals("", clickPayload["clickTextContent"]) // Screen fields are omitted rather than sent empty when the stack has no screen. assertFalse(clickPayload.containsKey("screenId")) assertFalse(clickPayload.containsKey("screenName"))