diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java index 3cc492a9e4c..571fddc6e83 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanExporter.java @@ -6,6 +6,7 @@ import static io.sentry.opentelemetry.OtelSpanUtils.maybeTransferOtelAttribute; import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.api.trace.StatusCode; import io.opentelemetry.sdk.common.CompletableResultCode; import io.opentelemetry.sdk.trace.data.SpanData; @@ -28,6 +29,7 @@ import io.sentry.SentryLevel; import io.sentry.SentryLongDate; import io.sentry.SpanContext; +import io.sentry.SpanDataConvention; import io.sentry.SpanId; import io.sentry.SpanOptions; import io.sentry.SpanStatus; @@ -240,6 +242,7 @@ private void createAndFinishSpanForOtelSpan( setOtelInstrumentationInfo(spanData, sentryChildSpan); setOtelSpanKind(spanData, sentryChildSpan); transferSpanDetails(sentrySpanMaybe, sentryChildSpan); + setSentrySpanKind(spanData, sentryChildSpan); for (SpanNode childNode : spanNode.getChildren()) { createAndFinishSpanForOtelSpan(childNode, sentryChildSpan, remaining); @@ -355,6 +358,10 @@ private void transferSpanDetails( setOtelInstrumentationInfo(span, sentryTransaction); setOtelSpanKind(span, sentryTransaction); transferSpanDetails(sentrySpanMaybe, sentryTransaction); + setSentrySpanKind(span, sentryTransaction); + if (SpanKind.SERVER.equals(span.getKind())) { + maybeTransferOtelAttribute(span, sentryTransaction, HttpAttributes.HTTP_RESPONSE_STATUS_CODE); + } maybeTransferOtelAttribute(span, sentryTransaction, ThreadIncubatingAttributes.THREAD_ID); maybeTransferOtelAttribute(span, sentryTransaction, ThreadIncubatingAttributes.THREAD_NAME); @@ -555,6 +562,13 @@ private void setOtelSpanKind(final @NotNull SpanData otelSpan, final @NotNull IS sentrySpan.setData("otel.kind", otelSpan.getKind().name()); } + private void setSentrySpanKind( + final @NotNull SpanData otelSpan, final @NotNull ISpan sentrySpan) { + if (SpanKind.SERVER.equals(otelSpan.getKind())) { + sentrySpan.setData(SpanDataConvention.SENTRY_KIND_KEY, "server"); + } + } + @Override public CompletableResultCode flush() { scopes.flush(10000); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySpanExporterTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySpanExporterTest.kt new file mode 100644 index 00000000000..84b852e6f15 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SentrySpanExporterTest.kt @@ -0,0 +1,186 @@ +package io.sentry.opentelemetry + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.api.trace.SpanKind +import io.opentelemetry.context.Context +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor +import io.opentelemetry.semconv.HttpAttributes +import io.sentry.ISentryClient +import io.sentry.NoOpScopes +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SpanDataConvention +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.SentryId +import io.sentry.protocol.SentryTransaction +import io.sentry.test.createSentryClientMock +import io.sentry.test.createTestScopes +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentrySpanExporterTest { + private val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + tracesSampleRate = 1.0 + } + private val client: ISentryClient = createSentryClientMock() + private val scopes = createTestScopes(options).also { it.bindClient(client) } + private val exporter = SentrySpanExporter(scopes) + + init { + whenever( + client.captureTransaction(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + ) + .thenReturn(SentryId()) + Sentry.setCurrentScopes(scopes) + } + + @AfterTest + fun tearDown() { + Sentry.setCurrentScopes(NoOpScopes.getInstance()) + } + + @Test + fun `server roots and children get server kind and root response status`() { + withOpenTelemetry { openTelemetry -> + val tracer = openTelemetry.getTracer("test") + val root = + tracer + .spanBuilder("server-root") + .setSpanKind(SpanKind.SERVER) + .setAttribute(HttpAttributes.HTTP_RESPONSE_STATUS_CODE, 404L) + .startSpan() + val child = + tracer + .spanBuilder("server-child") + .setParent(root.storeInContext(Context.root())) + .setSpanKind(SpanKind.SERVER) + .startSpan() + + child.end() + root.end() + } + + val transaction = capturedTransactions().single() + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(404L) + assertThat(transaction.contexts.trace!!.data["otel.kind"]).isEqualTo("SERVER") + assertThat(transaction.spans).hasSize(1) + assertThat(transaction.spans.single().data!![SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.spans.single().data!!["otel.kind"]).isEqualTo("SERVER") + } + + @Test + fun `client and internal roots do not get server data`() { + withOpenTelemetry { openTelemetry -> + val tracer = openTelemetry.getTracer("test") + tracer + .spanBuilder("client-root") + .setSpanKind(SpanKind.CLIENT) + .setAttribute(HttpAttributes.HTTP_RESPONSE_STATUS_CODE, 404L) + .startSpan() + .end() + tracer + .spanBuilder("internal-root") + .setSpanKind(SpanKind.INTERNAL) + .setAttribute(HttpAttributes.HTTP_RESPONSE_STATUS_CODE, 500L) + .startSpan() + .end() + } + + val transactions = capturedTransactions().associateBy { it.transaction } + assertThat(transactions.keys).containsExactly("client-root", "internal-root") + assertThat(transactions["client-root"]!!.contexts.trace!!.data) + .doesNotContainKey(SpanDataConvention.SENTRY_KIND_KEY) + assertThat(transactions["client-root"]!!.contexts.trace!!.data) + .doesNotContainKey(SpanDataConvention.HTTP_STATUS_CODE_KEY) + assertThat(transactions["internal-root"]!!.contexts.trace!!.data) + .doesNotContainKey(SpanDataConvention.SENTRY_KIND_KEY) + assertThat(transactions["internal-root"]!!.contexts.trace!!.data) + .doesNotContainKey(SpanDataConvention.HTTP_STATUS_CODE_KEY) + } + + @Test + fun `rpc server root does not fabricate HTTP response status`() { + withOpenTelemetry { openTelemetry -> + openTelemetry + .getTracer("test") + .spanBuilder("rpc-server") + .setSpanKind(SpanKind.SERVER) + .startSpan() + .end() + } + + val transaction = capturedTransactions().single() + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data) + .doesNotContainKey(SpanDataConvention.HTTP_STATUS_CODE_KEY) + } + + @Test + fun `Sentry server marker survives default OTel internal kind`() { + withOpenTelemetry(includeSentrySpanProcessor = true) { openTelemetry -> + options.spanFactory = OtelSpanFactory(openTelemetry) + val transaction = + scopes.startTransaction( + TransactionContext("native-server", "http.server"), + TransactionOptions(), + ) + transaction.setData(SpanDataConvention.SENTRY_KIND_KEY, "server") + transaction.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, 404) + transaction.finish() + } + + val transaction = capturedTransactions().single() + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(404) + assertThat(transaction.contexts.trace!!.data["otel.kind"]).isEqualTo("INTERNAL") + } + + private fun withOpenTelemetry( + includeSentrySpanProcessor: Boolean = false, + callback: (OpenTelemetrySdk) -> Unit, + ) { + val providerBuilder = SdkTracerProvider.builder() + if (includeSentrySpanProcessor) { + providerBuilder.addSpanProcessor(OtelSentrySpanProcessor(scopes)) + } + val provider = providerBuilder.addSpanProcessor(SimpleSpanProcessor.create(exporter)).build() + val openTelemetry = OpenTelemetrySdk.builder().setTracerProvider(provider).build() + try { + callback(openTelemetry) + provider.forceFlush().join(10, TimeUnit.SECONDS) + } finally { + provider.close() + } + } + + private fun capturedTransactions(): List { + val transaction = argumentCaptor() + verify(client, atLeastOnce()) + .captureTransaction( + transaction.capture(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + return transaction.allValues + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTracingFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTracingFilter.java index a978c57d9c3..39e2fc3ec9e 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTracingFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTracingFilter.java @@ -9,6 +9,7 @@ import io.sentry.ITransaction; import io.sentry.ScopesAdapter; import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; import io.sentry.SpanStatus; import io.sentry.TransactionContext; import io.sentry.TransactionOptions; @@ -139,8 +140,10 @@ private void doFilterWithTransaction( final @Nullable ITransaction transaction = getOrStartTransaction(httpRequest, transactionContext); + boolean filterChainCompleted = false; try { filterChain.doFilter(httpRequest, httpResponse); + filterChainCompleted = true; } catch (Throwable e) { if (transaction != null) { // exceptions that are not handled by Spring @@ -164,6 +167,10 @@ private void doFilterWithTransaction( if (transactionName != null) { transaction.setName(transactionName, transactionNameSource); transaction.setOperation(TRANSACTION_OP); + // An escaping exception may be mapped to an HTTP status later by the container. + if (filterChainCompleted) { + transaction.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, httpResponse.getStatus()); + } // if exception has been thrown, transaction status is already set to INTERNAL_ERROR, and // httpResponse.getStatus() returns 200. if (transaction.getStatus() == null) { @@ -240,15 +247,20 @@ private ITransaction startTransaction( transactionOptions.setBindToScope(true); transactionOptions.setOrigin(TRACE_ORIGIN); + final @NotNull ITransaction transaction; if (transactionContext != null) { transactionContext.setName(name); transactionContext.setTransactionNameSource(TransactionNameSource.URL); transactionContext.setOperation("http.server"); - return scopes.startTransaction(transactionContext, transactionOptions); + transaction = scopes.startTransaction(transactionContext, transactionOptions); + } else { + transaction = + scopes.startTransaction( + new TransactionContext(name, TransactionNameSource.URL, "http.server"), + transactionOptions); } - - return scopes.startTransaction( - new TransactionContext(name, TransactionNameSource.URL, "http.server"), transactionOptions); + transaction.setData(SpanDataConvention.SENTRY_KIND_KEY, "server"); + return transaction; } } diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java index 898c9fd5937..0301159bb4d 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java @@ -13,6 +13,7 @@ import io.sentry.NoOpScopes; import io.sentry.Sentry; import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; import io.sentry.SpanStatus; import io.sentry.TransactionContext; import io.sentry.TransactionOptions; @@ -126,6 +127,7 @@ private void finishTransaction(ServerWebExchange exchange, ITransaction transact if (response != null) { final @Nullable HttpStatusCode statusCode = response.getStatusCode(); if (statusCode != null) { + transaction.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, statusCode.value()); transaction .getContexts() .withResponse( @@ -154,16 +156,20 @@ private void finishTransaction(ServerWebExchange exchange, ITransaction transact transactionOptions.setBindToScope(true); transactionOptions.setOrigin(origin); + final @NotNull ITransaction transaction; if (transactionContext != null) { transactionContext.setName(name); transactionContext.setTransactionNameSource(TransactionNameSource.URL); transactionContext.setOperation(TRANSACTION_OP); - return scopes.startTransaction(transactionContext, transactionOptions); + transaction = scopes.startTransaction(transactionContext, transactionOptions); + } else { + transaction = + scopes.startTransaction( + new TransactionContext(name, TransactionNameSource.URL, TRANSACTION_OP), + transactionOptions); } - - return scopes.startTransaction( - new TransactionContext(name, TransactionNameSource.URL, TRANSACTION_OP), - transactionOptions); + transaction.setData(SpanDataConvention.SENTRY_KIND_KEY, "server"); + return transaction; } } diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTracingFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTracingFilterTest.kt index fae0f19ebe6..7bdb93e786a 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTracingFilterTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTracingFilterTest.kt @@ -5,6 +5,7 @@ import io.sentry.IScopes import io.sentry.PropagationContext import io.sentry.SentryOptions import io.sentry.SentryTracer +import io.sentry.SpanDataConvention import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.TraceContext @@ -134,6 +135,10 @@ class SentryTracingFilterTest { assertThat(it.transaction).isEqualTo("POST /product/{id}") assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) }, anyOrNull(), anyOrNull(), @@ -160,6 +165,26 @@ class SentryTracingFilterTest { ) } + @Test + fun `records not found response status`() { + val filter = fixture.getSut(status = 404) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes) + .captureTransaction( + check { + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(404) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `sets correct span status based on the response status`() { val filter = fixture.getSut(status = 500) @@ -214,7 +239,13 @@ class SentryTracingFilterTest { verify(fixture.scopes) .captureTransaction( - check { assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) }, + check { + assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) + }, anyOrNull(), anyOrNull(), anyOrNull(), @@ -245,7 +276,11 @@ class SentryTracingFilterTest { } catch (_: Exception) {} verify(fixture.scopes) .captureTransaction( - check { assertThat(it.status).isEqualTo(SpanStatus.INTERNAL_ERROR) }, + check { + assertThat(it.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + assertThat(it.contexts.trace!!.data) + .doesNotContainKey(SpanDataConvention.HTTP_STATUS_CODE_KEY) + }, anyOrNull(), anyOrNull(), anyOrNull(), @@ -429,6 +464,10 @@ class SentryTracingFilterTest { assertThat(it.transaction).isEqualTo("POST /product/{id}") assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) }, anyOrNull(), anyOrNull(), diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt index c6c65b560db..a5b31f73c2d 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt @@ -9,6 +9,7 @@ import io.sentry.ScopeCallback import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.SentryTracer +import io.sentry.SpanDataConvention import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.TraceContext @@ -143,6 +144,32 @@ class SentryWebFluxTracingFilterTest { assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") assertThat(it.contexts.response!!.statusCode).isEqualTo(200) + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + } + + @Test + fun `records not found response status`() { + val filter = fixture.getSut(status = HttpStatus.NOT_FOUND) + + withMockScopes { + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.scopes) + .captureTransaction( + check { + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(404) }, anyOrNull(), anyOrNull(), @@ -215,7 +242,13 @@ class SentryWebFluxTracingFilterTest { verify(fixture.scopes) .captureTransaction( - check { assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) }, + check { + assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) + }, anyOrNull(), anyOrNull(), anyOrNull(), diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt index 30543274d5d..23b86c38867 100644 --- a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt @@ -2,6 +2,7 @@ package io.sentry.spring.boot4.integrationtest import com.google.common.truth.Truth.assertThat import io.sentry.Sentry +import io.sentry.SpanDataConvention import io.sentry.protocol.FeatureFlag import io.sentry.test.event import io.sentry.test.hasFlag @@ -45,6 +46,10 @@ class PersonIntegrationTest : IntegrationTest() { } == true } .transaction()!! + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data) + .doesNotContainKey(SpanDataConvention.HTTP_STATUS_CODE_KEY) assertThat( transaction.hasOp( "http.server", @@ -83,6 +88,10 @@ class PersonIntegrationTest : IntegrationTest() { val transaction = server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) assertThat(transaction.hasSpanWithOp("PersonService.create")).isTrue() assertThat( transaction.hasSpanWithOpAndDescription( diff --git a/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt index 8151eb4a8a1..15aab17c410 100644 --- a/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt @@ -2,6 +2,7 @@ package io.sentry.spring.boot4.webfluxintegrationtest import com.google.common.truth.Truth.assertThat import io.sentry.Sentry +import io.sentry.SpanDataConvention import io.sentry.test.event import io.sentry.test.hasFlag import io.sentry.test.hasLogWithAttribute @@ -30,6 +31,10 @@ class PersonWebfluxIntegrationTest : WebfluxIntegrationTest() { val transaction = server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! assertThat(transaction.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(500) val logs = server @@ -55,6 +60,10 @@ class PersonWebfluxIntegrationTest : WebfluxIntegrationTest() { val transaction = server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! assertThat(transaction.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) } @Test diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt index 3f8ba722099..97b80afa3cc 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt @@ -2,6 +2,7 @@ package io.sentry.spring.boot.jakarta.integrationtest import com.google.common.truth.Truth.assertThat import io.sentry.Sentry +import io.sentry.SpanDataConvention import io.sentry.protocol.FeatureFlag import io.sentry.test.event import io.sentry.test.hasFlag @@ -45,6 +46,10 @@ class PersonIntegrationTest : IntegrationTest() { } == true } .transaction()!! + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data) + .doesNotContainKey(SpanDataConvention.HTTP_STATUS_CODE_KEY) assertThat( transaction.hasOp( "http.server", @@ -83,6 +88,10 @@ class PersonIntegrationTest : IntegrationTest() { val transaction = server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) assertThat(transaction.hasSpanWithOp("PersonService.create")).isTrue() assertThat( transaction.hasSpanWithOpAndDescription( diff --git a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt index c72ed553235..662ffc961dc 100644 --- a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt @@ -2,6 +2,7 @@ package io.sentry.spring.boot.jakarta.webfluxintegrationtest import com.google.common.truth.Truth.assertThat import io.sentry.Sentry +import io.sentry.SpanDataConvention import io.sentry.test.event import io.sentry.test.hasFlag import io.sentry.test.hasLogWithAttribute @@ -30,6 +31,10 @@ class PersonWebfluxIntegrationTest : WebfluxIntegrationTest() { val transaction = server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! assertThat(transaction.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(500) val logs = server @@ -55,6 +60,10 @@ class PersonWebfluxIntegrationTest : WebfluxIntegrationTest() { val transaction = server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! assertThat(transaction.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) } @Test diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/PersonIntegrationTest.kt index 5a15d818166..43dd6bb5f07 100644 --- a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/PersonIntegrationTest.kt @@ -2,6 +2,7 @@ package io.sentry.spring.boot.integrationtest import com.google.common.truth.Truth.assertThat import io.sentry.Sentry +import io.sentry.SpanDataConvention import io.sentry.protocol.FeatureFlag import io.sentry.test.event import io.sentry.test.hasFlag @@ -45,6 +46,10 @@ class PersonIntegrationTest : IntegrationTest() { } == true } .transaction()!! + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data) + .doesNotContainKey(SpanDataConvention.HTTP_STATUS_CODE_KEY) assertThat( transaction.hasOp( "http.server", @@ -83,6 +88,10 @@ class PersonIntegrationTest : IntegrationTest() { val transaction = server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) assertThat(transaction.hasSpanWithOp("PersonService.create")).isTrue() assertThat( transaction.hasSpanWithOpAndDescription( diff --git a/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt index 379d6a5913d..0210da8ba64 100644 --- a/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt @@ -2,6 +2,7 @@ package io.sentry.spring.boot.webfluxintegrationtest import com.google.common.truth.Truth.assertThat import io.sentry.Sentry +import io.sentry.SpanDataConvention import io.sentry.test.event import io.sentry.test.hasFlag import io.sentry.test.hasLogWithAttribute @@ -30,6 +31,10 @@ class PersonWebfluxIntegrationTest : WebfluxIntegrationTest() { val transaction = server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! assertThat(transaction.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(500) val logs = server @@ -55,6 +60,10 @@ class PersonWebfluxIntegrationTest : WebfluxIntegrationTest() { val transaction = server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! assertThat(transaction.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(transaction.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) } @Test diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTracingFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTracingFilter.java index 4832e1c21ff..ddc1c25ec72 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTracingFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/tracing/SentryTracingFilter.java @@ -9,6 +9,7 @@ import io.sentry.ITransaction; import io.sentry.ScopesAdapter; import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; import io.sentry.SpanStatus; import io.sentry.TransactionContext; import io.sentry.TransactionOptions; @@ -139,8 +140,10 @@ private void doFilterWithTransaction( final @Nullable ITransaction transaction = getOrStartTransaction(httpRequest, transactionContext); + boolean filterChainCompleted = false; try { filterChain.doFilter(httpRequest, httpResponse); + filterChainCompleted = true; } catch (Throwable e) { if (transaction != null) { // exceptions that are not handled by Spring @@ -164,6 +167,10 @@ private void doFilterWithTransaction( if (transactionName != null) { transaction.setName(transactionName, transactionNameSource); transaction.setOperation(TRANSACTION_OP); + // An escaping exception may be mapped to an HTTP status later by the container. + if (filterChainCompleted) { + transaction.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, httpResponse.getStatus()); + } // if exception has been thrown, transaction status is already set to INTERNAL_ERROR, and // httpResponse.getStatus() returns 200. if (transaction.getStatus() == null) { @@ -240,15 +247,20 @@ private ITransaction startTransaction( transactionOptions.setBindToScope(true); transactionOptions.setOrigin(TRACE_ORIGIN); + final @NotNull ITransaction transaction; if (transactionContext != null) { transactionContext.setName(name); transactionContext.setTransactionNameSource(TransactionNameSource.URL); transactionContext.setOperation("http.server"); - return scopes.startTransaction(transactionContext, transactionOptions); + transaction = scopes.startTransaction(transactionContext, transactionOptions); + } else { + transaction = + scopes.startTransaction( + new TransactionContext(name, TransactionNameSource.URL, "http.server"), + transactionOptions); } - - return scopes.startTransaction( - new TransactionContext(name, TransactionNameSource.URL, "http.server"), transactionOptions); + transaction.setData(SpanDataConvention.SENTRY_KIND_KEY, "server"); + return transaction; } } diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/AbstractSentryWebFilter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/AbstractSentryWebFilter.java index a3edfd54c83..2468b43d20f 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/AbstractSentryWebFilter.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/webflux/AbstractSentryWebFilter.java @@ -13,6 +13,7 @@ import io.sentry.NoOpScopes; import io.sentry.Sentry; import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; import io.sentry.SpanStatus; import io.sentry.TransactionContext; import io.sentry.TransactionOptions; @@ -126,6 +127,7 @@ private void finishTransaction(ServerWebExchange exchange, ITransaction transact if (response != null) { final @Nullable HttpStatusCode statusCode = response.getStatusCode(); if (statusCode != null) { + transaction.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, statusCode.value()); transaction .getContexts() .withResponse( @@ -154,16 +156,20 @@ private void finishTransaction(ServerWebExchange exchange, ITransaction transact transactionOptions.setBindToScope(true); transactionOptions.setOrigin(origin); + final @NotNull ITransaction transaction; if (transactionContext != null) { transactionContext.setName(name); transactionContext.setTransactionNameSource(TransactionNameSource.URL); transactionContext.setOperation(TRANSACTION_OP); - return scopes.startTransaction(transactionContext, transactionOptions); + transaction = scopes.startTransaction(transactionContext, transactionOptions); + } else { + transaction = + scopes.startTransaction( + new TransactionContext(name, TransactionNameSource.URL, TRANSACTION_OP), + transactionOptions); } - - return scopes.startTransaction( - new TransactionContext(name, TransactionNameSource.URL, TRANSACTION_OP), - transactionOptions); + transaction.setData(SpanDataConvention.SENTRY_KIND_KEY, "server"); + return transaction; } } diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentryTracingFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentryTracingFilterTest.kt index 61fabb6d478..376083a41b7 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentryTracingFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/tracing/SentryTracingFilterTest.kt @@ -5,6 +5,7 @@ import io.sentry.IScopes import io.sentry.PropagationContext import io.sentry.SentryOptions import io.sentry.SentryTracer +import io.sentry.SpanDataConvention import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.TraceContext @@ -134,6 +135,10 @@ class SentryTracingFilterTest { assertThat(it.transaction).isEqualTo("POST /product/{id}") assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) }, anyOrNull(), anyOrNull(), @@ -160,6 +165,26 @@ class SentryTracingFilterTest { ) } + @Test + fun `records not found response status`() { + val filter = fixture.getSut(status = 404) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes) + .captureTransaction( + check { + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(404) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `sets correct span status based on the response status`() { val filter = fixture.getSut(status = 500) @@ -214,7 +239,13 @@ class SentryTracingFilterTest { verify(fixture.scopes) .captureTransaction( - check { assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) }, + check { + assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) + }, anyOrNull(), anyOrNull(), anyOrNull(), @@ -245,7 +276,11 @@ class SentryTracingFilterTest { } catch (_: Exception) {} verify(fixture.scopes) .captureTransaction( - check { assertThat(it.status).isEqualTo(SpanStatus.INTERNAL_ERROR) }, + check { + assertThat(it.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + assertThat(it.contexts.trace!!.data) + .doesNotContainKey(SpanDataConvention.HTTP_STATUS_CODE_KEY) + }, anyOrNull(), anyOrNull(), anyOrNull(), @@ -429,6 +464,10 @@ class SentryTracingFilterTest { assertThat(it.transaction).isEqualTo("POST /product/{id}") assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) }, anyOrNull(), anyOrNull(), diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebFluxTracingFilterTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebFluxTracingFilterTest.kt index 0a01b4cbcc4..9fe7351b1ef 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebFluxTracingFilterTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/webflux/SentryWebFluxTracingFilterTest.kt @@ -9,6 +9,7 @@ import io.sentry.ScopeCallback import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.SentryTracer +import io.sentry.SpanDataConvention import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.TraceContext @@ -143,6 +144,32 @@ class SentryWebFluxTracingFilterTest { assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") assertThat(it.contexts.response!!.statusCode).isEqualTo(200) + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + } + + @Test + fun `records not found response status`() { + val filter = fixture.getSut(status = HttpStatus.NOT_FOUND) + + withMockScopes { + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.scopes) + .captureTransaction( + check { + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(404) }, anyOrNull(), anyOrNull(), @@ -215,7 +242,13 @@ class SentryWebFluxTracingFilterTest { verify(fixture.scopes) .captureTransaction( - check { assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) }, + check { + assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) + }, anyOrNull(), anyOrNull(), anyOrNull(), diff --git a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentryTracingFilter.java b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentryTracingFilter.java index 02da16feaca..9a74b27c234 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/tracing/SentryTracingFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/tracing/SentryTracingFilter.java @@ -9,6 +9,7 @@ import io.sentry.ITransaction; import io.sentry.ScopesAdapter; import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; import io.sentry.SpanStatus; import io.sentry.TransactionContext; import io.sentry.TransactionOptions; @@ -138,8 +139,10 @@ private void doFilterWithTransaction( final @Nullable ITransaction transaction = getOrStartTransaction(httpRequest, transactionContext); + boolean filterChainCompleted = false; try { filterChain.doFilter(httpRequest, httpResponse); + filterChainCompleted = true; } catch (Throwable e) { if (transaction != null) { // exceptions that are not handled by Spring @@ -163,6 +166,10 @@ private void doFilterWithTransaction( if (transactionName != null) { transaction.setName(transactionName, transactionNameSource); transaction.setOperation(TRANSACTION_OP); + // An escaping exception may be mapped to an HTTP status later by the container. + if (filterChainCompleted) { + transaction.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, httpResponse.getStatus()); + } // if exception has been thrown, transaction status is already set to INTERNAL_ERROR, and // httpResponse.getStatus() returns 200. if (transaction.getStatus() == null) { @@ -239,15 +246,20 @@ private ITransaction startTransaction( transactionOptions.setBindToScope(true); transactionOptions.setOrigin(TRACE_ORIGIN); + final @NotNull ITransaction transaction; if (transactionContext != null) { transactionContext.setName(name); transactionContext.setTransactionNameSource(TransactionNameSource.URL); transactionContext.setOperation("http.server"); - return scopes.startTransaction(transactionContext, transactionOptions); + transaction = scopes.startTransaction(transactionContext, transactionOptions); + } else { + transaction = + scopes.startTransaction( + new TransactionContext(name, TransactionNameSource.URL, "http.server"), + transactionOptions); } - - return scopes.startTransaction( - new TransactionContext(name, TransactionNameSource.URL, "http.server"), transactionOptions); + transaction.setData(SpanDataConvention.SENTRY_KIND_KEY, "server"); + return transaction; } } diff --git a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryWebFilter.java b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryWebFilter.java index 031f579d4e4..9f907b206dd 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryWebFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/webflux/SentryWebFilter.java @@ -13,6 +13,7 @@ import io.sentry.NoOpScopes; import io.sentry.Sentry; import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; import io.sentry.SpanStatus; import io.sentry.TransactionContext; import io.sentry.TransactionOptions; @@ -132,17 +133,21 @@ private boolean shouldTraceRequest( transactionOptions.setBindToScope(true); transactionOptions.setOrigin(TRACE_ORIGIN); + final @NotNull ITransaction transaction; if (transactionContext != null) { transactionContext.setName(name); transactionContext.setTransactionNameSource(TransactionNameSource.URL); transactionContext.setOperation(TRANSACTION_OP); - return scopes.startTransaction(transactionContext, transactionOptions); + transaction = scopes.startTransaction(transactionContext, transactionOptions); + } else { + transaction = + scopes.startTransaction( + new TransactionContext(name, TransactionNameSource.URL, TRANSACTION_OP), + transactionOptions); } - - return scopes.startTransaction( - new TransactionContext(name, TransactionNameSource.URL, TRANSACTION_OP), - transactionOptions); + transaction.setData(SpanDataConvention.SENTRY_KIND_KEY, "server"); + return transaction; } private void finishTransaction(ServerWebExchange exchange, ITransaction transaction) { @@ -155,6 +160,7 @@ private void finishTransaction(ServerWebExchange exchange, ITransaction transact if (response != null) { final @Nullable Integer rawStatusCode = response.getRawStatusCode(); if (rawStatusCode != null) { + transaction.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, rawStatusCode); transaction .getContexts() .withResponse( diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentryTracingFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentryTracingFilterTest.kt index acfcdb4ebab..c21100e539b 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentryTracingFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/tracing/SentryTracingFilterTest.kt @@ -5,6 +5,7 @@ import io.sentry.IScopes import io.sentry.PropagationContext import io.sentry.SentryOptions import io.sentry.SentryTracer +import io.sentry.SpanDataConvention import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.TraceContext @@ -134,6 +135,10 @@ class SentryTracingFilterTest { assertThat(it.transaction).isEqualTo("POST /product/{id}") assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) }, anyOrNull(), anyOrNull(), @@ -160,6 +165,26 @@ class SentryTracingFilterTest { ) } + @Test + fun `records not found response status`() { + val filter = fixture.getSut(status = 404) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes) + .captureTransaction( + check { + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(404) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + @Test fun `sets correct span status based on the response status`() { val filter = fixture.getSut(status = 500) @@ -214,7 +239,13 @@ class SentryTracingFilterTest { verify(fixture.scopes) .captureTransaction( - check { assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) }, + check { + assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) + }, anyOrNull(), anyOrNull(), anyOrNull(), @@ -245,7 +276,11 @@ class SentryTracingFilterTest { } catch (_: Exception) {} verify(fixture.scopes) .captureTransaction( - check { assertThat(it.status).isEqualTo(SpanStatus.INTERNAL_ERROR) }, + check { + assertThat(it.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + assertThat(it.contexts.trace!!.data) + .doesNotContainKey(SpanDataConvention.HTTP_STATUS_CODE_KEY) + }, anyOrNull(), anyOrNull(), anyOrNull(), @@ -429,6 +464,10 @@ class SentryTracingFilterTest { assertThat(it.transaction).isEqualTo("POST /product/{id}") assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) }, anyOrNull(), anyOrNull(), diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebFluxTracingFilterTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebFluxTracingFilterTest.kt index 326b5979991..88fe80cab0d 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebFluxTracingFilterTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/webflux/SentryWebFluxTracingFilterTest.kt @@ -9,6 +9,7 @@ import io.sentry.ScopeCallback import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.SentryTracer +import io.sentry.SpanDataConvention import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.TraceContext @@ -144,6 +145,32 @@ class SentryWebFluxTracingFilterTest { assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") assertThat(it.contexts.response!!.statusCode).isEqualTo(200) + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + } + + @Test + fun `records not found response status`() { + val filter = fixture.getSut(status = HttpStatus.NOT_FOUND) + + withMockScopes { + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.scopes) + .captureTransaction( + check { + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(404) }, anyOrNull(), anyOrNull(), @@ -216,7 +243,13 @@ class SentryWebFluxTracingFilterTest { verify(fixture.scopes) .captureTransaction( - check { assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) }, + check { + assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) + assertThat(it.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY]) + .isEqualTo("server") + assertThat(it.contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + .isEqualTo(200) + }, anyOrNull(), anyOrNull(), anyOrNull(), diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index b3c312169a3..49e5242da7f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4360,6 +4360,7 @@ public abstract interface class io/sentry/SpanDataConvention { public static final field MESSAGING_OPERATION_TYPE Ljava/lang/String; public static final field MESSAGING_SYSTEM Ljava/lang/String; public static final field PROFILER_ID Ljava/lang/String; + public static final field SENTRY_KIND_KEY Ljava/lang/String; public static final field THREAD_ID Ljava/lang/String; public static final field THREAD_NAME Ljava/lang/String; } diff --git a/sentry/src/main/java/io/sentry/SpanDataConvention.java b/sentry/src/main/java/io/sentry/SpanDataConvention.java index 4ede74505cb..93760fb8dc4 100644 --- a/sentry/src/main/java/io/sentry/SpanDataConvention.java +++ b/sentry/src/main/java/io/sentry/SpanDataConvention.java @@ -12,6 +12,7 @@ public interface SpanDataConvention { String HTTP_FRAGMENT_KEY = "http.fragment"; String HTTP_METHOD_KEY = "http.request.method"; String HTTP_STATUS_CODE_KEY = "http.response.status_code"; + String SENTRY_KIND_KEY = "sentry.kind"; String HTTP_RESPONSE_CONTENT_LENGTH_KEY = "http.response_content_length"; String BLOCKED_MAIN_THREAD_KEY = "blocked_main_thread"; String CALL_STACK_KEY = "call_stack";