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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions dd-sdk-android-core/api/apiSurface
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,22 @@ interface com.datadog.android.api.feature.StorageBackedFeature : Feature
interface com.datadog.android.api.instrumentation.network.ExtendedRequestInfo
fun <T> tag(Class<T>): T?
fun <T> HttpRequestInfo.tag(Class<T>): T?
class com.datadog.android.api.instrumentation.network.HttpBodySnapshot
constructor(ByteArray, String? = null, Boolean = false)
fun string(): String
override fun equals(Any?): Boolean
override fun hashCode(): Int
override fun toString(): String
companion object
const val DEFAULT_MAX_BODY_BYTES: Long
interface com.datadog.android.api.instrumentation.network.HttpRequestBody
interface com.datadog.android.api.instrumentation.network.HttpRequestInfo
val url: String
val headers: Map<String, List<String>>
val contentType: String?
val method: String
fun contentLength(): Long?
fun peekBody(Long = HttpBodySnapshot.DEFAULT_MAX_BODY_BYTES): HttpBodySnapshot?
interface com.datadog.android.api.instrumentation.network.HttpRequestInfoBuilder
fun setUrl(String): HttpRequestInfoBuilder
fun addHeader(String, String): HttpRequestInfoBuilder
Expand All @@ -179,6 +188,7 @@ interface com.datadog.android.api.instrumentation.network.HttpResponseInfo
val contentType: String?
val contentLength: Long?
val request: HttpRequestInfo?
fun peekBody(Long = HttpBodySnapshot.DEFAULT_MAX_BODY_BYTES): HttpBodySnapshot?
interface com.datadog.android.api.instrumentation.network.MutableHttpRequestInfo
fun newBuilder(): HttpRequestInfoBuilder
data class com.datadog.android.api.net.Request
Expand Down
29 changes: 29 additions & 0 deletions dd-sdk-android-core/api/dd-sdk-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,23 @@ public final class com/datadog/android/api/instrumentation/network/ExtendedReque
public static final fun tag (Lcom/datadog/android/api/instrumentation/network/HttpRequestInfo;Ljava/lang/Class;)Ljava/lang/Object;
}

