diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySpanProcessorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySpanProcessorTest.kt index 3cdb487f29..4c3df4b72b 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySpanProcessorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySpanProcessorTest.kt @@ -1,18 +1,31 @@ package io.sentry.opentelemetry import io.opentelemetry.api.OpenTelemetry +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanBuilder +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.SpanId import io.opentelemetry.api.trace.SpanKind +import io.opentelemetry.api.trace.StatusCode +import io.opentelemetry.api.trace.TraceId import io.opentelemetry.api.trace.Tracer import io.opentelemetry.context.Context import io.opentelemetry.context.propagation.ContextPropagators +import io.opentelemetry.context.propagation.TextMapGetter +import io.opentelemetry.context.propagation.TextMapSetter import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.trace.ReadWriteSpan +import io.opentelemetry.sdk.trace.ReadableSpan import io.opentelemetry.sdk.trace.SdkTracerProvider import io.opentelemetry.semconv.trace.attributes.SemanticAttributes +import io.sentry.Baggage +import io.sentry.BaggageHeader import io.sentry.IHub import io.sentry.ISpan import io.sentry.ITransaction import io.sentry.Instrumenter import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader import io.sentry.SpanStatus import io.sentry.TransactionContext import io.sentry.TransactionOptions @@ -24,48 +37,64 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever +import java.net.http.HttpHeaders import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue class SentrySpanProcessorTest { - private class Fixture { + companion object { + val SENTRY_TRACE_HEADER_STRING = "2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1" + val BAGGAGE_HEADER_STRING = "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=2722d9f6ec019ade60c776169d9a8904,sentry-transaction=HTTP%20GET" + } - lateinit var options: SentryOptions - lateinit var hub: IHub - lateinit var transaction: ITransaction - lateinit var span: ISpan + private class Fixture { - fun getSut(): Tracer { - options = SentryOptions().also { - it.dsn = "https://key@sentry.io/proj" - it.instrumenter = Instrumenter.OTEL - } - hub = mock() - transaction = mock() - span = mock() + val options = SentryOptions().also { + it.dsn = "https://key@sentry.io/proj" + it.instrumenter = Instrumenter.OTEL + } + val hub = mock() + val transaction = mock() + val span = mock() + lateinit var openTelemetry: OpenTelemetry + lateinit var tracer: Tracer + val sentryTrace = SentryTraceHeader(SENTRY_TRACE_HEADER_STRING) + val baggage = Baggage.fromHeader(BAGGAGE_HEADER_STRING) + fun setup() { whenever(hub.isEnabled).thenReturn(true) whenever(hub.options).thenReturn(options) whenever(hub.startTransaction(any(), any())).thenReturn(transaction) + + whenever(span.toSentryTrace()).thenReturn(sentryTrace) + whenever(transaction.toSentryTrace()).thenReturn(sentryTrace) + + val baggageHeader = BaggageHeader.fromBaggageAndOutgoingHeader(baggage, null) + whenever(span.toBaggageHeader(any())).thenReturn(baggageHeader) + whenever(transaction.toBaggageHeader(any())).thenReturn(baggageHeader) + whenever(transaction.startChild(any(), anyOrNull(), anyOrNull(), eq(Instrumenter.OTEL))).thenReturn(span) val sdkTracerProvider = SdkTracerProvider.builder() .addSpanProcessor(SentrySpanProcessor(hub)) .build() - val openTelemetry: OpenTelemetry = OpenTelemetrySdk.builder() + openTelemetry = OpenTelemetrySdk.builder() .setTracerProvider(sdkTracerProvider) .setPropagators(ContextPropagators.create(SentryPropagator())) .build() - return openTelemetry.getTracer("sentry-test") + tracer = openTelemetry.getTracer("sentry-test") } } @@ -85,64 +114,336 @@ class SentrySpanProcessorTest { @Test fun `ignores sentry client request`() { - val tracer = fixture.getSut() - tracer.spanBuilder("testspan") - .setSpanKind(SpanKind.CLIENT) + fixture.setup() + givenSpanBuilder(SpanKind.CLIENT) .setAttribute(SemanticAttributes.HTTP_URL, "https://key@sentry.io/proj/some-api") .startSpan() - verify(fixture.hub, never()).startTransaction(any(), any()) + thenNoTransactionIsStarted() } @Test fun `ignores sentry internal request`() { - val tracer = fixture.getSut() - tracer.spanBuilder("testspan") - .setSpanKind(SpanKind.CLIENT) + fixture.setup() + givenSpanBuilder(SpanKind.CLIENT) .setAttribute(SemanticAttributes.HTTP_URL, "https://key@sentry.io/proj/some-api") .startSpan() - verify(fixture.hub, never()).startTransaction(any(), any()) + thenNoTransactionIsStarted() + } + + @Test + fun `does nothing on start if Sentry has not been initialized`() { + val hub = mock() + val context = mock() + val span = mock() + + whenever(hub.isEnabled).thenReturn(false) + + SentrySpanProcessor(hub).onStart(context, span) + + verify(hub).isEnabled + verifyNoMoreInteractions(hub) + verifyNoInteractions(context, span) + } + + @Test + fun `does nothing on end if Sentry has not been initialized`() { + val hub = mock() + val span = mock() + + whenever(hub.isEnabled).thenReturn(false) + + SentrySpanProcessor(hub).onEnd(span) + + verify(hub).isEnabled + verifyNoMoreInteractions(hub) + verifyNoInteractions(span) + } + + @Test + fun `does not start transaction for invalid SpanId`() { + fixture.setup() + val mockSpan = mock() + val mockSpanContext = mock() + whenever(mockSpanContext.spanId).thenReturn(SpanId.getInvalid()) + whenever(mockSpan.spanContext).thenReturn(mockSpanContext) + SentrySpanProcessor(fixture.hub).onStart(Context.current(), mockSpan) + thenNoTransactionIsStarted() + } + + @Test + fun `does not start transaction for invalid TraceId`() { + fixture.setup() + val mockSpan = mock() + val mockSpanContext = mock() + whenever(mockSpanContext.spanId).thenReturn(SpanId.fromBytes("seed".toByteArray())) + whenever(mockSpanContext.traceId).thenReturn(TraceId.getInvalid()) + whenever(mockSpan.spanContext).thenReturn(mockSpanContext) + SentrySpanProcessor(fixture.hub).onStart(Context.current(), mockSpan) + thenNoTransactionIsStarted() } @Test fun `creates transaction for first otel span and span for second`() { - val tracer = fixture.getSut() - val otelSpan = tracer.spanBuilder("testspan") - .setSpanKind(SpanKind.SERVER) + fixture.setup() + val otelSpan = givenSpanBuilder().startSpan() + thenTransactionIsStarted(otelSpan, isContinued = false) + + val otelChildSpan = givenSpanBuilder(SpanKind.CLIENT, parentSpan = otelSpan) .startSpan() + thenChildSpanIsStarted() - verify(fixture.hub).startTransaction( - check { - assertEquals("testspan", it.name) - assertEquals(TransactionNameSource.CUSTOM, it.transactionNameSource) - assertEquals("testspan", it.operation) - assertEquals(otelSpan.spanContext.spanId, it.spanId.toString()) - assertEquals(otelSpan.spanContext.traceId, it.traceId.toString()) - assertNull(it.parentSpanId) - assertNull(it.parentSamplingDecision) - assertNull(it.baggage) - }, - check { - assertNotNull(it.startTimestamp) - assertFalse(it.isBindToScope) - } - ) + otelChildSpan.end() + thenChildSpanIsFinished() + + otelSpan.end() + thenTransactionIsFinished() + } + + private fun whenExtractingHeaders(sentryTrace: Boolean = true, baggage: Boolean = true): Context { + val headers = givenHeaders(sentryTrace, baggage) + return fixture.openTelemetry.propagators.textMapPropagator.extract(Context.current(), headers, HeaderGetter()) + } + + @Test + fun `propagator can extract and result is used for transaction and attached on inject`() { + fixture.setup() + val extractedContext = whenExtractingHeaders() + + extractedContext.makeCurrent().use { _ -> + val otelSpan = givenSpanBuilder().startSpan() + thenTraceIdIsUsed(otelSpan) + thenTransactionIsStarted(otelSpan, isContinued = true) + + val otelChildSpan = givenSpanBuilder(SpanKind.CLIENT, parentSpan = otelSpan) + .startSpan() + thenChildSpanIsStarted() + + val map = mutableMapOf() + fixture.openTelemetry.propagators.textMapPropagator.inject(Context.current().with(otelSpan), map, TestSetter()) + + assertTrue(map.isNotEmpty()) + assertEquals(SENTRY_TRACE_HEADER_STRING, map["sentry-trace"]) + assertEquals(BAGGAGE_HEADER_STRING, map["baggage"]) + + otelChildSpan.end() + thenChildSpanIsFinished() + + otelSpan.end() + thenTransactionIsFinished() + } + } + + @Test + fun `incoming baggage without sentry-trace is ignored`() { + fixture.setup() + val extractedContext = whenExtractingHeaders(sentryTrace = false, baggage = true) + + extractedContext.makeCurrent().use { _ -> + val otelSpan = givenSpanBuilder() + .startSpan() + thenTraceIdIsNotUsed(otelSpan) + thenTransactionIsStarted(otelSpan, isContinued = false) + + val otelChildSpan = givenSpanBuilder(SpanKind.CLIENT, parentSpan = otelSpan) + .startSpan() + thenChildSpanIsStarted() + + otelChildSpan.end() + thenChildSpanIsFinished() + + otelSpan.end() + thenTransactionIsFinished() + } + } + + @Test + fun `sentry-trace without baggage continues trace`() { + fixture.setup() + val extractedContext = whenExtractingHeaders(sentryTrace = true, baggage = false) - val otelChildSpan = tracer.spanBuilder("childspan") - .setSpanKind(SpanKind.CLIENT) - .setParent(Context.current().with(otelSpan)) + extractedContext.makeCurrent().use { _ -> + val otelSpan = givenSpanBuilder() + .startSpan() + + thenTraceIdIsUsed(otelSpan) + thenTransactionIsStarted(otelSpan, isContinued = true, continuesWithFilledBaggage = false) + + val otelChildSpan = givenSpanBuilder(SpanKind.CLIENT, parentSpan = otelSpan) + .startSpan() + thenChildSpanIsStarted() + + otelChildSpan.end() + thenChildSpanIsFinished() + + otelSpan.end() + thenTransactionIsFinished() + } + } + + @Test + fun `sets status for errored span`() { + fixture.setup() + val otelSpan = givenSpanBuilder().startSpan() + thenTransactionIsStarted(otelSpan, isContinued = false) + + val otelChildSpan = givenSpanBuilder(SpanKind.CLIENT, parentSpan = otelSpan) .startSpan() + thenChildSpanIsStarted() - verify(fixture.transaction).startChild(eq("childspan"), eq("childspan"), any(), eq(Instrumenter.OTEL)) + otelChildSpan.setStatus(StatusCode.ERROR) + otelChildSpan.setAttribute(SemanticAttributes.HTTP_URL, "http://github.com/getsentry/sentry-java") + otelChildSpan.setAttribute(SemanticAttributes.HTTP_STATUS_CODE, 404L) + + otelChildSpan.end() + thenChildSpanIsFinished(SpanStatus.NOT_FOUND) otelSpan.end() + thenTransactionIsFinished() + } + + @Test + fun `sets status for errored span if not http`() { + fixture.setup() + val otelSpan = givenSpanBuilder().startSpan() + thenTransactionIsStarted(otelSpan, isContinued = false) + + val otelChildSpan = givenSpanBuilder(SpanKind.CLIENT, parentSpan = otelSpan) + .startSpan() + thenChildSpanIsStarted() + + otelChildSpan.setStatus(StatusCode.ERROR) + + otelChildSpan.end() + thenChildSpanIsFinished(SpanStatus.UNKNOWN_ERROR) + + otelSpan.end() + thenTransactionIsFinished() + } + + private fun givenSpanBuilder(spanKind: SpanKind = SpanKind.SERVER, parentSpan: Span? = null): SpanBuilder { + val spanName = if (parentSpan == null) "testspan" else "childspan" + val spanBuilder = fixture.tracer + .spanBuilder(spanName) + .setAttribute("some-attribute", "some-value") + .setSpanKind(spanKind) + + parentSpan?.let { spanBuilder.setParent(Context.current().with(parentSpan)) } + + return spanBuilder + } + + private fun givenHeaders(sentryTrace: Boolean = true, baggage: Boolean = true): HttpHeaders? { + val headerMap = mutableMapOf>().also { + if (sentryTrace) { + it.put("sentry-trace", listOf(SENTRY_TRACE_HEADER_STRING)) + } + if (baggage) { + it.put("baggage", listOf(BAGGAGE_HEADER_STRING)) + } + } + + return HttpHeaders.of(headerMap) { _, _ -> true } + } + + private fun thenTransactionIsStarted(otelSpan: Span, isContinued: Boolean = false, continuesWithFilledBaggage: Boolean = true) { + if (isContinued) { + verify(fixture.hub).startTransaction( + check { + assertEquals("testspan", it.name) + assertEquals(TransactionNameSource.CUSTOM, it.transactionNameSource) + assertEquals("testspan", it.operation) + assertEquals(otelSpan.spanContext.spanId, it.spanId.toString()) + assertEquals("2722d9f6ec019ade60c776169d9a8904", it.traceId.toString()) + assertEquals("cedf5b7571cb4972", it.parentSpanId?.toString()) + assertTrue(it.parentSamplingDecision!!.sampled) + if (continuesWithFilledBaggage) { + assertEquals("2722d9f6ec019ade60c776169d9a8904", it.baggage?.traceId) + assertEquals("1", it.baggage?.sampleRate) + assertEquals("HTTP GET", it.baggage?.transaction) + assertEquals("502f25099c204a2fbf4cb16edc5975d1", it.baggage?.publicKey) + } else { + assertNotNull(it.baggage) + assertNull(it.baggage?.traceId) + assertNull(it.baggage?.sampleRate) + assertNull(it.baggage?.transaction) + assertNull(it.baggage?.publicKey) + assertFalse(it.baggage!!.isMutable) + } + assertFalse(it.baggage!!.isMutable) + }, + check { + assertNotNull(it.startTimestamp) + assertFalse(it.isBindToScope) + } + ) + } else { + verify(fixture.hub).startTransaction( + check { + assertEquals("testspan", it.name) + assertEquals(TransactionNameSource.CUSTOM, it.transactionNameSource) + assertEquals("testspan", it.operation) + assertEquals(otelSpan.spanContext.spanId, it.spanId.toString()) + assertEquals(otelSpan.spanContext.traceId, it.traceId.toString()) + assertNull(it.parentSpanId) + assertNull(it.parentSamplingDecision) + assertNull(it.baggage) + }, + check { + assertNotNull(it.startTimestamp) + assertFalse(it.isBindToScope) + } + ) + } + } + + private fun thenTraceIdIsUsed(otelSpan: Span) { + assertEquals("2722d9f6ec019ade60c776169d9a8904", otelSpan.spanContext.traceId) + } + private fun thenTraceIdIsNotUsed(otelSpan: Span) { + assertNotEquals("2722d9f6ec019ade60c776169d9a8904", otelSpan.spanContext.traceId) + } + + private fun thenNoTransactionIsStarted() { + verify(fixture.hub, never()).startTransaction( + any(), + any() + ) + } + + private fun thenChildSpanIsStarted() { + verify(fixture.transaction).startChild( + eq("childspan"), + eq("childspan"), + any(), + eq(Instrumenter.OTEL) + ) + } + + private fun thenChildSpanIsFinished(status: SpanStatus = SpanStatus.OK) { + verify(fixture.span).finish(eq(status), any()) + } + + private fun thenTransactionIsFinished() { verify(fixture.transaction).setContext(eq("otel"), any()) verify(fixture.transaction).finish(eq(SpanStatus.OK), any()) + } +} - otelChildSpan.end() +class HeaderGetter : TextMapGetter { + override fun keys(headers: HttpHeaders): MutableIterable { + return headers.map().map { it.key }.toMutableList() + } + + override fun get(headers: HttpHeaders?, key: String): String? { + return headers?.firstValue(key)?.orElse(null) + } +} - verify(fixture.span).finish(eq(SpanStatus.OK), any()) +class TestSetter : TextMapSetter> { + override fun set(values: MutableMap?, key: String, value: String) { + values?.put(key, value) } }