From e3eca3ff6611b4313454cfc8d2875919d4cb8d7d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 27 Apr 2026 05:15:51 +0200 Subject: [PATCH 1/6] feat(opentelemetry): [Queue Instrumentation 33] Map OTel messaging spans to Sentry queue ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire OTel messaging spans into the Sentry Queues product when `sentry.enable-queue-tracing=true` so OTel-only setups (e.g. the agentless Spring Boot Jakarta sample) populate queue dashboards without needing the Sentry-native Kafka interceptors. `SpanDescriptionExtractor` now recognizes spans carrying `messaging.system` and maps them to `queue.publish` / `queue.process` / `queue.receive` ops, using the destination name as the description and `TransactionNameSource.TASK`. Op selection prefers `messaging.operation.type` (current OTel semconv), falls back to the deprecated `messaging.operation`, and only as a last resort consults `SpanKind` — `SpanKind.CONSUMER` is overloaded for both `receive` and `process`, so attribute-driven mapping is required to disambiguate. The extractor takes `SentryOptions` so the mapping stays gated; when the flag is off, behavior is unchanged. `SentrySpanExporter` additionally transfers the messaging attributes (`system`, `destination.name`, `operation.type`, `message.id`, `message.body.size`, `message.envelope.size`) onto root transactions. Root transactions don't bulk-copy OTel attributes the way child spans do, but the Queues product reads `trace.data.messaging.*`, so consumer root transactions need them propagated explicitly. These are operational metadata only (no payload contents), so the transfer is unconditional. Add `MESSAGING_OPERATION_TYPE` and `MESSAGING_MESSAGE_ENVELOPE_SIZE` to `SpanDataConvention` for use by the exporter and downstream integrations. Document the OTel-mode behavior in the two Jakarta OTel sample `application-kafka.properties` so users know the flag activates the OTel remapping path here, not the Sentry-native Kafka auto-config (which stays suppressed by its `@ConditionalOnMissingClass` OTel guard). --- .../api/sentry-opentelemetry-core.api | 2 +- .../opentelemetry/SentrySpanExporter.java | 22 ++- .../opentelemetry/SentrySpanProcessor.java | 4 +- .../SpanDescriptionExtractor.java | 62 +++++++- .../kotlin/SpanDescriptionExtractorTest.kt | 141 +++++++++++++++++- .../resources/application-kafka.properties | 10 ++ .../resources/application-kafka.properties | 9 ++ sentry/api/sentry.api | 2 + .../java/io/sentry/SpanDataConvention.java | 2 + 9 files changed, 246 insertions(+), 8 deletions(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api index b51c8cc39bc..847d69bca1b 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api +++ b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api @@ -149,7 +149,7 @@ public final class io/sentry/opentelemetry/SentrySpanProcessor : io/opentelemetr public final class io/sentry/opentelemetry/SpanDescriptionExtractor { public fun ()V - public fun extractSpanInfo (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/opentelemetry/IOtelSpanWrapper;)Lio/sentry/opentelemetry/OtelSpanInfo; + public fun extractSpanInfo (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/opentelemetry/IOtelSpanWrapper;Lio/sentry/SentryOptions;)Lio/sentry/opentelemetry/OtelSpanInfo; } public final class io/sentry/opentelemetry/SpanNode { 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 680177f8451..e7fc873908a 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 @@ -12,6 +12,7 @@ import io.opentelemetry.sdk.trace.data.StatusData; import io.opentelemetry.sdk.trace.export.SpanExporter; import io.opentelemetry.semconv.HttpAttributes; +import io.opentelemetry.semconv.incubating.MessagingIncubatingAttributes; import io.opentelemetry.semconv.incubating.ProcessIncubatingAttributes; import io.opentelemetry.semconv.incubating.ThreadIncubatingAttributes; import io.sentry.Baggage; @@ -200,7 +201,7 @@ private void createAndFinishSpanForOtelSpan( final @Nullable IOtelSpanWrapper sentrySpanMaybe = spanStorage.getSentrySpan(spanData.getSpanContext()); final @NotNull OtelSpanInfo spanInfo = - spanDescriptionExtractor.extractSpanInfo(spanData, sentrySpanMaybe); + spanDescriptionExtractor.extractSpanInfo(spanData, sentrySpanMaybe, scopes.getOptions()); scopes .getOptions() @@ -294,7 +295,7 @@ private void transferSpanDetails( final @NotNull IScopes scopesToUse = scopesToUseBeforeForking.forkedCurrentScope("SentrySpanExporter.createTransaction"); final @NotNull OtelSpanInfo spanInfo = - spanDescriptionExtractor.extractSpanInfo(span, sentrySpanMaybe); + spanDescriptionExtractor.extractSpanInfo(span, sentrySpanMaybe, scopesToUse.getOptions()); scopesToUse .getOptions() @@ -361,6 +362,23 @@ private void transferSpanDetails( maybeTransferOtelAttribute(span, sentryTransaction, ThreadIncubatingAttributes.THREAD_ID); maybeTransferOtelAttribute(span, sentryTransaction, ThreadIncubatingAttributes.THREAD_NAME); + // Root transactions don't bulk-copy OTel attributes into span data (unlike child spans). + // The Sentry Queues product reads `trace.data.messaging.*`, so messaging attributes must + // be explicitly transferred for consumer root transactions to show up correctly. These are + // operational metadata (no payload contents) and are safe to transfer unconditionally. + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_SYSTEM); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_MESSAGE_ID); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_MESSAGE_BODY_SIZE); + maybeTransferOtelAttribute( + span, sentryTransaction, MessagingIncubatingAttributes.MESSAGING_MESSAGE_ENVELOPE_SIZE); + scopesToUse.configureScope( ScopeType.CURRENT, scope -> attributesExtractor.extract(span, scope, scopesToUse.getOptions())); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java index 9c6a51f17c3..31bd6368318 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java @@ -297,7 +297,7 @@ private boolean isSentryRequest(final @NotNull ReadableSpan otelSpan) { private void updateTransactionWithOtelData( final @NotNull ITransaction sentryTransaction, final @NotNull ReadableSpan otelSpan) { final @NotNull OtelSpanInfo otelSpanInfo = - spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null); + spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null, scopes.getOptions()); sentryTransaction.setOperation(otelSpanInfo.getOp()); String transactionName = otelSpanInfo.getDescription(); sentryTransaction.setName( @@ -334,7 +334,7 @@ private void updateSpanWithOtelData( }); final @NotNull OtelSpanInfo otelSpanInfo = - spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null); + spanDescriptionExtractor.extractSpanInfo(otelSpan.toSpanData(), null, scopes.getOptions()); sentrySpan.setOperation(otelSpanInfo.getOp()); sentrySpan.setDescription(otelSpanInfo.getDescription()); } diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java index b66555d68c9..7b413c8267f 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java @@ -7,6 +7,8 @@ import io.opentelemetry.semconv.UrlAttributes; import io.opentelemetry.semconv.incubating.DbIncubatingAttributes; import io.opentelemetry.semconv.incubating.HttpIncubatingAttributes; +import io.opentelemetry.semconv.incubating.MessagingIncubatingAttributes; +import io.sentry.SentryOptions; import io.sentry.protocol.TransactionNameSource; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -17,7 +19,9 @@ public final class SpanDescriptionExtractor { @SuppressWarnings("deprecation") public @NotNull OtelSpanInfo extractSpanInfo( - final @NotNull SpanData otelSpan, final @Nullable IOtelSpanWrapper sentrySpan) { + final @NotNull SpanData otelSpan, + final @Nullable IOtelSpanWrapper sentrySpan, + final @NotNull SentryOptions options) { final @NotNull Attributes attributes = otelSpan.getAttributes(); final @Nullable String httpMethod = attributes.get(HttpAttributes.HTTP_REQUEST_METHOD); @@ -30,6 +34,14 @@ public final class SpanDescriptionExtractor { return descriptionForDbSystem(otelSpan); } + if (options.isEnableQueueTracing()) { + final @Nullable String messagingSystem = + attributes.get(MessagingIncubatingAttributes.MESSAGING_SYSTEM); + if (messagingSystem != null) { + return descriptionForMessagingSystem(otelSpan); + } + } + final @NotNull String name = otelSpan.getName(); final @Nullable String maybeDescription = sentrySpan != null ? sentrySpan.getDescription() : name; @@ -91,6 +103,54 @@ private static boolean isRootSpan(SpanData otelSpan) { return !otelSpan.getParentSpanContext().isValid() || otelSpan.getParentSpanContext().isRemote(); } + @SuppressWarnings("deprecation") + private OtelSpanInfo descriptionForMessagingSystem(final @NotNull SpanData otelSpan) { + final @NotNull Attributes attributes = otelSpan.getAttributes(); + final @NotNull String op = opForMessaging(otelSpan); + final @Nullable String destination = + attributes.get(MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME); + final @NotNull String description = destination != null ? destination : otelSpan.getName(); + return new OtelSpanInfo(op, description, TransactionNameSource.TASK); + } + + @SuppressWarnings("deprecation") + private @NotNull String opForMessaging(final @NotNull SpanData otelSpan) { + final @NotNull Attributes attributes = otelSpan.getAttributes(); + // Prefer `messaging.operation.type` (current OTel semconv), fall back to legacy + // `messaging.operation`. OTel's SpanKind.CONSUMER is overloaded for both `receive` and + // `process`, so attribute-first mapping is required. SpanKind is used only as a last resort. + @Nullable + String operationType = attributes.get(MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE); + if (operationType == null) { + operationType = attributes.get(MessagingIncubatingAttributes.MESSAGING_OPERATION); + } + if (operationType != null) { + switch (operationType) { + case "publish": + case "send": + case "create": + return "queue.publish"; + case "receive": + return "queue.receive"; + case "process": + case "deliver": + return "queue.process"; + default: + // fall through to SpanKind mapping + break; + } + } + + final @NotNull SpanKind kind = otelSpan.getKind(); + if (SpanKind.PRODUCER.equals(kind)) { + return "queue.publish"; + } + if (SpanKind.CONSUMER.equals(kind)) { + return "queue.process"; + } + return "queue"; + } + @SuppressWarnings("deprecation") private OtelSpanInfo descriptionForDbSystem(final @NotNull SpanData otelSpan) { final @NotNull Attributes attributes = otelSpan.getAttributes(); diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt index 9c5a1a352df..9b0298c1d93 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt @@ -11,6 +11,8 @@ import io.opentelemetry.semconv.HttpAttributes import io.opentelemetry.semconv.UrlAttributes import io.opentelemetry.semconv.incubating.DbIncubatingAttributes import io.opentelemetry.semconv.incubating.HttpIncubatingAttributes +import io.opentelemetry.semconv.incubating.MessagingIncubatingAttributes +import io.sentry.SentryOptions import io.sentry.protocol.TransactionNameSource import kotlin.test.Test import kotlin.test.assertEquals @@ -228,6 +230,140 @@ class SpanDescriptionExtractorTest { assertEquals(TransactionNameSource.TASK, info.transactionNameSource) } + @Test + fun `ignores messaging system when queue tracing disabled`() { + givenSpanName("my-topic publish") + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = false) + + assertEquals("my-topic publish", info.op) + assertEquals("my-topic publish", info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `maps messaging publish operation type to queue publish op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging process operation type to queue process op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "process", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.process", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `maps messaging receive operation type to queue receive op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "receive", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.receive", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `falls back to legacy messaging operation attribute`() { + @Suppress("DEPRECATION") + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "rabbitmq", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "queue-name", + MessagingIncubatingAttributes.MESSAGING_OPERATION to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("queue-name", info.description) + } + + @Test + fun `falls back to PRODUCER span kind when no operation attribute`() { + givenSpanKind(SpanKind.PRODUCER) + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-topic", info.description) + } + + @Test + fun `falls back to CONSUMER span kind when no operation attribute`() { + givenSpanKind(SpanKind.CONSUMER) + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.process", info.op) + assertEquals("my-topic", info.description) + } + + @Test + fun `falls back to span name as description when destination missing`() { + givenSpanName("my-topic publish") + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-topic publish", info.description) + } + @Test fun `uses span name as op and description if no relevant attributes`() { givenSpanName("span name") @@ -289,9 +425,10 @@ class SpanDescriptionExtractorTest { builder.put(key as AttributeKey, value) } - private fun whenExtractingSpanInfo(): OtelSpanInfo { + private fun whenExtractingSpanInfo(queueTracingEnabled: Boolean = false): OtelSpanInfo { fixture.setup() - return SpanDescriptionExtractor().extractSpanInfo(fixture.otelSpan, fixture.sentrySpan) + val options = SentryOptions().apply { isEnableQueueTracing = queueTracingEnabled } + return SpanDescriptionExtractor().extractSpanInfo(fixture.otelSpan, fixture.sentrySpan, options) } private fun givenParentContext(parentContext: SpanContext) { diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application-kafka.properties index e0abadf5f9c..21e96692c55 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application-kafka.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application-kafka.properties @@ -1,4 +1,14 @@ # Kafka — activate with: --spring.profiles.active=kafka + +# In OTel mode, `sentry.enable-queue-tracing=true` enables the OTel->Sentry +# messaging span remapping in `SpanDescriptionExtractor`/`SentrySpanExporter`: +# it maps OTel messaging spans to `queue.publish`/`queue.process` ops with the +# destination as description and transfers messaging attributes to root +# transactions so the Sentry Queues product lights up. Sentry's Spring Kafka +# auto-config (`SentryKafkaQueueConfiguration`) stays suppressed here because +# `sentry-opentelemetry-agentless-spring` pulls in the OTel customizer that +# its `@ConditionalOnMissingClass(...OpenTelemetry...)` guard looks for, so +# the flag does NOT wire the Sentry-native Kafka interceptors in this sample. sentry.enable-queue-tracing=true spring.kafka.bootstrap-servers=localhost:9092 diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application-kafka.properties index e0abadf5f9c..d9a98cb63c2 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application-kafka.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application-kafka.properties @@ -1,4 +1,13 @@ # Kafka — activate with: --spring.profiles.active=kafka + +# In OTel mode, `sentry.enable-queue-tracing=true` enables the OTel->Sentry +# messaging span remapping in `SpanDescriptionExtractor`/`SentrySpanExporter`: +# it maps OTel messaging spans to `queue.publish`/`queue.process` ops with the +# destination as description and transfers messaging attributes to root +# transactions so the Sentry Queues product lights up. Sentry's Spring Kafka +# auto-config (`SentryKafkaQueueConfiguration`) stays suppressed here because +# of its `@ConditionalOnMissingClass(...OpenTelemetry...)` guard, so the flag +# does NOT wire the Sentry-native Kafka interceptors in this sample. sentry.enable-queue-tracing=true spring.kafka.bootstrap-servers=localhost:9092 diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9e5f09320b5..e4611a46d44 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4398,9 +4398,11 @@ public abstract interface class io/sentry/SpanDataConvention { public static final field HTTP_STATUS_CODE_KEY Ljava/lang/String; public static final field MESSAGING_DESTINATION_NAME Ljava/lang/String; public static final field MESSAGING_MESSAGE_BODY_SIZE Ljava/lang/String; + public static final field MESSAGING_MESSAGE_ENVELOPE_SIZE Ljava/lang/String; public static final field MESSAGING_MESSAGE_ID Ljava/lang/String; public static final field MESSAGING_MESSAGE_RECEIVE_LATENCY Ljava/lang/String; public static final field MESSAGING_MESSAGE_RETRY_COUNT Ljava/lang/String; + 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 THREAD_ID Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/SpanDataConvention.java b/sentry/src/main/java/io/sentry/SpanDataConvention.java index 047a235422d..4ede74505cb 100644 --- a/sentry/src/main/java/io/sentry/SpanDataConvention.java +++ b/sentry/src/main/java/io/sentry/SpanDataConvention.java @@ -35,5 +35,7 @@ public interface SpanDataConvention { String MESSAGING_MESSAGE_ID = "messaging.message.id"; String MESSAGING_MESSAGE_RETRY_COUNT = "messaging.message.retry.count"; String MESSAGING_MESSAGE_BODY_SIZE = "messaging.message.body.size"; + String MESSAGING_MESSAGE_ENVELOPE_SIZE = "messaging.message.envelope.size"; String MESSAGING_MESSAGE_RECEIVE_LATENCY = "messaging.message.receive.latency"; + String MESSAGING_OPERATION_TYPE = "messaging.operation.type"; } From f85b2d70fc1ec269556465d97ef4d2d885990a66 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 27 Apr 2026 05:24:30 +0200 Subject: [PATCH 2/6] fix(otel): Prefer messaging over http mapping when queue tracing enabled Some OTel instrumentations (notably aws-sdk-2.2 SQS) attach both `http.request.method` and `messaging.system` to the same span. With the previous gate order, those spans resolved to http.client and the Sentry Queues product never lit up for one of the most common OTel-coexistence targets. When `enableQueueTracing` is true and `messaging.system` is present, map to a queue.* op before the http and db checks. When the flag is off, the existing http-first ordering is preserved. Co-Authored-By: Claude --- .../SpanDescriptionExtractor.java | 16 +++---- .../kotlin/SpanDescriptionExtractorTest.kt | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java index 7b413c8267f..0693a421b88 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java @@ -24,6 +24,14 @@ public final class SpanDescriptionExtractor { final @NotNull SentryOptions options) { final @NotNull Attributes attributes = otelSpan.getAttributes(); + if (options.isEnableQueueTracing()) { + final @Nullable String messagingSystem = + attributes.get(MessagingIncubatingAttributes.MESSAGING_SYSTEM); + if (messagingSystem != null) { + return descriptionForMessagingSystem(otelSpan); + } + } + final @Nullable String httpMethod = attributes.get(HttpAttributes.HTTP_REQUEST_METHOD); if (httpMethod != null) { return descriptionForHttpMethod(otelSpan, httpMethod); @@ -34,14 +42,6 @@ public final class SpanDescriptionExtractor { return descriptionForDbSystem(otelSpan); } - if (options.isEnableQueueTracing()) { - final @Nullable String messagingSystem = - attributes.get(MessagingIncubatingAttributes.MESSAGING_SYSTEM); - if (messagingSystem != null) { - return descriptionForMessagingSystem(otelSpan); - } - } - final @NotNull String name = otelSpan.getName(); final @Nullable String maybeDescription = sentrySpan != null ? sentrySpan.getDescription() : name; diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt index 9b0298c1d93..2b1f7891fbc 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt @@ -364,6 +364,48 @@ class SpanDescriptionExtractorTest { assertEquals("my-topic publish", info.description) } + @Test + fun `messaging mapping wins over http when both attributes present and queue tracing enabled`() { + // Some OTel instrumentations (e.g. aws-sdk-2.2 SQS) attach both messaging and http + // attributes to the same span. Messaging is more specific and must win. + givenSpanKind(SpanKind.PRODUCER) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "POST", + UrlAttributes.URL_FULL to "https://sqs.us-east-1.amazonaws.com/", + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "aws.sqs", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-queue", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.publish", info.op) + assertEquals("my-queue", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `http mapping wins over messaging when queue tracing disabled`() { + givenSpanKind(SpanKind.CLIENT) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "POST", + UrlAttributes.URL_FULL to "https://sqs.us-east-1.amazonaws.com/", + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "aws.sqs", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-queue", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "publish", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = false) + + assertEquals("http.client", info.op) + assertEquals("POST https://sqs.us-east-1.amazonaws.com/", info.description) + assertEquals(TransactionNameSource.URL, info.transactionNameSource) + } + @Test fun `uses span name as op and description if no relevant attributes`() { givenSpanName("span name") From 26f4229f227c7785e0b65da54e5f43c838f236a5 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 27 Apr 2026 06:18:17 +0200 Subject: [PATCH 3/6] fix(otel): Map messaging "create" to queue.create instead of queue.publish The OTel messaging semconv defines "create" and "publish" as distinct operations: "create" represents message construction, "publish" the network send. Folding both into queue.publish risks double-counting producer transactions on instrumentations that emit a separate create span (per OTel semconv guidance). Per the Sentry Queues telemetry spec (https://develop.sentry.dev/sdk/telemetry/traces/modules/queues/), queue.create is a canonical op distinct from queue.publish, so map "create" to its spec-correct destination rather than dropping it. Empirically, current Kafka OTel instrumentation does not emit a separate create span, so this is a no-op for Kafka users today; the change future-proofs other systems and any future Kafka OTel version. Co-Authored-By: Claude --- .../opentelemetry/SpanDescriptionExtractor.java | 3 ++- .../test/kotlin/SpanDescriptionExtractorTest.kt | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java index 0693a421b88..cd6e722d85a 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java @@ -128,8 +128,9 @@ private OtelSpanInfo descriptionForMessagingSystem(final @NotNull SpanData otelS switch (operationType) { case "publish": case "send": - case "create": return "queue.publish"; + case "create": + return "queue.create"; case "receive": return "queue.receive"; case "process": diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt index 2b1f7891fbc..8a2ec69245c 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt @@ -282,6 +282,23 @@ class SpanDescriptionExtractorTest { assertEquals(TransactionNameSource.TASK, info.transactionNameSource) } + @Test + fun `maps messaging create operation type to queue create op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "kafka", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-topic", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "create", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.create", info.op) + assertEquals("my-topic", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + @Test fun `maps messaging receive operation type to queue receive op`() { givenAttributes( From 6040247e2f1cdb03c90787438b3d7316735055fb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 27 Apr 2026 06:25:39 +0200 Subject: [PATCH 4/6] docs(options): Clarify enableQueueTracing covers native + OTel paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setEnableQueueTracing Javadoc said only "Whether queue operations (publish, process) should be traced." — silent on the fact that the flag also drives OTel messaging-span transformation when sentry-opentelemetry is on the classpath. Reword on both the getter and setter to make explicit that the flag both emits Sentry-native queue spans and transforms OTel messaging spans to match Sentry's queue conventions, so customers grepping their IDE see what the flag does in either integration mode. Co-Authored-By: Claude --- sentry/src/main/java/io/sentry/SentryOptions.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 819789678e5..7db109e9d2e 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -2708,18 +2708,20 @@ public void setEnableCacheTracing(boolean enableCacheTracing) { } /** - * Whether queue operations (publish, process) should be traced. + * Whether Sentry emits Queue spans and transforms OpenTelemetry messaging spans to match Sentry's + * queue conventions. * - * @return true if queue operations should be traced + * @return true if queue tracing is enabled */ public boolean isEnableQueueTracing() { return enableQueueTracing; } /** - * Whether queue operations (publish, process) should be traced. + * Whether Sentry emits Queue spans and transforms OpenTelemetry messaging spans to match Sentry's + * queue conventions. * - * @param enableQueueTracing true if queue operations should be traced + * @param enableQueueTracing true to enable queue tracing */ public void setEnableQueueTracing(boolean enableQueueTracing) { this.enableQueueTracing = enableQueueTracing; From b0f802ab24889dc46950fd3a5357d831eb432b9f Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 27 Apr 2026 09:47:03 +0200 Subject: [PATCH 5/6] fix(otel): Map messaging "settle" to queue.settle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OTel messaging semconv defines messaging.operation.type=settle for consumer ack/nack/reject spans (JMS, RabbitMQ, Pulsar acknowledge). The switch had no case for "settle", so settle spans on SpanKind.CONSUMER were falling through to the SpanKind fallback and becoming queue.process — duplicating the real process span — while on SpanKind.CLIENT they became the generic "queue" default. queue.settle is one of the canonical Queues telemetry ops per https://develop.sentry.dev/sdk/telemetry/traces/modules/queues/, so add the explicit mapping. Co-Authored-By: Claude --- .../opentelemetry/SpanDescriptionExtractor.java | 2 ++ .../test/kotlin/SpanDescriptionExtractorTest.kt | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java index cd6e722d85a..90db227505d 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SpanDescriptionExtractor.java @@ -136,6 +136,8 @@ private OtelSpanInfo descriptionForMessagingSystem(final @NotNull SpanData otelS case "process": case "deliver": return "queue.process"; + case "settle": + return "queue.settle"; default: // fall through to SpanKind mapping break; diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt index 8a2ec69245c..26c4ea408cd 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/SpanDescriptionExtractorTest.kt @@ -316,6 +316,23 @@ class SpanDescriptionExtractorTest { assertEquals(TransactionNameSource.TASK, info.transactionNameSource) } + @Test + fun `maps messaging settle operation type to queue settle op`() { + givenAttributes( + mapOf( + MessagingIncubatingAttributes.MESSAGING_SYSTEM to "rabbitmq", + MessagingIncubatingAttributes.MESSAGING_DESTINATION_NAME to "my-queue", + MessagingIncubatingAttributes.MESSAGING_OPERATION_TYPE to "settle", + ) + ) + + val info = whenExtractingSpanInfo(queueTracingEnabled = true) + + assertEquals("queue.settle", info.op) + assertEquals("my-queue", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + @Test fun `falls back to legacy messaging operation attribute`() { @Suppress("DEPRECATION") From cac8c1fcb738048ac434bce54bfdfa7cc203675e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 27 Apr 2026 09:55:11 +0200 Subject: [PATCH 6/6] chore(samples): Drop verbose comment above sentry.enable-queue-tracing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OTel Kafka sample properties carried a 10-line comment explaining the OTel->Sentry remapping mechanism and SentryKafkaQueueConfiguration suppression behavior. That belongs in the SDK docs, not in a sample config — drop it so the property line speaks for itself. Co-Authored-By: Claude --- .../src/main/resources/application-kafka.properties | 10 ---------- .../src/main/resources/application-kafka.properties | 9 --------- 2 files changed, 19 deletions(-) diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application-kafka.properties index 21e96692c55..e0abadf5f9c 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application-kafka.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/src/main/resources/application-kafka.properties @@ -1,14 +1,4 @@ # Kafka — activate with: --spring.profiles.active=kafka - -# In OTel mode, `sentry.enable-queue-tracing=true` enables the OTel->Sentry -# messaging span remapping in `SpanDescriptionExtractor`/`SentrySpanExporter`: -# it maps OTel messaging spans to `queue.publish`/`queue.process` ops with the -# destination as description and transfers messaging attributes to root -# transactions so the Sentry Queues product lights up. Sentry's Spring Kafka -# auto-config (`SentryKafkaQueueConfiguration`) stays suppressed here because -# `sentry-opentelemetry-agentless-spring` pulls in the OTel customizer that -# its `@ConditionalOnMissingClass(...OpenTelemetry...)` guard looks for, so -# the flag does NOT wire the Sentry-native Kafka interceptors in this sample. sentry.enable-queue-tracing=true spring.kafka.bootstrap-servers=localhost:9092 diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application-kafka.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application-kafka.properties index d9a98cb63c2..e0abadf5f9c 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application-kafka.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application-kafka.properties @@ -1,13 +1,4 @@ # Kafka — activate with: --spring.profiles.active=kafka - -# In OTel mode, `sentry.enable-queue-tracing=true` enables the OTel->Sentry -# messaging span remapping in `SpanDescriptionExtractor`/`SentrySpanExporter`: -# it maps OTel messaging spans to `queue.publish`/`queue.process` ops with the -# destination as description and transfers messaging attributes to root -# transactions so the Sentry Queues product lights up. Sentry's Spring Kafka -# auto-config (`SentryKafkaQueueConfiguration`) stays suppressed here because -# of its `@ConditionalOnMissingClass(...OpenTelemetry...)` guard, so the flag -# does NOT wire the Sentry-native Kafka interceptors in this sample. sentry.enable-queue-tracing=true spring.kafka.bootstrap-servers=localhost:9092