From c4a7806d11872a2999440214db730429fbadb948 Mon Sep 17 00:00:00 2001 From: Nikita Ogorodnikov Date: Mon, 13 Nov 2023 11:00:22 +0100 Subject: [PATCH] RUM-1924: Use tracestate header to supply vendor-specific information --- .../opentracing/propagation/W3CHttpCodec.java | 77 ++++++++++- .../okhttp/trace/TracingInterceptor.kt | 28 +++- ...nterceptorNonDdTracerNotSendingSpanTest.kt | 53 +++++--- .../TracingInterceptorNonDdTracerTest.kt | 53 +++++--- .../TracingInterceptorNotSendingSpanTest.kt | 71 ++++++---- .../okhttp/trace/TracingInterceptorTest.kt | 87 +++++++----- .../okhttp/utils/assertj/HeadersAssert.kt | 124 ++++++++++++++++++ 7 files changed, 381 insertions(+), 112 deletions(-) create mode 100644 integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/utils/assertj/HeadersAssert.kt diff --git a/features/dd-sdk-android-trace/src/main/java/com/datadog/opentracing/propagation/W3CHttpCodec.java b/features/dd-sdk-android-trace/src/main/java/com/datadog/opentracing/propagation/W3CHttpCodec.java index 6bb229c80e..3ff895c3f5 100644 --- a/features/dd-sdk-android-trace/src/main/java/com/datadog/opentracing/propagation/W3CHttpCodec.java +++ b/features/dd-sdk-android-trace/src/main/java/com/datadog/opentracing/propagation/W3CHttpCodec.java @@ -20,6 +20,7 @@ import io.opentracing.SpanContext; import io.opentracing.propagation.TextMapExtract; import io.opentracing.propagation.TextMapInject; +import kotlin.text.StringsKt; /** * A codec designed for HTTP transport via headers using W3C traceparent header @@ -31,11 +32,21 @@ class W3CHttpCodec { private static final String TRACEPARENT_KEY = "traceparent"; - private static final String TRACEPARENT_VALUE = "00-0000000000000000%s-%s-0%s"; + private static final String TRACESTATE_KEY = "tracestate"; + + private static final String TRACEPARENT_VALUE = "00-%s-%s-0%s"; + + private static final int TRACECONTEXT_PARENT_ID_LENGTH = 16; + private static final int TRACECONTEXT_TRACE_ID_LENGTH = 32; private static final String SAMPLING_PRIORITY_ACCEPT = String.valueOf(1); private static final String SAMPLING_PRIORITY_DROP = String.valueOf(0); private static final int HEX_RADIX = 16; + private static final String ORIGIN_TRACESTATE_TAG_VALUE = "o"; + private static final String SAMPLING_PRIORITY_TRACESTATE_TAG_VALUE = "s"; + private static final String PARENT_SPAN_ID_TRACESTATE_TAG_VALUE = "p"; + private static final String DATADOG_VENDOR_TRACESTATE_PREFIX = "dd="; + private W3CHttpCodec() { // This class should not be created. This also makes code coverage checks happy. } @@ -47,11 +58,15 @@ public void inject(final DDSpanContext context, final TextMapInject carrier) { try { String traceId = context.getTraceId().toString(HEX_RADIX).toLowerCase(Locale.US); String spanId = context.getSpanId().toString(HEX_RADIX).toLowerCase(Locale.US); + String samplingPriority = convertSamplingPriority(context.getSamplingPriority()); + String origin = context.getOrigin(); carrier.put(TRACEPARENT_KEY, String.format(TRACEPARENT_VALUE, - traceId, - spanId, - convertSamplingPriority(context.getSamplingPriority()))); + StringsKt.padStart(traceId, TRACECONTEXT_TRACE_ID_LENGTH, '0'), + StringsKt.padStart(spanId, TRACECONTEXT_PARENT_ID_LENGTH, '0'), + samplingPriority)); + // TODO RUM-2121 3rd party vendor information will be erased + carrier.put(TRACESTATE_KEY, createTraceStateHeader(samplingPriority, origin, spanId)); } catch (final NumberFormatException e) { } @@ -60,6 +75,30 @@ public void inject(final DDSpanContext context, final TextMapInject carrier) { private String convertSamplingPriority(final int samplingPriority) { return samplingPriority > 0 ? SAMPLING_PRIORITY_ACCEPT : SAMPLING_PRIORITY_DROP; } + + private String createTraceStateHeader( + String samplingPriority, + String origin, + String parentSpanId + ) { + StringBuilder sb = new StringBuilder(DATADOG_VENDOR_TRACESTATE_PREFIX) + .append(SAMPLING_PRIORITY_TRACESTATE_TAG_VALUE) + .append(":") + .append(samplingPriority) + .append(";") + .append(PARENT_SPAN_ID_TRACESTATE_TAG_VALUE) + .append(":") + .append(parentSpanId); + + if (origin != null) { + sb.append(";") + .append(ORIGIN_TRACESTATE_TAG_VALUE) + .append(":") + .append(origin.toLowerCase(Locale.US)); + } + + return sb.toString(); + } } public static class Extractor implements HttpCodec.Extractor { @@ -80,6 +119,7 @@ public SpanContext extract(final TextMapExtract carrier) { BigInteger traceId = BigInteger.ZERO; BigInteger spanId = BigInteger.ZERO; int samplingPriority = PrioritySampling.UNSET; + String origin = null; for (final Map.Entry entry : carrier) { final String key = entry.getKey().toLowerCase(Locale.US); @@ -90,13 +130,15 @@ public SpanContext extract(final TextMapExtract carrier) { } if (TRACEPARENT_KEY.equalsIgnoreCase(key)) { + // version - traceId - parentId - traceFlags String[] valueParts = value.split("-"); if (valueParts.length != 4){ continue; } - if (valueParts[0] == "ff"){ + if ("ff".equalsIgnoreCase(valueParts[0])){ + // ff version is forbidden continue; } @@ -116,6 +158,9 @@ public SpanContext extract(final TextMapExtract carrier) { samplingPriority = convertSamplingPriority(valueParts[3]); + } else if (TRACESTATE_KEY.equalsIgnoreCase(key)) { + Map datadogTraceStateTags = extractDatadogTagsFromTraceState(value); + origin = datadogTraceStateTags.get(ORIGIN_TRACESTATE_TAG_VALUE); } if (taggedHeaders.containsKey(key)) { @@ -132,14 +177,14 @@ public SpanContext extract(final TextMapExtract carrier) { traceId, spanId, samplingPriority, - null, + origin, Collections.emptyMap(), tags); context.lockSamplingPriority(); return context; } else if (!tags.isEmpty()) { - return new TagContext(null, tags); + return new TagContext(origin, tags); } } catch (final RuntimeException e) { } @@ -152,5 +197,23 @@ private int convertSamplingPriority(final String samplingPriority) { ? PrioritySampling.SAMPLER_KEEP : PrioritySampling.SAMPLER_DROP; } + + private Map extractDatadogTagsFromTraceState(String traceState) { + String[] vendors = traceState.split(","); + Map tags = new HashMap<>(); + for (String vendor : vendors) { + if (vendor.startsWith(DATADOG_VENDOR_TRACESTATE_PREFIX)) { + String[] vendorTags = vendor.substring(DATADOG_VENDOR_TRACESTATE_PREFIX.length()) + .split(";"); + for (String vendorTag : vendorTags) { + String[] keyAndValue = vendorTag.split(":"); + if (keyAndValue.length == 2) { + tags.put(keyAndValue[0], keyAndValue[1]); + } + } + } + } + return tags; + } } } diff --git a/integrations/dd-sdk-android-okhttp/src/main/kotlin/com/datadog/android/okhttp/trace/TracingInterceptor.kt b/integrations/dd-sdk-android-okhttp/src/main/kotlin/com/datadog/android/okhttp/trace/TracingInterceptor.kt index f1c3a0031d..d5403ce0dc 100644 --- a/integrations/dd-sdk-android-okhttp/src/main/kotlin/com/datadog/android/okhttp/trace/TracingInterceptor.kt +++ b/integrations/dd-sdk-android-okhttp/src/main/kotlin/com/datadog/android/okhttp/trace/TracingInterceptor.kt @@ -480,14 +480,25 @@ internal constructor( TracingHeaderType.TRACECONTEXT -> { requestBuilder.removeHeader(W3C_TRACEPARENT_KEY) + requestBuilder.removeHeader(W3C_TRACESTATE_KEY) + val traceId = span.context().toTraceId() + val spanId = span.context().toSpanId() requestBuilder.addHeader( W3C_TRACEPARENT_KEY, @Suppress("UnsafeThirdPartyFunctionCall") // Format string is static - W3C_DROP_SAMPLING_DECISION.format( - span.context().toTraceId(), - span.context().toSpanId() + W3C_TRACEPARENT_DROP_SAMPLING_DECISION.format( + traceId.padStart(length = W3C_TRACE_ID_LENGTH, padChar = '0'), + spanId.padStart(length = W3C_PARENT_ID_LENGTH, padChar = '0') ) ) + // TODO RUM-2121 3rd party vendor information will be erased + @Suppress("UnsafeThirdPartyFunctionCall") // Format string is static + var traceStateHeader = W3C_TRACESTATE_DROP_SAMPLING_DECISION + .format(spanId.padStart(length = W3C_PARENT_ID_LENGTH, padChar = '0')) + if (traceOrigin != null) { + traceStateHeader += ";o:$traceOrigin" + } + requestBuilder.addHeader(W3C_TRACESTATE_KEY, traceStateHeader) } } } @@ -538,7 +549,8 @@ internal constructor( tracedRequestBuilder.addHeader(key, value) } - W3C_TRACEPARENT_KEY -> if (tracingHeaderTypes.contains(TracingHeaderType.TRACECONTEXT)) { + W3C_TRACEPARENT_KEY, + W3C_TRACESTATE_KEY -> if (tracingHeaderTypes.contains(TracingHeaderType.TRACECONTEXT)) { tracedRequestBuilder.addHeader(key, value) } @@ -649,7 +661,13 @@ internal constructor( // taken from W3CHttpCodec internal const val W3C_TRACEPARENT_KEY = "traceparent" - internal const val W3C_DROP_SAMPLING_DECISION = "00-%s-%s-00" + internal const val W3C_TRACESTATE_KEY = "tracestate" + + // https://www.w3.org/TR/trace-context/#traceparent-header + internal const val W3C_TRACEPARENT_DROP_SAMPLING_DECISION = "00-%s-%s-00" + internal const val W3C_TRACESTATE_DROP_SAMPLING_DECISION = "dd=p:%s;s:0" internal const val W3C_SAMPLING_DECISION_INDEX = 3 + internal const val W3C_TRACE_ID_LENGTH = 32 + internal const val W3C_PARENT_ID_LENGTH = 16 } } diff --git a/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorNonDdTracerNotSendingSpanTest.kt b/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorNonDdTracerNotSendingSpanTest.kt index 5652422289..c842f582e1 100644 --- a/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorNonDdTracerNotSendingSpanTest.kt +++ b/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorNonDdTracerNotSendingSpanTest.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.feature.Feature import com.datadog.android.core.internal.net.DefaultFirstPartyHostHeaderTypeResolver import com.datadog.android.core.internal.utils.loggableStackTrace import com.datadog.android.core.sampling.Sampler +import com.datadog.android.okhttp.utils.assertj.HeadersAssert.Companion.assertThat import com.datadog.android.okhttp.utils.config.DatadogSingletonTestConfiguration import com.datadog.android.okhttp.utils.verifyLog import com.datadog.android.trace.TracingHeaderType @@ -148,8 +149,7 @@ internal open class TracingInterceptorNonDdTracerNotSendingSpanTest { @StringForgery(type = StringForgeryType.HEXADECIMAL) lateinit var fakeTraceId: String - @StringForgery - lateinit var fakeOrigin: String + private var fakeOrigin: String? = null lateinit var fakeLocalHosts: Map> @@ -165,6 +165,7 @@ internal open class TracingInterceptorNonDdTracerNotSendingSpanTest { whenever(mockSpanContext.toTraceId()) doReturn fakeTraceId whenever(mockTraceSampler.sample()) doReturn true + fakeOrigin = forge.aNullable { anAlphabeticalString() } fakeMediaType = if (forge.aBool()) { val mediaType = forge.anElementFrom("application", "image", "text", "model") + "/" + forge.anAlphabeticalString() @@ -338,12 +339,16 @@ internal open class TracingInterceptorNonDdTracerNotSendingSpanTest { assertThat(response).isSameAs(fakeResponse) argumentCaptor { verify(mockChain).proceed(capture()) - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + fakeOrigin ) } } @@ -486,12 +491,16 @@ internal open class TracingInterceptorNonDdTracerNotSendingSpanTest { assertThat(response).isSameAs(fakeResponse) argumentCaptor { verify(mockChain).proceed(capture()) - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + fakeOrigin ) } } @@ -831,12 +840,16 @@ internal open class TracingInterceptorNonDdTracerNotSendingSpanTest { assertThat(response).isSameAs(fakeResponse) argumentCaptor { verify(mockChain).proceed(capture()) - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + fakeOrigin ) } } diff --git a/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorNonDdTracerTest.kt b/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorNonDdTracerTest.kt index 2b3a62234e..870439ab33 100644 --- a/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorNonDdTracerTest.kt +++ b/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorNonDdTracerTest.kt @@ -13,6 +13,7 @@ import com.datadog.android.core.internal.net.DefaultFirstPartyHostHeaderTypeReso import com.datadog.android.core.internal.utils.loggableStackTrace import com.datadog.android.core.sampling.RateBasedSampler import com.datadog.android.core.sampling.Sampler +import com.datadog.android.okhttp.utils.assertj.HeadersAssert.Companion.assertThat import com.datadog.android.okhttp.utils.config.DatadogSingletonTestConfiguration import com.datadog.android.okhttp.utils.verifyLog import com.datadog.android.trace.TracingHeaderType @@ -143,8 +144,7 @@ internal open class TracingInterceptorNonDdTracerTest { @StringForgery(type = StringForgeryType.HEXADECIMAL) lateinit var fakeTraceId: String - @StringForgery - lateinit var fakeOrigin: String + private var fakeOrigin: String? = null lateinit var fakeLocalHosts: Map> @@ -161,6 +161,7 @@ internal open class TracingInterceptorNonDdTracerTest { whenever(mockSpanContext.toTraceId()) doReturn fakeTraceId whenever(mockTraceSampler.sample()) doReturn true + fakeOrigin = forge.aNullable { anAlphabeticalString() } val mediaType = forge.anElementFrom("application", "image", "text", "model") + "/" + forge.anAlphabeticalString() fakeLocalHosts = forge.aMap { @@ -367,12 +368,16 @@ internal open class TracingInterceptorNonDdTracerTest { assertThat(response).isSameAs(fakeResponse) argumentCaptor { verify(mockChain).proceed(capture()) - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + fakeOrigin ) } } @@ -515,12 +520,16 @@ internal open class TracingInterceptorNonDdTracerTest { assertThat(response).isSameAs(fakeResponse) argumentCaptor { verify(mockChain).proceed(capture()) - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + fakeOrigin ) } } @@ -948,12 +957,16 @@ internal open class TracingInterceptorNonDdTracerTest { assertThat(response).isSameAs(fakeResponse) argumentCaptor { verify(mockChain).proceed(capture()) - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + fakeOrigin ) } } diff --git a/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorNotSendingSpanTest.kt b/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorNotSendingSpanTest.kt index 946f4f3a24..3d6dbf3dbc 100644 --- a/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorNotSendingSpanTest.kt +++ b/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorNotSendingSpanTest.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.feature.Feature import com.datadog.android.core.internal.net.DefaultFirstPartyHostHeaderTypeResolver import com.datadog.android.core.internal.utils.loggableStackTrace import com.datadog.android.core.sampling.Sampler +import com.datadog.android.okhttp.utils.assertj.HeadersAssert.Companion.assertThat import com.datadog.android.okhttp.utils.config.DatadogSingletonTestConfiguration import com.datadog.android.okhttp.utils.config.GlobalRumMonitorTestConfiguration import com.datadog.android.okhttp.utils.verifyLog @@ -147,8 +148,7 @@ internal open class TracingInterceptorNotSendingSpanTest { @StringForgery(type = StringForgeryType.HEXADECIMAL) lateinit var fakeTraceId: String - @StringForgery - lateinit var fakeOrigin: String + private var fakeOrigin: String? = null lateinit var fakeLocalHosts: Map> @@ -164,6 +164,7 @@ internal open class TracingInterceptorNotSendingSpanTest { whenever(mockSpanContext.toTraceId()) doReturn fakeTraceId whenever(mockTraceSampler.sample()) doReturn true + fakeOrigin = forge.aNullable { anAlphabeticalString() } fakeMediaType = if (forge.aBool()) { val mediaType = forge.anElementFrom("application", "image", "text", "model") + "/" + forge.anAlphabeticalString() @@ -212,7 +213,7 @@ internal open class TracingInterceptorNotSendingSpanTest { } } - open fun getExpectedOrigin(): String { + open fun getExpectedOrigin(): String? { return fakeOrigin } @@ -354,12 +355,16 @@ internal open class TracingInterceptorNotSendingSpanTest { assertThat(response).isSameAs(fakeResponse) argumentCaptor { verify(mockChain).proceed(capture()) - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + getExpectedOrigin() ) } } @@ -399,12 +404,16 @@ internal open class TracingInterceptorNotSendingSpanTest { assertThat(lastValue.header(TracingInterceptor.B3M_TRACE_ID_KEY)).isNull() assertThat(lastValue.header(TracingInterceptor.B3_HEADER_KEY)) .isEqualTo("0") - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + getExpectedOrigin() ) } } @@ -547,12 +556,16 @@ internal open class TracingInterceptorNotSendingSpanTest { assertThat(response).isSameAs(fakeResponse) argumentCaptor { verify(mockChain).proceed(capture()) - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + getExpectedOrigin() ) } } @@ -890,12 +903,16 @@ internal open class TracingInterceptorNotSendingSpanTest { assertThat(response).isSameAs(fakeResponse) argumentCaptor { verify(mockChain).proceed(capture()) - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + getExpectedOrigin() ) } } diff --git a/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorTest.kt b/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorTest.kt index 4a2a3f4270..e4383085fe 100644 --- a/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorTest.kt +++ b/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/trace/TracingInterceptorTest.kt @@ -13,6 +13,7 @@ import com.datadog.android.core.internal.net.DefaultFirstPartyHostHeaderTypeReso import com.datadog.android.core.internal.utils.loggableStackTrace import com.datadog.android.core.sampling.RateBasedSampler import com.datadog.android.core.sampling.Sampler +import com.datadog.android.okhttp.utils.assertj.HeadersAssert.Companion.assertThat import com.datadog.android.okhttp.utils.config.DatadogSingletonTestConfiguration import com.datadog.android.okhttp.utils.config.GlobalRumMonitorTestConfiguration import com.datadog.android.okhttp.utils.verifyLog @@ -143,8 +144,7 @@ internal open class TracingInterceptorTest { @StringForgery(type = StringForgeryType.HEXADECIMAL) lateinit var fakeTraceId: String - @StringForgery - lateinit var fakeOrigin: String + private var fakeOrigin: String? = null lateinit var fakeLocalHosts: Map> @@ -162,6 +162,7 @@ internal open class TracingInterceptorTest { whenever(mockSpanContext.toTraceId()) doReturn fakeTraceId whenever(mockTraceSampler.sample()) doReturn true + fakeOrigin = forge.aNullable { anAlphabeticalString() } val mediaType = forge.anElementFrom("application", "image", "text", "model") + "/" + forge.anAlphabeticalString() fakeLocalHosts = @@ -193,7 +194,7 @@ internal open class TracingInterceptorTest { ) } - open fun getExpectedOrigin(): String { + open fun getExpectedOrigin(): String? { return fakeOrigin } @@ -384,12 +385,16 @@ internal open class TracingInterceptorTest { assertThat(response).isSameAs(fakeResponse) argumentCaptor { verify(mockChain).proceed(capture()) - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + getExpectedOrigin() ) } } @@ -429,12 +434,16 @@ internal open class TracingInterceptorTest { assertThat(lastValue.header(TracingInterceptor.B3M_TRACE_ID_KEY)).isNull() assertThat(lastValue.header(TracingInterceptor.B3_HEADER_KEY)) .isEqualTo("0") - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + getExpectedOrigin() ) } } @@ -572,12 +581,16 @@ internal open class TracingInterceptorTest { assertThat(response).isSameAs(fakeResponse) argumentCaptor { verify(mockChain).proceed(capture()) - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + getExpectedOrigin() ) } } @@ -623,12 +636,16 @@ internal open class TracingInterceptorTest { assertThat(lastValue.header(TracingInterceptor.B3M_TRACE_ID_KEY)).isNull() assertThat(lastValue.header(TracingInterceptor.B3_HEADER_KEY)) .isEqualTo("0") - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + getExpectedOrigin() ) } } @@ -1036,12 +1053,16 @@ internal open class TracingInterceptorTest { assertThat(response).isSameAs(fakeResponse) argumentCaptor { verify(mockChain).proceed(capture()) - assertThat(lastValue.header(TracingInterceptor.W3C_TRACEPARENT_KEY)) - .isEqualTo( - "00-%s-%s-00".format( - mockSpan.context().toTraceId(), - mockSpan.context().toSpanId() - ) + assertThat(lastValue.headers) + .hasTraceParentHeader( + mockSpan.context().toTraceId(), + mockSpan.context().toSpanId(), + isSampled = false + ) + .hasTraceStateHeaderWithOnlyDatadogVendorValues( + mockSpan.context().toSpanId(), + isSampled = false, + getExpectedOrigin() ) } } diff --git a/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/utils/assertj/HeadersAssert.kt b/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/utils/assertj/HeadersAssert.kt new file mode 100644 index 0000000000..c1ac811dd4 --- /dev/null +++ b/integrations/dd-sdk-android-okhttp/src/test/kotlin/com/datadog/android/okhttp/utils/assertj/HeadersAssert.kt @@ -0,0 +1,124 @@ +/* + * 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.okhttp.utils.assertj + +import okhttp3.Headers +import org.assertj.core.api.AbstractAssert +import org.assertj.core.api.Assertions.assertThat + +internal class HeadersAssert(actual: Headers) : + AbstractAssert(actual, HeadersAssert::class.java) { + + fun hasTraceParentHeader(traceId: String, spanId: String, isSampled: Boolean): HeadersAssert { + val expected = createTraceParentHeader(traceId, spanId, isSampled) + return hasHeader(TRACEPARENT_HEADER_NAME, expected) + } + + fun hasHeader(name: String, expectedValue: String): HeadersAssert { + val actualValue = actual[name] + if (actualValue == null) { + failWithMessage( + "We were expecting to have header [$name] but it was missing." + ) + } else { + assertThat(actualValue) + .overridingErrorMessage( + "We were expecting to have value [$expectedValue] for" + + " header [$name], but it was [$actualValue]." + ) + .isEqualTo(expectedValue) + } + return this + } + + fun hasTraceStateHeaderWithOnlyDatadogVendorValues( + spanId: String, + isSampled: Boolean, + origin: String? = null + ): HeadersAssert { + val headerValue = actual[TRACESTATE_HEADER_NAME] + if (headerValue == null) { + failWithMessage( + "We were expecting to have header [$TRACESTATE_HEADER_NAME] but it was missing." + ) + } else { + val vendorTags = headerValue.split(",") + assertThat(vendorTags) + .overridingErrorMessage( + "We were expecting to have only one vendor for" + + " [$TRACESTATE_HEADER_NAME] header, but the actual header value is [$headerValue]" + ) + .hasSize(1) + val vendor = vendorTags[0] + assertThat(vendor) + .overridingErrorMessage( + "We were expecting to have Datadog vendor for" + + " [$TRACESTATE_HEADER_NAME] header, but the actual header value is [$headerValue]" + ) + .startsWith("dd=") + + val rawActualTags = vendor.substringAfter("dd=") + .split(";") + .map { it.split(":").let { it[0] to it[1] } } + .groupBy { it.first } + .mapValues { it.value.map { it.second } } + + rawActualTags.forEach { + assertThat(it.value) + .overridingErrorMessage( + "We were expecting to not have duplicated or empty tags for" + + " Datadog vendor of [$TRACESTATE_HEADER_NAME] header, but" + + " the actual tags were $vendor" + ) + .hasSize(1) + } + + val expectedTags = mutableMapOf( + "s" to if (isSampled) "1" else "0", + "p" to spanId.padStart(length = 16, padChar = '0') + ).apply { + if (origin != null) put("o", origin) + } + + val actualTags = rawActualTags.mapValues { it.value.first() } + + assertThat(actualTags) + .overridingErrorMessage( + "We were expecting to have the following tags for" + + " Datadog vendor of [$TRACESTATE_HEADER_NAME] header [$expectedTags], but" + + " the actual tags were [$actualTags]" + ) + .isEqualTo(expectedTags) + } + return this + } + + private fun createTraceParentHeader( + traceId: String, + spanId: String, + isSampled: Boolean + ): String { + // https://www.w3.org/TR/trace-context/#traceparent-header + val paddedTraceId = traceId.padStart(length = 32, padChar = '0') + val paddedSpanId = spanId.padStart(length = 16, padChar = '0') + val flags = if (isSampled) "01" else "00" + return "00-$paddedTraceId-$paddedSpanId-$flags" + } + + companion object { + + private const val TRACEPARENT_HEADER_NAME = "traceparent" + private const val TRACESTATE_HEADER_NAME = "tracestate" + + /** + * Create assertion for [Headers]. + * @param actual the actual element to assert on + * @return the created assertion object. + */ + fun assertThat(actual: Headers): HeadersAssert = HeadersAssert(actual) + } +}