public final class com/datadog/android/api/instrumentation/network/HttpBodySnapshot {
public static final field Companion Lcom/datadog/android/api/instrumentation/network/HttpBodySnapshot$Companion;
public static final field DEFAULT_MAX_BODY_BYTES J
public fun <init> ([BLjava/lang/String;Z)V
public synthetic fun <init> ([BLjava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun equals (Ljava/lang/Object;)Z
public final fun getBytes ()[B
public final fun getContentType ()Ljava/lang/String;
public fun hashCode ()I
public final fun isTruncated ()Z
public final fun string ()Ljava/lang/String;
public fun toString ()Ljava/lang/String;
}

public final class com/datadog/android/api/instrumentation/network/HttpBodySnapshot$Companion {
}

public abstract interface class com/datadog/android/api/instrumentation/network/HttpRequestBody {
}

Expand All @@ -479,6 +496,12 @@ public abstract interface class com/datadog/android/api/instrumentation/network/
public abstract fun getHeaders ()Ljava/util/Map;
public abstract fun getMethod ()Ljava/lang/String;
public abstract fun getUrl ()Ljava/lang/String;
public abstract fun peekBody (J)Lcom/datadog/android/api/instrumentation/network/HttpBodySnapshot;
}

public final class com/datadog/android/api/instrumentation/network/HttpRequestInfo$DefaultImpls {
public static fun peekBody (Lcom/datadog/android/api/instrumentation/network/HttpRequestInfo;J)Lcom/datadog/android/api/instrumentation/network/HttpBodySnapshot;
public static synthetic fun peekBody$default (Lcom/datadog/android/api/instrumentation/network/HttpRequestInfo;JILjava/lang/Object;)Lcom/datadog/android/api/instrumentation/network/HttpBodySnapshot;
}

public abstract interface class com/datadog/android/api/instrumentation/network/HttpRequestInfoBuilder {
Expand All @@ -503,6 +526,12 @@ public abstract interface class com/datadog/android/api/instrumentation/network/
public abstract fun getRequest ()Lcom/datadog/android/api/instrumentation/network/HttpRequestInfo;
public abstract fun getStatusCode ()I
public abstract fun getUrl ()Ljava/lang/String;
public abstract fun peekBody (J)Lcom/datadog/android/api/instrumentation/network/HttpBodySnapshot;
}

public final class com/datadog/android/api/instrumentation/network/HttpResponseInfo$DefaultImpls {
public static fun peekBody (Lcom/datadog/android/api/instrumentation/network/HttpResponseInfo;J)Lcom/datadog/android/api/instrumentation/network/HttpBodySnapshot;
public static synthetic fun peekBody$default (Lcom/datadog/android/api/instrumentation/network/HttpResponseInfo;JILjava/lang/Object;)Lcom/datadog/android/api/instrumentation/network/HttpBodySnapshot;
}

public abstract interface class com/datadog/android/api/instrumentation/network/MutableHttpRequestInfo {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/
package com.datadog.android.api.instrumentation.network

import java.nio.charset.Charset
import java.util.Locale

/**
* An immutable, size-capped copy of an HTTP request or response payload.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if it's immutable - maybe better to declare as data class

*
* Instances are produced by [HttpRequestInfo.peekBody] and [HttpResponseInfo.peekBody]. Taking a
* snapshot never consumes the underlying payload: the request or response stays fully readable by
* the application.
*
* @property bytes the captured payload, holding at most the number of bytes requested from
* [HttpRequestInfo.peekBody] / [HttpResponseInfo.peekBody]. This array is not defensively copied,
* so it must be treated as read-only.
* @property contentType the MIME type declared for the payload, including any `charset` parameter,
* or null when the payload declared none.
* @property isTruncated true when the payload was larger than the requested maximum and [bytes]
* therefore only holds its beginning.
*/
class HttpBodySnapshot(
val bytes: ByteArray,
val contentType: String? = null,
val isTruncated: Boolean = false
) {

/**
* Decodes [bytes] into a [String], using the charset declared in [contentType] and falling back
* to UTF-8 when it declares none or declares one this device doesn't support.
*
* Note that when [isTruncated] is true the payload was cut at a byte boundary, so the last
* character of the result may be garbled for multi-byte encodings.
*
* @return the decoded payload.
*/
fun string(): String = String(bytes, resolveCharset(contentType))

/** @inheritDoc */
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is HttpBodySnapshot) return false

return bytes.contentEquals(other.bytes) &&
contentType == other.contentType &&
isTruncated == other.isTruncated
}

/** @inheritDoc */
override fun hashCode(): Int {
var result = bytes.contentHashCode()
result = HASH_MULTIPLIER * result + (contentType?.hashCode() ?: 0)
result = HASH_MULTIPLIER * result + isTruncated.hashCode()
return result
}

// The payload itself is deliberately left out: it may be large and may hold sensitive data.
/** @inheritDoc */
override fun toString(): String =
"HttpBodySnapshot(size=${bytes.size}, contentType=$contentType, isTruncated=$isTruncated)"

companion object {

/**
* The number of bytes [HttpRequestInfo.peekBody] and [HttpResponseInfo.peekBody] capture
* when no explicit maximum is given: 512 KB.
*
* A snapshot is held in memory on top of the payload the networking library already holds,
* so this cap keeps the duplication bounded for large downloads and uploads.
*/
const val DEFAULT_MAX_BODY_BYTES: Long = 512L * 1024L

private const val HASH_MULTIPLIER = 31
private const val CHARSET_PARAMETER = "charset"

private fun resolveCharset(contentType: String?): Charset {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am bit afraid this method might be silently complex from the memory perspective because of transformations.

For instance - firstOrNull { it.lowercase(Locale.US) } - will create a String object for each element of array.
?.map { it.trim() } - as well
and so on.

so in case if this method would be called multiple times in a short period - it could create a pressure to the GC.

I am not sure that you could completley avoid that, but manual iteration over the string, might be cheaper. Good test coverage would be required as well for all supported HTTP header format

@abrooksv abrooksv Aug 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startsWith(.., ignoreCase = true) could help here, it delegates down to String.regionMatches, which does iteration without us blowing up the method complexity (still pay the trim cost though)

Then you can defer the lowercasing cost only when a match is found

"$CHARSET_PARAMETER=" should probably also be a const so you don't pay the cost of building the search string sequence iteration

val charsetName = contentType
?.split(';')
?.asSequence()
?.map { it.trim() }
?.firstOrNull { it.lowercase(Locale.US).startsWith("$CHARSET_PARAMETER=") }
?.substringAfter('=')
?.trim('"', ' ')
?: return Charsets.UTF_8

return try {
@Suppress("UnsafeThirdPartyFunctionCall") // IllegalArgumentException is caught
Charset.forName(charsetName)
} catch (@Suppress("SwallowedException") _: IllegalArgumentException) {
// covers both IllegalCharsetNameException and UnsupportedCharsetException
Charsets.UTF_8
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
*/
package com.datadog.android.api.instrumentation.network

import androidx.annotation.WorkerThread

/**
* Represents information about an HTTP request.
*/
Expand Down Expand Up @@ -36,4 +38,20 @@ interface HttpRequestInfo {
* @return the length of the content in bytes, or null if the content length is unavailable.
*/
fun contentLength(): Long?

/**
* Reads the beginning of the request payload without consuming it, so that the request stays
* intact and can still be sent.
*
* The payload is copied into memory on top of what the networking library already holds, and
* producing it may require real work (reading a file, running a serializer), so only call this
* when the payload is actually needed. Nothing is read until this method is called.
*
* @param maxBytes the maximum number of bytes to capture. Anything beyond that is dropped and
* the returned snapshot is flagged as [HttpBodySnapshot.isTruncated].
* @return a snapshot of the payload, or null when the request has no payload, when the payload
* can only be transmitted once (a one-shot or duplex body), or when it couldn't be read.
*/
@WorkerThread
fun peekBody(maxBytes: Long = HttpBodySnapshot.DEFAULT_MAX_BODY_BYTES): HttpBodySnapshot? = null
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,21 @@ interface HttpResponseInfo {
* Represents the HTTP request associated with the response, or null when not available.
*/
val request: HttpRequestInfo?

/**
* Reads the beginning of the response payload without consuming it, so that the payload stays
* fully readable by the application.
*
* The payload is copied into memory on top of what the networking library already holds, and
* reading it may block until enough of it has arrived, so only call this when the payload is
* actually needed. Nothing is read until this method is called.
*
* @param maxBytes the maximum number of bytes to capture. Anything beyond that is dropped and
* the returned snapshot is flagged as [HttpBodySnapshot.isTruncated].
* @return a snapshot of the payload, or null when the response has no payload, when the
* payload is a stream that would never complete on its own (Server-Sent Events, gRPC,
* WebSocket), or when it couldn't be read.
*/
@WorkerThread
fun peekBody(maxBytes: Long = HttpBodySnapshot.DEFAULT_MAX_BODY_BYTES): HttpBodySnapshot? = null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

package com.datadog.android.api.instrumentation.network

import com.datadog.android.utils.forge.Configurator
import fr.xgouchet.elmyr.annotation.StringForgery
import fr.xgouchet.elmyr.junit5.ForgeConfiguration
import fr.xgouchet.elmyr.junit5.ForgeExtension
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.extension.Extensions
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.junit.jupiter.MockitoSettings
import org.mockito.quality.Strictness

@Extensions(
ExtendWith(ForgeExtension::class)
)
@MockitoSettings(strictness = Strictness.LENIENT)
@ForgeConfiguration(Configurator::class)
internal class HttpBodySnapshotTest {

@Test
fun `M decode as UTF-8 W string() {no content type}`(@StringForgery fakePayload: String) {
// Given
val testedSnapshot = HttpBodySnapshot(fakePayload.toByteArray(Charsets.UTF_8))

// When
val result = testedSnapshot.string()

// Then
assertThat(result).isEqualTo(fakePayload)
}

@Test
fun `M decode as UTF-8 W string() {content type without charset}`(@StringForgery fakePayload: String) {
// Given
val testedSnapshot = HttpBodySnapshot(
bytes = fakePayload.toByteArray(Charsets.UTF_8),
contentType = "application/json"
)

// When
val result = testedSnapshot.string()

// Then
assertThat(result).isEqualTo(fakePayload)
}

@ParameterizedTest
@ValueSource(
strings = [
"text/plain; charset=ISO-8859-1",
"text/plain;charset=ISO-8859-1",
"text/plain; charset=iso-8859-1",
"text/plain; CHARSET=ISO-8859-1",
"text/plain; charset=\"ISO-8859-1\"",
"text/plain; boundary=xyz; charset=ISO-8859-1"
]
)
fun `M decode with declared charset W string() {charset parameter}`(fakeContentType: String) {
// Given
val fakePayload = "café naïve"
val testedSnapshot = HttpBodySnapshot(
bytes = fakePayload.toByteArray(Charsets.ISO_8859_1),
contentType = fakeContentType
)

// When
val result = testedSnapshot.string()

// Then
assertThat(result).isEqualTo(fakePayload)
}

@ParameterizedTest
@ValueSource(
strings = [
"text/plain; charset=not-a-real-charset",
"text/plain; charset=<>!",
"text/plain; charset="
]
)
fun `M fall back to UTF-8 W string() {unusable charset}`(fakeContentType: String) {
// Given
val fakePayload = "café naïve"
val testedSnapshot = HttpBodySnapshot(
bytes = fakePayload.toByteArray(Charsets.UTF_8),
contentType = fakeContentType
)

// When
val result = testedSnapshot.string()

// Then
assertThat(result).isEqualTo(fakePayload)
}

@Test
fun `M not be truncated by default W constructor()`(@StringForgery fakePayload: String) {
// When
val testedSnapshot = HttpBodySnapshot(fakePayload.toByteArray())

// Then
assertThat(testedSnapshot.isTruncated).isFalse()
assertThat(testedSnapshot.contentType).isNull()
}

@Test
fun `M compare payload content W equals() {same content}`(
@StringForgery fakePayload: String,
@StringForgery fakeContentType: String
) {
// Given
val testedSnapshot = HttpBodySnapshot(fakePayload.toByteArray(), fakeContentType, true)
val other = HttpBodySnapshot(fakePayload.toByteArray(), fakeContentType, true)

// Then
assertThat(testedSnapshot).isEqualTo(other)
assertThat(testedSnapshot.hashCode()).isEqualTo(other.hashCode())
}

@Test
fun `M differ W equals() {different payload}`(
@StringForgery fakePayload: String,
@StringForgery fakeOtherPayload: String
) {
// Given
val testedSnapshot = HttpBodySnapshot(fakePayload.toByteArray())
val other = HttpBodySnapshot((fakePayload + fakeOtherPayload).toByteArray())

// Then
assertThat(testedSnapshot).isNotEqualTo(other)
}

@Test
fun `M differ W equals() {different truncation flag}`(@StringForgery fakePayload: String) {
// Given
val testedSnapshot = HttpBodySnapshot(fakePayload.toByteArray(), isTruncated = true)
val other = HttpBodySnapshot(fakePayload.toByteArray(), isTruncated = false)

// Then
assertThat(testedSnapshot).isNotEqualTo(other)
}

@Test
fun `M not leak the payload W toString()`(@StringForgery(size = 32) fakePayload: String) {
// Given
val testedSnapshot = HttpBodySnapshot(fakePayload.toByteArray(), "application/json", true)

// When
val result = testedSnapshot.toString()

// Then
assertThat(result).doesNotContain(fakePayload)
assertThat(result).contains("size=${fakePayload.toByteArray().size}")
assertThat(result).contains("contentType=application/json")
assertThat(result).contains("isTruncated=true")
}
}
Loading
Loading