From be5af44f2e2959f5f73c8a9b2215a2c4f1d408aa Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 1 Apr 2026 15:36:59 +0200 Subject: [PATCH 1/4] feat(spring-jakarta): Add Kafka producer instrumentation Add SentryKafkaProducerWrapper that overrides doSend to create queue.publish spans for all KafkaTemplate send operations. Injects sentry-trace, baggage, and sentry-task-enqueued-time headers for distributed tracing and receive latency calculation. Add SentryKafkaProducerBeanPostProcessor to automatically wrap KafkaTemplate beans. Co-Authored-By: Claude --- .../api/sentry-spring-jakarta.api | 10 ++ sentry-spring-jakarta/build.gradle.kts | 2 + .../SentryKafkaProducerBeanPostProcessor.java | 32 ++++ .../kafka/SentryKafkaProducerWrapper.java | 120 +++++++++++++++ ...entryKafkaProducerBeanPostProcessorTest.kt | 56 +++++++ .../kafka/SentryKafkaProducerWrapperTest.kt | 137 ++++++++++++++++++ 6 files changed, 357 insertions(+) create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapper.java create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessorTest.kt create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapperTest.kt diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index fe634da6f4c..bc95af08593 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -244,6 +244,16 @@ public final class io/sentry/spring/jakarta/graphql/SentrySpringSubscriptionHand public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IScopes;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; } +public final class io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/core/PriorityOrdered { + public fun ()V + public fun getOrder ()I + public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; +} + +public final class io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapper : org/springframework/kafka/core/KafkaTemplate { + public fun (Lorg/springframework/kafka/core/KafkaTemplate;Lio/sentry/IScopes;)V +} + public class io/sentry/spring/jakarta/opentelemetry/SentryOpenTelemetryAgentWithoutAutoInitConfiguration { public fun ()V public fun sentryOpenTelemetryOptionsConfiguration ()Lio/sentry/Sentry$OptionsConfiguration; diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index f1920e24510..93367d803f5 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { compileOnly(libs.servlet.jakarta.api) compileOnly(libs.slf4j.api) compileOnly(libs.springboot3.starter.graphql) + compileOnly(libs.spring.kafka3) compileOnly(libs.springboot3.starter.quartz) compileOnly(Config.Libs.springWebflux) @@ -68,6 +69,7 @@ dependencies { testImplementation(libs.springboot3.starter.aop) testImplementation(libs.springboot3.starter.graphql) testImplementation(libs.springboot3.starter.security) + testImplementation(libs.spring.kafka3) testImplementation(libs.springboot3.starter.test) testImplementation(libs.springboot3.starter.web) testImplementation(libs.springboot3.starter.webflux) diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor.java new file mode 100644 index 00000000000..674c191804d --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor.java @@ -0,0 +1,32 @@ +package io.sentry.spring.jakarta.kafka; + +import io.sentry.ScopesAdapter; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; +import org.springframework.kafka.core.KafkaTemplate; + +/** Wraps {@link KafkaTemplate} beans in {@link SentryKafkaProducerWrapper} for instrumentation. */ +@ApiStatus.Internal +public final class SentryKafkaProducerBeanPostProcessor + implements BeanPostProcessor, PriorityOrdered { + + @Override + @SuppressWarnings("unchecked") + public @NotNull Object postProcessAfterInitialization( + final @NotNull Object bean, final @NotNull String beanName) throws BeansException { + if (bean instanceof KafkaTemplate && !(bean instanceof SentryKafkaProducerWrapper)) { + return new SentryKafkaProducerWrapper<>( + (KafkaTemplate) bean, ScopesAdapter.getInstance()); + } + return bean; + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapper.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapper.java new file mode 100644 index 00000000000..3962ccefd57 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapper.java @@ -0,0 +1,120 @@ +package io.sentry.spring.jakarta.kafka; + +import io.micrometer.observation.Observation; +import io.sentry.BaggageHeader; +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanDataConvention; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import io.sentry.util.TracingUtils; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.header.Headers; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; + +/** + * Wraps a {@link KafkaTemplate} to create {@code queue.publish} spans for Kafka send operations. + * + *

Overrides {@code doSend} which is the common path for all send variants in {@link + * KafkaTemplate}. + */ +@ApiStatus.Internal +public final class SentryKafkaProducerWrapper extends KafkaTemplate { + + static final String TRACE_ORIGIN = "auto.queue.spring_jakarta.kafka.producer"; + static final String SENTRY_ENQUEUED_TIME_HEADER = "sentry-task-enqueued-time"; + + private final @NotNull IScopes scopes; + + public SentryKafkaProducerWrapper( + final @NotNull KafkaTemplate delegate, final @NotNull IScopes scopes) { + super(delegate.getProducerFactory()); + this.scopes = scopes; + this.setDefaultTopic(delegate.getDefaultTopic()); + if (delegate.isTransactional()) { + this.setTransactionIdPrefix(delegate.getTransactionIdPrefix()); + } + this.setMessageConverter(delegate.getMessageConverter()); + this.setMicrometerTagsProvider(delegate.getMicrometerTagsProvider()); + } + + @Override + protected @NotNull CompletableFuture> doSend( + final @NotNull ProducerRecord record, final @Nullable Observation observation) { + if (!scopes.getOptions().isEnableQueueTracing()) { + return super.doSend(record, observation); + } + + final @Nullable ISpan activeSpan = scopes.getSpan(); + if (activeSpan == null || activeSpan.isNoOp()) { + return super.doSend(record, observation); + } + + final @NotNull SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(TRACE_ORIGIN); + final @NotNull ISpan span = activeSpan.startChild("queue.publish", record.topic(), spanOptions); + if (span.isNoOp()) { + return super.doSend(record, observation); + } + + span.setData(SpanDataConvention.MESSAGING_SYSTEM, "kafka"); + span.setData(SpanDataConvention.MESSAGING_DESTINATION_NAME, record.topic()); + + try { + injectHeaders(record.headers(), span); + } catch (Throwable ignored) { + // Header injection must not break the send + } + + final @NotNull CompletableFuture> future; + try { + future = super.doSend(record, observation); + return future.whenComplete( + (result, throwable) -> { + if (throwable != null) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(throwable); + } else { + span.setStatus(SpanStatus.OK); + } + span.finish(); + }); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + span.finish(); + throw e; + } + } + + private void injectHeaders(final @NotNull Headers headers, final @NotNull ISpan span) { + final @Nullable TracingUtils.TracingHeaders tracingHeaders = + TracingUtils.trace(scopes, null, span); + if (tracingHeaders != null) { + final @NotNull SentryTraceHeader sentryTraceHeader = tracingHeaders.getSentryTraceHeader(); + headers.remove(sentryTraceHeader.getName()); + headers.add( + sentryTraceHeader.getName(), + sentryTraceHeader.getValue().getBytes(StandardCharsets.UTF_8)); + + final @Nullable BaggageHeader baggageHeader = tracingHeaders.getBaggageHeader(); + if (baggageHeader != null) { + headers.remove(baggageHeader.getName()); + headers.add( + baggageHeader.getName(), baggageHeader.getValue().getBytes(StandardCharsets.UTF_8)); + } + } + + headers.remove(SENTRY_ENQUEUED_TIME_HEADER); + headers.add( + SENTRY_ENQUEUED_TIME_HEADER, + String.valueOf(System.currentTimeMillis()).getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessorTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessorTest.kt new file mode 100644 index 00000000000..289e941e2af --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessorTest.kt @@ -0,0 +1,56 @@ +package io.sentry.spring.jakarta.kafka + +import io.sentry.IScopes +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory + +class SentryKafkaProducerBeanPostProcessorTest { + + @Test + fun `wraps KafkaTemplate beans in SentryKafkaProducerWrapper`() { + val producerFactory = mock>() + val kafkaTemplate = mock>() + whenever(kafkaTemplate.producerFactory).thenReturn(producerFactory) + whenever(kafkaTemplate.defaultTopic).thenReturn("") + whenever(kafkaTemplate.messageConverter).thenReturn(mock()) + whenever(kafkaTemplate.micrometerTagsProvider).thenReturn(null) + + val processor = SentryKafkaProducerBeanPostProcessor() + val result = processor.postProcessAfterInitialization(kafkaTemplate, "kafkaTemplate") + + assertTrue(result is SentryKafkaProducerWrapper<*, *>) + } + + @Test + fun `does not double-wrap SentryKafkaProducerWrapper`() { + val producerFactory = mock>() + val kafkaTemplate = mock>() + whenever(kafkaTemplate.producerFactory).thenReturn(producerFactory) + whenever(kafkaTemplate.defaultTopic).thenReturn("") + whenever(kafkaTemplate.messageConverter).thenReturn(mock()) + whenever(kafkaTemplate.micrometerTagsProvider).thenReturn(null) + + val scopes = mock() + val alreadyWrapped = SentryKafkaProducerWrapper(kafkaTemplate, scopes) + val processor = SentryKafkaProducerBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(alreadyWrapped, "kafkaTemplate") + + assertSame(alreadyWrapped, result) + } + + @Test + fun `does not wrap non-KafkaTemplate beans`() { + val someBean = "not a kafka template" + val processor = SentryKafkaProducerBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(someBean, "someBean") + + assertSame(someBean, result) + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapperTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapperTest.kt new file mode 100644 index 00000000000..918817d7429 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapperTest.kt @@ -0,0 +1,137 @@ +package io.sentry.spring.jakarta.kafka + +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import java.nio.charset.StandardCharsets +import java.util.concurrent.CompletableFuture +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.apache.kafka.clients.producer.ProducerRecord +import org.apache.kafka.common.header.internals.RecordHeaders +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.support.SendResult + +class SentryKafkaProducerWrapperTest { + + private lateinit var scopes: IScopes + private lateinit var options: SentryOptions + private lateinit var delegate: KafkaTemplate + private lateinit var producerFactory: ProducerFactory + + @BeforeTest + fun setup() { + scopes = mock() + producerFactory = mock() + delegate = mock() + options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + isEnableQueueTracing = true + } + whenever(scopes.options).thenReturn(options) + whenever(delegate.producerFactory).thenReturn(producerFactory) + whenever(delegate.defaultTopic).thenReturn("") + whenever(delegate.messageConverter).thenReturn(mock()) + whenever(delegate.micrometerTagsProvider).thenReturn(null) + } + + private fun createTransaction(): SentryTracer { + val tx = SentryTracer(TransactionContext("tx", "op"), scopes) + whenever(scopes.span).thenReturn(tx) + return tx + } + + private fun createWrapper(): SentryKafkaProducerWrapper { + return SentryKafkaProducerWrapper(delegate, scopes) + } + + @Test + fun `creates queue publish span with correct op and data`() { + val tx = createTransaction() + val wrapper = createWrapper() + val record = ProducerRecord("my-topic", "key", "value") + val future = CompletableFuture>() + + // doSend is protected, so we test through the public send(ProducerRecord) API + // We need to mock at the producer factory level since we're extending KafkaTemplate + // Instead, let's verify span creation by checking the transaction's children + // The wrapper calls super.doSend which needs a real producer — let's test the span lifecycle + + // For unit testing, we verify the span was started and data was set + // by checking the transaction after the wrapper processes + // Since doSend calls the real Kafka producer, we need to test at integration level + // or verify the span behavior through the transaction + + assertEquals(0, tx.spans.size) // no spans yet before send + } + + @Test + fun `does not create span when queue tracing is disabled`() { + val tx = createTransaction() + options.isEnableQueueTracing = false + val wrapper = createWrapper() + + assertEquals(0, tx.spans.size) + } + + @Test + fun `does not create span when no active span`() { + whenever(scopes.span).thenReturn(null) + val wrapper = createWrapper() + + // No exception thrown, wrapper created successfully + assertNotNull(wrapper) + } + + @Test + fun `injects sentry-trace, baggage, and enqueued-time headers`() { + val tx = createTransaction() + val wrapper = createWrapper() + val headers = RecordHeaders() + val record = ProducerRecord("my-topic", null, "key", "value", headers) + + // We can test header injection by invoking the wrapper and checking headers + // Since doSend needs a real producer, let's use reflection to test injectHeaders + val method = + SentryKafkaProducerWrapper::class + .java + .getDeclaredMethod( + "injectHeaders", + org.apache.kafka.common.header.Headers::class.java, + io.sentry.ISpan::class.java, + ) + method.isAccessible = true + + val spanOptions = io.sentry.SpanOptions() + spanOptions.origin = SentryKafkaProducerWrapper.TRACE_ORIGIN + val span = tx.startChild("queue.publish", "my-topic", spanOptions) + + method.invoke(wrapper, headers, span) + + val sentryTraceHeader = headers.lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER) + assertNotNull(sentryTraceHeader, "sentry-trace header should be injected") + + val enqueuedTimeHeader = + headers.lastHeader(SentryKafkaProducerWrapper.SENTRY_ENQUEUED_TIME_HEADER) + assertNotNull(enqueuedTimeHeader, "sentry-task-enqueued-time header should be injected") + val enqueuedTime = String(enqueuedTimeHeader.value(), StandardCharsets.UTF_8).toLong() + assertTrue(enqueuedTime > 0, "enqueued time should be a positive epoch millis value") + } + + @Test + fun `trace origin is set correctly`() { + assertEquals( + "auto.queue.spring_jakarta.kafka.producer", + SentryKafkaProducerWrapper.TRACE_ORIGIN, + ) + } +} From 5049ffcce6cc381caf254ef0fa15a473e7a60ca2 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 1 Apr 2026 15:59:39 +0200 Subject: [PATCH 2/4] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f9b4c06ca..4fc8eb10d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Add Kafka producer instrumentation for Spring Boot 3 ([#5254](https://github.com/getsentry/sentry-java/pull/5254)) - Add `enableQueueTracing` option and messaging span data conventions ([#5250](https://github.com/getsentry/sentry-java/pull/5250)) - Prevent cross-organization trace continuation ([#5136](https://github.com/getsentry/sentry-java/pull/5136)) - By default, the SDK now extracts the organization ID from the DSN (e.g. `o123.ingest.sentry.io`) and compares it with the `sentry-org_id` value in incoming baggage headers. When the two differ, the SDK starts a fresh trace instead of continuing the foreign one. This guards against accidentally linking traces across organizations. From 915e42b16c26142ec3240716c33d2ffecb925554 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 9 Apr 2026 13:14:03 +0200 Subject: [PATCH 3/4] ref(spring-jakarta): Replace SentryKafkaProducerWrapper with SentryProducerInterceptor Replace the KafkaTemplate subclass approach with a Kafka-native ProducerInterceptor. The BeanPostProcessor now sets the interceptor on the existing KafkaTemplate instead of replacing the bean, which preserves any custom configuration on the template. Existing customer interceptors are composed using Spring's CompositeProducerInterceptor. If reflection fails to read the existing interceptor, a warning is logged. Co-Authored-By: Claude --- .../api/sentry-spring-jakarta.api | 8 +- .../SentryKafkaProducerBeanPostProcessor.java | 58 +++++++- ...er.java => SentryProducerInterceptor.java} | 73 ++++------ ...entryKafkaProducerBeanPostProcessorTest.kt | 72 +++++---- .../kafka/SentryKafkaProducerWrapperTest.kt | 137 ------------------ .../kafka/SentryProducerInterceptorTest.kt | 133 +++++++++++++++++ 6 files changed, 272 insertions(+), 209 deletions(-) rename sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/{SentryKafkaProducerWrapper.java => SentryProducerInterceptor.java} (58%) delete mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapperTest.kt create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryProducerInterceptorTest.kt diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index bc95af08593..696d63c756e 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -250,8 +250,12 @@ public final class io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostPro public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; } -public final class io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapper : org/springframework/kafka/core/KafkaTemplate { - public fun (Lorg/springframework/kafka/core/KafkaTemplate;Lio/sentry/IScopes;)V +public final class io/sentry/spring/jakarta/kafka/SentryProducerInterceptor : org/apache/kafka/clients/producer/ProducerInterceptor { + public fun (Lio/sentry/IScopes;)V + public fun close ()V + public fun configure (Ljava/util/Map;)V + public fun onAcknowledgement (Lorg/apache/kafka/clients/producer/RecordMetadata;Ljava/lang/Exception;)V + public fun onSend (Lorg/apache/kafka/clients/producer/ProducerRecord;)Lorg/apache/kafka/clients/producer/ProducerRecord; } public class io/sentry/spring/jakarta/opentelemetry/SentryOpenTelemetryAgentWithoutAutoInitConfiguration { diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor.java index 674c191804d..6ede82add77 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessor.java @@ -1,15 +1,28 @@ package io.sentry.spring.jakarta.kafka; import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import java.lang.reflect.Field; +import org.apache.kafka.clients.producer.ProducerInterceptor; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.springframework.beans.BeansException; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.core.Ordered; import org.springframework.core.PriorityOrdered; import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.CompositeProducerInterceptor; -/** Wraps {@link KafkaTemplate} beans in {@link SentryKafkaProducerWrapper} for instrumentation. */ +/** + * Sets a {@link SentryProducerInterceptor} on {@link KafkaTemplate} beans via {@link + * KafkaTemplate#setProducerInterceptor(ProducerInterceptor)}. The original bean is not replaced. + * + *

If the template already has a {@link ProducerInterceptor}, both are composed using {@link + * CompositeProducerInterceptor}. Reading the existing interceptor requires reflection (no public + * getter in Spring Kafka 3.x); if reflection fails, a warning is logged and only the Sentry + * interceptor is set. + */ @ApiStatus.Internal public final class SentryKafkaProducerBeanPostProcessor implements BeanPostProcessor, PriorityOrdered { @@ -18,13 +31,50 @@ public final class SentryKafkaProducerBeanPostProcessor @SuppressWarnings("unchecked") public @NotNull Object postProcessAfterInitialization( final @NotNull Object bean, final @NotNull String beanName) throws BeansException { - if (bean instanceof KafkaTemplate && !(bean instanceof SentryKafkaProducerWrapper)) { - return new SentryKafkaProducerWrapper<>( - (KafkaTemplate) bean, ScopesAdapter.getInstance()); + if (bean instanceof KafkaTemplate) { + final @NotNull KafkaTemplate template = (KafkaTemplate) bean; + final @Nullable ProducerInterceptor existing = getExistingInterceptor(template); + + if (existing instanceof SentryProducerInterceptor) { + return bean; + } + + @SuppressWarnings("rawtypes") + final SentryProducerInterceptor sentryInterceptor = + new SentryProducerInterceptor<>(ScopesAdapter.getInstance()); + + if (existing != null) { + @SuppressWarnings("rawtypes") + final CompositeProducerInterceptor composite = + new CompositeProducerInterceptor(sentryInterceptor, existing); + template.setProducerInterceptor(composite); + } else { + template.setProducerInterceptor(sentryInterceptor); + } } return bean; } + @SuppressWarnings("unchecked") + private @Nullable ProducerInterceptor getExistingInterceptor( + final @NotNull KafkaTemplate template) { + try { + final @NotNull Field field = KafkaTemplate.class.getDeclaredField("producerInterceptor"); + field.setAccessible(true); + return (ProducerInterceptor) field.get(template); + } catch (NoSuchFieldException | IllegalAccessException e) { + ScopesAdapter.getInstance() + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Unable to read existing producerInterceptor from KafkaTemplate via reflection. " + + "If you had a custom ProducerInterceptor, it may be overwritten by Sentry's interceptor.", + e); + return null; + } + } + @Override public int getOrder() { return Ordered.LOWEST_PRECEDENCE; diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapper.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryProducerInterceptor.java similarity index 58% rename from sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapper.java rename to sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryProducerInterceptor.java index 3962ccefd57..916fcceb26a 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapper.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/kafka/SentryProducerInterceptor.java @@ -1,6 +1,5 @@ package io.sentry.spring.jakarta.kafka; -import io.micrometer.observation.Observation; import io.sentry.BaggageHeader; import io.sentry.IScopes; import io.sentry.ISpan; @@ -10,58 +9,55 @@ import io.sentry.SpanStatus; import io.sentry.util.TracingUtils; import java.nio.charset.StandardCharsets; -import java.util.concurrent.CompletableFuture; +import java.util.Map; +import org.apache.kafka.clients.producer.ProducerInterceptor; import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; import org.apache.kafka.common.header.Headers; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.kafka.support.SendResult; /** - * Wraps a {@link KafkaTemplate} to create {@code queue.publish} spans for Kafka send operations. + * A Kafka {@link ProducerInterceptor} that creates {@code queue.publish} spans and injects tracing + * headers into outgoing records. * - *

Overrides {@code doSend} which is the common path for all send variants in {@link - * KafkaTemplate}. + *

The span starts and finishes synchronously in {@link #onSend(ProducerRecord)}, representing + * "message enqueued" semantics. This avoids cross-thread correlation complexity since {@link + * #onAcknowledgement(RecordMetadata, Exception)} runs on the Kafka I/O thread. + * + *

If the customer already has a {@link ProducerInterceptor}, the {@link + * SentryKafkaProducerBeanPostProcessor} composes both using Spring's {@link + * org.springframework.kafka.support.CompositeProducerInterceptor}. */ @ApiStatus.Internal -public final class SentryKafkaProducerWrapper extends KafkaTemplate { +public final class SentryProducerInterceptor implements ProducerInterceptor { static final String TRACE_ORIGIN = "auto.queue.spring_jakarta.kafka.producer"; static final String SENTRY_ENQUEUED_TIME_HEADER = "sentry-task-enqueued-time"; private final @NotNull IScopes scopes; - public SentryKafkaProducerWrapper( - final @NotNull KafkaTemplate delegate, final @NotNull IScopes scopes) { - super(delegate.getProducerFactory()); + public SentryProducerInterceptor(final @NotNull IScopes scopes) { this.scopes = scopes; - this.setDefaultTopic(delegate.getDefaultTopic()); - if (delegate.isTransactional()) { - this.setTransactionIdPrefix(delegate.getTransactionIdPrefix()); - } - this.setMessageConverter(delegate.getMessageConverter()); - this.setMicrometerTagsProvider(delegate.getMicrometerTagsProvider()); } @Override - protected @NotNull CompletableFuture> doSend( - final @NotNull ProducerRecord record, final @Nullable Observation observation) { + public @NotNull ProducerRecord onSend(final @NotNull ProducerRecord record) { if (!scopes.getOptions().isEnableQueueTracing()) { - return super.doSend(record, observation); + return record; } final @Nullable ISpan activeSpan = scopes.getSpan(); if (activeSpan == null || activeSpan.isNoOp()) { - return super.doSend(record, observation); + return record; } final @NotNull SpanOptions spanOptions = new SpanOptions(); spanOptions.setOrigin(TRACE_ORIGIN); final @NotNull ISpan span = activeSpan.startChild("queue.publish", record.topic(), spanOptions); if (span.isNoOp()) { - return super.doSend(record, observation); + return record; } span.setData(SpanDataConvention.MESSAGING_SYSTEM, "kafka"); @@ -73,27 +69,22 @@ public SentryKafkaProducerWrapper( // Header injection must not break the send } - final @NotNull CompletableFuture> future; - try { - future = super.doSend(record, observation); - return future.whenComplete( - (result, throwable) -> { - if (throwable != null) { - span.setStatus(SpanStatus.INTERNAL_ERROR); - span.setThrowable(throwable); - } else { - span.setStatus(SpanStatus.OK); - } - span.finish(); - }); - } catch (Throwable e) { - span.setStatus(SpanStatus.INTERNAL_ERROR); - span.setThrowable(e); - span.finish(); - throw e; - } + span.setStatus(SpanStatus.OK); + span.finish(); + + return record; } + @Override + public void onAcknowledgement( + final @Nullable RecordMetadata metadata, final @Nullable Exception exception) {} + + @Override + public void close() {} + + @Override + public void configure(final @Nullable Map configs) {} + private void injectHeaders(final @NotNull Headers headers, final @NotNull ISpan span) { final @Nullable TracingUtils.TracingHeaders tracingHeaders = TracingUtils.trace(scopes, null, span); diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessorTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessorTest.kt index 289e941e2af..25e1d3348e8 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessorTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerBeanPostProcessorTest.kt @@ -1,51 +1,48 @@ package io.sentry.spring.jakarta.kafka -import io.sentry.IScopes import kotlin.test.Test import kotlin.test.assertSame import kotlin.test.assertTrue +import org.apache.kafka.clients.producer.ProducerInterceptor import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.ProducerFactory +import org.springframework.kafka.support.CompositeProducerInterceptor class SentryKafkaProducerBeanPostProcessorTest { - @Test - fun `wraps KafkaTemplate beans in SentryKafkaProducerWrapper`() { - val producerFactory = mock>() - val kafkaTemplate = mock>() - whenever(kafkaTemplate.producerFactory).thenReturn(producerFactory) - whenever(kafkaTemplate.defaultTopic).thenReturn("") - whenever(kafkaTemplate.messageConverter).thenReturn(mock()) - whenever(kafkaTemplate.micrometerTagsProvider).thenReturn(null) + private fun readInterceptor(template: KafkaTemplate<*, *>): Any? { + val field = KafkaTemplate::class.java.getDeclaredField("producerInterceptor") + field.isAccessible = true + return field.get(template) + } + @Test + fun `sets SentryProducerInterceptor on KafkaTemplate`() { + val template = KafkaTemplate(mock>()) val processor = SentryKafkaProducerBeanPostProcessor() - val result = processor.postProcessAfterInitialization(kafkaTemplate, "kafkaTemplate") - assertTrue(result is SentryKafkaProducerWrapper<*, *>) + processor.postProcessAfterInitialization(template, "kafkaTemplate") + + assertTrue(readInterceptor(template) is SentryProducerInterceptor<*, *>) } @Test - fun `does not double-wrap SentryKafkaProducerWrapper`() { - val producerFactory = mock>() - val kafkaTemplate = mock>() - whenever(kafkaTemplate.producerFactory).thenReturn(producerFactory) - whenever(kafkaTemplate.defaultTopic).thenReturn("") - whenever(kafkaTemplate.messageConverter).thenReturn(mock()) - whenever(kafkaTemplate.micrometerTagsProvider).thenReturn(null) - - val scopes = mock() - val alreadyWrapped = SentryKafkaProducerWrapper(kafkaTemplate, scopes) + fun `does not double-wrap when SentryProducerInterceptor already set`() { + val template = KafkaTemplate(mock>()) val processor = SentryKafkaProducerBeanPostProcessor() - val result = processor.postProcessAfterInitialization(alreadyWrapped, "kafkaTemplate") + processor.postProcessAfterInitialization(template, "kafkaTemplate") + val firstInterceptor = readInterceptor(template) + + processor.postProcessAfterInitialization(template, "kafkaTemplate") + val secondInterceptor = readInterceptor(template) - assertSame(alreadyWrapped, result) + assertSame(firstInterceptor, secondInterceptor) } @Test - fun `does not wrap non-KafkaTemplate beans`() { + fun `does not modify non-KafkaTemplate beans`() { val someBean = "not a kafka template" val processor = SentryKafkaProducerBeanPostProcessor() @@ -53,4 +50,29 @@ class SentryKafkaProducerBeanPostProcessorTest { assertSame(someBean, result) } + + @Test + fun `returns the same bean instance`() { + val template = KafkaTemplate(mock>()) + val processor = SentryKafkaProducerBeanPostProcessor() + + val result = processor.postProcessAfterInitialization(template, "kafkaTemplate") + + assertSame(template, result, "BPP should return the same bean, not a replacement") + } + + @Test + fun `composes with existing customer interceptor using CompositeProducerInterceptor`() { + val template = KafkaTemplate(mock>()) + val customerInterceptor = mock>() + template.setProducerInterceptor(customerInterceptor) + + val processor = SentryKafkaProducerBeanPostProcessor() + processor.postProcessAfterInitialization(template, "kafkaTemplate") + + assertTrue( + readInterceptor(template) is CompositeProducerInterceptor<*, *>, + "Should use CompositeProducerInterceptor when existing interceptor is present", + ) + } } diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapperTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapperTest.kt deleted file mode 100644 index 918817d7429..00000000000 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryKafkaProducerWrapperTest.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.sentry.spring.jakarta.kafka - -import io.sentry.IScopes -import io.sentry.SentryOptions -import io.sentry.SentryTraceHeader -import io.sentry.SentryTracer -import io.sentry.TransactionContext -import java.nio.charset.StandardCharsets -import java.util.concurrent.CompletableFuture -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import org.apache.kafka.clients.producer.ProducerRecord -import org.apache.kafka.common.header.internals.RecordHeaders -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever -import org.springframework.kafka.core.KafkaTemplate -import org.springframework.kafka.core.ProducerFactory -import org.springframework.kafka.support.SendResult - -class SentryKafkaProducerWrapperTest { - - private lateinit var scopes: IScopes - private lateinit var options: SentryOptions - private lateinit var delegate: KafkaTemplate - private lateinit var producerFactory: ProducerFactory - - @BeforeTest - fun setup() { - scopes = mock() - producerFactory = mock() - delegate = mock() - options = - SentryOptions().apply { - dsn = "https://key@sentry.io/proj" - isEnableQueueTracing = true - } - whenever(scopes.options).thenReturn(options) - whenever(delegate.producerFactory).thenReturn(producerFactory) - whenever(delegate.defaultTopic).thenReturn("") - whenever(delegate.messageConverter).thenReturn(mock()) - whenever(delegate.micrometerTagsProvider).thenReturn(null) - } - - private fun createTransaction(): SentryTracer { - val tx = SentryTracer(TransactionContext("tx", "op"), scopes) - whenever(scopes.span).thenReturn(tx) - return tx - } - - private fun createWrapper(): SentryKafkaProducerWrapper { - return SentryKafkaProducerWrapper(delegate, scopes) - } - - @Test - fun `creates queue publish span with correct op and data`() { - val tx = createTransaction() - val wrapper = createWrapper() - val record = ProducerRecord("my-topic", "key", "value") - val future = CompletableFuture>() - - // doSend is protected, so we test through the public send(ProducerRecord) API - // We need to mock at the producer factory level since we're extending KafkaTemplate - // Instead, let's verify span creation by checking the transaction's children - // The wrapper calls super.doSend which needs a real producer — let's test the span lifecycle - - // For unit testing, we verify the span was started and data was set - // by checking the transaction after the wrapper processes - // Since doSend calls the real Kafka producer, we need to test at integration level - // or verify the span behavior through the transaction - - assertEquals(0, tx.spans.size) // no spans yet before send - } - - @Test - fun `does not create span when queue tracing is disabled`() { - val tx = createTransaction() - options.isEnableQueueTracing = false - val wrapper = createWrapper() - - assertEquals(0, tx.spans.size) - } - - @Test - fun `does not create span when no active span`() { - whenever(scopes.span).thenReturn(null) - val wrapper = createWrapper() - - // No exception thrown, wrapper created successfully - assertNotNull(wrapper) - } - - @Test - fun `injects sentry-trace, baggage, and enqueued-time headers`() { - val tx = createTransaction() - val wrapper = createWrapper() - val headers = RecordHeaders() - val record = ProducerRecord("my-topic", null, "key", "value", headers) - - // We can test header injection by invoking the wrapper and checking headers - // Since doSend needs a real producer, let's use reflection to test injectHeaders - val method = - SentryKafkaProducerWrapper::class - .java - .getDeclaredMethod( - "injectHeaders", - org.apache.kafka.common.header.Headers::class.java, - io.sentry.ISpan::class.java, - ) - method.isAccessible = true - - val spanOptions = io.sentry.SpanOptions() - spanOptions.origin = SentryKafkaProducerWrapper.TRACE_ORIGIN - val span = tx.startChild("queue.publish", "my-topic", spanOptions) - - method.invoke(wrapper, headers, span) - - val sentryTraceHeader = headers.lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER) - assertNotNull(sentryTraceHeader, "sentry-trace header should be injected") - - val enqueuedTimeHeader = - headers.lastHeader(SentryKafkaProducerWrapper.SENTRY_ENQUEUED_TIME_HEADER) - assertNotNull(enqueuedTimeHeader, "sentry-task-enqueued-time header should be injected") - val enqueuedTime = String(enqueuedTimeHeader.value(), StandardCharsets.UTF_8).toLong() - assertTrue(enqueuedTime > 0, "enqueued time should be a positive epoch millis value") - } - - @Test - fun `trace origin is set correctly`() { - assertEquals( - "auto.queue.spring_jakarta.kafka.producer", - SentryKafkaProducerWrapper.TRACE_ORIGIN, - ) - } -} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryProducerInterceptorTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryProducerInterceptorTest.kt new file mode 100644 index 00000000000..fc74371873d --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryProducerInterceptorTest.kt @@ -0,0 +1,133 @@ +package io.sentry.spring.jakarta.kafka + +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.TransactionContext +import java.nio.charset.StandardCharsets +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.apache.kafka.clients.producer.ProducerRecord +import org.apache.kafka.clients.producer.RecordMetadata +import org.apache.kafka.common.TopicPartition +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class SentryProducerInterceptorTest { + + private lateinit var scopes: IScopes + private lateinit var options: SentryOptions + + @BeforeTest + fun setup() { + scopes = mock() + options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + isEnableQueueTracing = true + } + whenever(scopes.options).thenReturn(options) + } + + private fun createTransaction(): SentryTracer { + val tx = SentryTracer(TransactionContext("tx", "op"), scopes) + whenever(scopes.span).thenReturn(tx) + return tx + } + + @Test + fun `creates queue publish span with correct op and data`() { + val tx = createTransaction() + val interceptor = SentryProducerInterceptor(scopes) + val record = ProducerRecord("my-topic", "key", "value") + + interceptor.onSend(record) + + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("queue.publish", span.operation) + assertEquals("my-topic", span.description) + assertEquals("kafka", span.data["messaging.system"]) + assertEquals("my-topic", span.data["messaging.destination.name"]) + assertTrue(span.isFinished) + } + + @Test + fun `does not create span when queue tracing is disabled`() { + val tx = createTransaction() + options.isEnableQueueTracing = false + val interceptor = SentryProducerInterceptor(scopes) + val record = ProducerRecord("my-topic", "key", "value") + + interceptor.onSend(record) + + assertEquals(0, tx.spans.size) + } + + @Test + fun `does not create span when no active span`() { + whenever(scopes.span).thenReturn(null) + val interceptor = SentryProducerInterceptor(scopes) + val record = ProducerRecord("my-topic", "key", "value") + + val result = interceptor.onSend(record) + + assertSame(record, result) + } + + @Test + fun `injects sentry-trace, baggage, and enqueued-time headers`() { + createTransaction() + val interceptor = SentryProducerInterceptor(scopes) + val record = ProducerRecord("my-topic", "key", "value") + + val result = interceptor.onSend(record) + + val resultHeaders = result.headers() + val sentryTraceHeader = resultHeaders.lastHeader(SentryTraceHeader.SENTRY_TRACE_HEADER) + assertNotNull(sentryTraceHeader, "sentry-trace header should be injected") + + val enqueuedTimeHeader = + resultHeaders.lastHeader(SentryProducerInterceptor.SENTRY_ENQUEUED_TIME_HEADER) + assertNotNull(enqueuedTimeHeader, "sentry-task-enqueued-time header should be injected") + val enqueuedTime = String(enqueuedTimeHeader.value(), StandardCharsets.UTF_8).toLong() + assertTrue(enqueuedTime > 0, "enqueued time should be a positive epoch millis value") + } + + @Test + fun `span is finished synchronously in onSend`() { + val tx = createTransaction() + val interceptor = SentryProducerInterceptor(scopes) + val record = ProducerRecord("my-topic", "key", "value") + + interceptor.onSend(record) + + assertEquals(1, tx.spans.size) + assertTrue(tx.spans.first().isFinished, "span should be finished after onSend returns") + } + + @Test + fun `onAcknowledgement does not throw`() { + val interceptor = SentryProducerInterceptor(scopes) + val metadata = RecordMetadata(TopicPartition("my-topic", 0), 0, 0, 0, 0, 0) + + interceptor.onAcknowledgement(metadata, null) + } + + @Test + fun `close does not throw`() { + val interceptor = SentryProducerInterceptor(scopes) + + interceptor.close() + } + + @Test + fun `trace origin is set correctly`() { + assertEquals("auto.queue.spring_jakarta.kafka.producer", SentryProducerInterceptor.TRACE_ORIGIN) + } +} From fdb3a03dc81990dabf1833ff105f70e6524bb700 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 9 Apr 2026 14:34:55 +0200 Subject: [PATCH 4/4] fix(spring-jakarta): Initialize Sentry in SentryProducerInterceptorTest TransactionContext constructor requires ScopesAdapter.getOptions() to be non-null for thread checker access. Add initForTest/close to ensure Sentry is properly initialized during tests. Co-Authored-By: Claude --- .../jakarta/kafka/SentryProducerInterceptorTest.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryProducerInterceptorTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryProducerInterceptorTest.kt index fc74371873d..41ca6c2ee54 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryProducerInterceptorTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/kafka/SentryProducerInterceptorTest.kt @@ -1,11 +1,14 @@ package io.sentry.spring.jakarta.kafka import io.sentry.IScopes +import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.SentryTraceHeader import io.sentry.SentryTracer import io.sentry.TransactionContext +import io.sentry.test.initForTest import java.nio.charset.StandardCharsets +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -25,6 +28,7 @@ class SentryProducerInterceptorTest { @BeforeTest fun setup() { + initForTest { it.dsn = "https://key@sentry.io/proj" } scopes = mock() options = SentryOptions().apply { @@ -34,6 +38,11 @@ class SentryProducerInterceptorTest { whenever(scopes.options).thenReturn(options) } + @AfterTest + fun teardown() { + Sentry.close() + } + private fun createTransaction(): SentryTracer { val tx = SentryTracer(TransactionContext("tx", "op"), scopes) whenever(scopes.span).thenReturn(tx)