Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a4ce01a
also load converter at sentry initialization time and hold in sentry-…
lbloder Oct 17, 2025
39225ff
use InitUtil to initialize profiler and converter
lbloder Oct 20, 2025
40395fb
add new configuration class to spring and spring4 variants
lbloder Oct 21, 2025
f8f253f
add tests for profiler and converter init
lbloder Oct 23, 2025
c02c3dd
format code
lbloder Oct 23, 2025
d180c19
add test for auto profiler config
lbloder Oct 24, 2025
5e75584
bump api
lbloder Oct 24, 2025
dde02b9
Merge branch 'main' into feat/profiling-w-spring-otel-agent
lbloder Nov 3, 2025
0514903
add missing package to test, adapt log statement
lbloder Nov 3, 2025
2cb26e1
add changelog entry, improve logs
lbloder Nov 3, 2025
737c147
Merge branch 'main' into feat/profiling-w-spring-otel-agent
lbloder Nov 3, 2025
41d2c6a
Update sentry-spring-7/src/main/java/io/sentry/spring7/SentryProfiler…
lbloder Nov 4, 2025
87071dd
Format code
getsentry-bot Nov 4, 2025
8fca29a
make initUtil methods more readable, return value from options, impro…
lbloder Nov 4, 2025
40bb0a2
Merge branch 'feat/profiling-w-spring-otel-agent' of github.com:getse…
lbloder Nov 4, 2025
4a52a0d
add tests for path creation
lbloder Nov 5, 2025
f04bdbc
improve test cases
lbloder Nov 7, 2025
c3c36b6
add condition on JavaContinuousProfiler to be on the classpath to inv…
lbloder Nov 10, 2025
3ebaeb5
Merge branch 'main' into feat/profiling-w-spring-otel-agent
lbloder Nov 10, 2025
fa864ed
Merge branch 'main' into feat/profiling-w-spring-otel-agent
lbloder Nov 10, 2025
1bb7e19
Merge branch 'main' into feat/profiling-w-spring-otel-agent
lbloder Nov 10, 2025
adf084c
return profiler and converter from options instead of a instance of noop
lbloder Nov 10, 2025
23ebbf7
Merge branch 'main' into feat/profiling-w-spring-otel-agent
lbloder Nov 11, 2025
937782e
Merge branch 'main' into feat/profiling-w-spring-otel-agent
lbloder Nov 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- Session Replay: Cache connection status instead of using blocking calls ([#4891](https://github.com/getsentry/sentry-java/pull/4891))
- Fix log count in client reports ([#4869](https://github.com/getsentry/sentry-java/pull/4869))
- Fix profilerId propagation ([#4833](https://github.com/getsentry/sentry-java/pull/4833))
- Fix profiling init for Spring and Spring Boot w Agent auto-init ([#4815](https://github.com/getsentry/sentry-java/pull/4815))

### Improvements

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import io.sentry.profiling.JavaProfileConverterProvider;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* AsyncProfiler implementation of {@link JavaProfileConverterProvider}. This provider integrates
Expand All @@ -15,7 +14,7 @@
public final class AsyncProfilerProfileConverterProvider implements JavaProfileConverterProvider {

@Override
public @Nullable IProfileConverter getProfileConverter() {
public @NotNull IProfileConverter getProfileConverter() {
return new AsyncProfilerProfileConverter();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package io.sentry.asyncprofiler.init

import io.sentry.ILogger
import io.sentry.ISentryExecutorService
import io.sentry.NoOpContinuousProfiler
import io.sentry.NoOpProfileConverter
import io.sentry.SentryOptions
import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler
import io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider
import io.sentry.util.InitUtil
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertSame
import org.mockito.kotlin.mock

class AsyncProfilerInitUtilTest {

@Test
fun `initialize Profiler returns no-op profiler if profiling disabled`() {
val options = SentryOptions()
val profiler = InitUtil.initializeProfiler(options)
assert(profiler is NoOpContinuousProfiler)
}

@Test
fun `initialize Converter returns no-op converter if profiling disabled`() {
val options = SentryOptions()
val converter = InitUtil.initializeProfileConverter(options)
assert(converter is NoOpProfileConverter)
}

@Test
fun `initialize profiler returns the existing profiler from options if already initialized`() {
val initialProfiler =
JavaContinuousProfiler(mock<ILogger>(), "", 10, mock<ISentryExecutorService>())
val options =
SentryOptions().also {
it.setProfileSessionSampleRate(1.0)
it.setContinuousProfiler(initialProfiler)
}

val profiler = InitUtil.initializeProfiler(options)
assertSame(initialProfiler, profiler)
}

@Test
fun `initialize converter returns the existing converter from options if already initialized`() {
val initialConverter = AsyncProfilerProfileConverterProvider.AsyncProfilerProfileConverter()
val options =
SentryOptions().also {
it.setProfileSessionSampleRate(1.0)
it.profilerConverter = initialConverter
}

val converter = InitUtil.initializeProfileConverter(options)
assertSame(initialConverter, converter)
}

@Test
fun `initialize Profiler returns JavaContinuousProfiler if profiling enabled but profiler not yet initialized`() {
val options = SentryOptions().also { it.setProfileSessionSampleRate(1.0) }
val profiler = InitUtil.initializeProfiler(options)
assertSame(profiler, options.continuousProfiler)
assert(profiler is JavaContinuousProfiler)
}

@Test
fun `initialize Converter returns AsyncProfilerProfileConverterProvider if profiling enabled but profiler not yet initialized`() {
val options = SentryOptions().also { it.setProfileSessionSampleRate(1.0) }
val converter = InitUtil.initializeProfileConverter(options)
assertSame(converter, options.profilerConverter)
assert(converter is AsyncProfilerProfileConverterProvider.AsyncProfilerProfileConverter)
}

@Test
fun `initialize profiler uses existing profilingTracesDirPath when set`() {
val customPath = "/custom/path/to/traces"
val options =
SentryOptions().also {
it.setProfileSessionSampleRate(1.0)
it.profilingTracesDirPath = customPath
}
val profiler = InitUtil.initializeProfiler(options)
assert(profiler is JavaContinuousProfiler)
assertSame(customPath, options.profilingTracesDirPath)
}

@Test
fun `initialize profiler creates and sets profilingTracesDirPath when null`() {
val options = SentryOptions().also { it.setProfileSessionSampleRate(1.0) }
val profiler = InitUtil.initializeProfiler(options)
assert(profiler is JavaContinuousProfiler)
assertNotNull(options.profilingTracesDirPath)
assert(options.profilingTracesDirPath!!.contains("sentry_profiling_traces"))
}
}
6 changes: 6 additions & 0 deletions sentry-spring-7/api/sentry-spring-7.api
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ public class io/sentry/spring7/SentryInitBeanPostProcessor : org/springframework
public fun setApplicationContext (Lorg/springframework/context/ApplicationContext;)V
}

public class io/sentry/spring7/SentryProfilerConfiguration {
public fun <init> ()V
public fun sentryOpenTelemetryProfilerConfiguration ()Lio/sentry/IContinuousProfiler;
public fun sentryOpenTelemetryProfilerConverterConfiguration ()Lio/sentry/IProfileConverter;
}

public class io/sentry/spring7/SentryRequestHttpServletRequestProcessor : io/sentry/EventProcessor {
public fun <init> (Lio/sentry/spring7/tracing/TransactionNameProvider;Ljakarta/servlet/http/HttpServletRequest;)V
public fun getOrder ()Ljava/lang/Long;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package io.sentry.spring7;

import com.jakewharton.nopen.annotation.Open;
import io.sentry.IContinuousProfiler;
import io.sentry.IProfileConverter;
import io.sentry.NoOpContinuousProfiler;
import io.sentry.NoOpProfileConverter;
import io.sentry.Sentry;
import io.sentry.SentryOptions;
import io.sentry.util.InitUtil;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* Handles late initialization of the profiler if the application is run with the Opentelemetry
* Agent in auto-init mode. In that case the agent cannot initialize the profiler yet and falls back
* to No-Op implementations. This Configuration sets the profiler and converter on the options if
* that was the case.
*/
@Configuration(proxyBeanMethods = false)
@Open
public class SentryProfilerConfiguration {

@Bean
@ConditionalOnMissingBean(name = "sentryOpenTelemetryProfilerConfiguration")
public IContinuousProfiler sentryOpenTelemetryProfilerConfiguration() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an expected order to these bean evaluations?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought about that again, and came to the conclusion that no, the order does not matter.

  1. It doesn't matter if the Profiler or the Converter are created first as they have no direct dependency to each other.
  2. Initially I was worried, that this needs to run after the original SentryAutoConfiguration, but based on the following scenarios it should not matter:
    a) No Agent -> Configuration does not run at all, as we depend on the AgentMarker
    b) Agent with AutoInit -> Sentry init happened through Agent and we set the profiler on the options. SentryAutoConfiguration shouldn't run at all as the dsn config should be done by OTEL. (caveat misconfiguration by also setting the dsn in application.properties)
    c1) Agent without AutoInit and AutoConfiguration runs before SentryProfilerConfiguration -> Sentry is enabled and profiler is already set if profiling is enabled, thus nothing happens.
    c2) Agent without AutoInit and SentryAutoConfiguration runs after SentryProfilerConfiguration -> Sentry is not yet enabled, thus SentryProfilerConfiguration does nothing and SentryAutoConfiguration configures profiler afterwards.

WDYT?

SentryOptions options = Sentry.getGlobalScope().getOptions();
IContinuousProfiler profiler = NoOpContinuousProfiler.getInstance();

if (Sentry.isEnabled()) {
return InitUtil.initializeProfiler(options);
} else {
return profiler;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: NoOp Profiler Overwrites Custom Configuration

When Sentry.isEnabled() returns false, the method creates and returns a new NoOpContinuousProfiler instance instead of returning the profiler already stored in options. This can inadvertently overwrite a custom profiler that was configured through OptionsConfiguration, as the bean will always return a fresh NoOp instance rather than preserving existing configuration.

Fix in Cursor Fix in Web

}

@Bean
@ConditionalOnMissingBean(name = "sentryOpenTelemetryProfilerConverterConfiguration")
public IProfileConverter sentryOpenTelemetryProfilerConverterConfiguration() {
SentryOptions options = Sentry.getGlobalScope().getOptions();
IProfileConverter converter = NoOpProfileConverter.getInstance();

if (Sentry.isEnabled()) {
return InitUtil.initializeProfileConverter(options);
} else {
return converter;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Custom Configuration Unexpectedly Overwritten

When Sentry.isEnabled() returns false, the method creates and returns a new NoOpProfileConverter instance instead of returning the converter already stored in options. This can inadvertently overwrite a custom converter that was configured through OptionsConfiguration, as the bean will always return a fresh NoOp instance rather than preserving existing configuration.

Fix in Cursor Fix in Web

}
}
4 changes: 4 additions & 0 deletions sentry-spring-boot-4/api/sentry-spring-boot-4.api
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public class io/sentry/spring/boot4/SentryLogbackInitializer : org/springframewo
public fun supportsEventType (Lorg/springframework/core/ResolvableType;)Z
}

public class io/sentry/spring/boot4/SentryProfilerAutoConfiguration {
public fun <init> ()V
}

public class io/sentry/spring/boot4/SentryProperties : io/sentry/SentryOptions {
public fun <init> ()V
public fun getExceptionResolverOrder ()I
Expand Down
1 change: 1 addition & 0 deletions sentry-spring-boot-4/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ dependencies {
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.otel)
testImplementation(libs.otel.extension.autoconfigure.spi)
testImplementation(projects.sentryAsyncProfiler)
/**
* Adding a version of opentelemetry-spring-boot-starter that doesn't support Spring Boot 4 causes
* java.lang.IllegalArgumentException: Could not find class
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.sentry.spring.boot4;

import com.jakewharton.nopen.annotation.Open;
import io.sentry.spring7.SentryProfilerConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(
name = {
"io.sentry.opentelemetry.agent.AgentMarker",
"io.sentry.asyncprofiler.profiling.JavaContinuousProfiler"
})
@Open
@Import(SentryProfilerConfiguration.class)
public class SentryProfilerAutoConfiguration {}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
io.sentry.spring.boot4.SentryAutoConfiguration
io.sentry.spring.boot4.SentryProfilerAutoConfiguration
io.sentry.spring.boot4.SentryLogbackAppenderAutoConfiguration
io.sentry.spring.boot4.SentryWebfluxAutoConfiguration
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import io.sentry.Breadcrumb
import io.sentry.EventProcessor
import io.sentry.FilterString
import io.sentry.Hint
import io.sentry.IContinuousProfiler
import io.sentry.IProfileConverter
import io.sentry.IScopes
import io.sentry.ITransportFactory
import io.sentry.Integration
import io.sentry.NoOpContinuousProfiler
import io.sentry.NoOpProfileConverter
import io.sentry.NoOpTransportFactory
import io.sentry.SamplingContext
import io.sentry.Sentry
Expand All @@ -18,6 +22,8 @@ import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryLevel
import io.sentry.SentryLogEvent
import io.sentry.SentryOptions
import io.sentry.asyncprofiler.profiling.JavaContinuousProfiler
import io.sentry.asyncprofiler.provider.AsyncProfilerProfileConverterProvider
import io.sentry.checkEvent
import io.sentry.opentelemetry.SentryAutoConfigurationCustomizerProvider
import io.sentry.opentelemetry.agent.AgentMarker
Expand Down Expand Up @@ -45,6 +51,7 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.aspectj.lang.ProceedingJoinPoint
import org.assertj.core.api.Assertions.assertThat
import org.mockito.internal.util.MockUtil.isMock
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.mock
Expand Down Expand Up @@ -87,6 +94,7 @@ class SentryAutoConfigurationTest {
AutoConfigurations.of(
SentryAutoConfiguration::class.java,
WebMvcAutoConfiguration::class.java,
SentryProfilerAutoConfiguration::class.java,
)
)

Expand Down Expand Up @@ -1037,6 +1045,110 @@ class SentryAutoConfigurationTest {
}
}

@Test
fun `when AgentMarker is on the classpath and ContinuousProfiling is enabled IContinuousProfiler and IProfileConverter beans are created and set on options`() {
SentryIntegrationPackageStorage.getInstance().clearStorage()
contextRunner
.withPropertyValues(
"sentry.dsn=http://key@localhost/proj",
"sentry.profile-session-sample-rate=1.0",
)
.run {
assertThat(it).hasSingleBean(IContinuousProfiler::class.java)
assertThat(it).hasSingleBean(IProfileConverter::class.java)
assertThat(it)
.getBean(IProfileConverter::class.java)
.isInstanceOf(
AsyncProfilerProfileConverterProvider.AsyncProfilerProfileConverter::class.java
)
assertThat(it)
.getBean(IContinuousProfiler::class.java)
.isInstanceOf(JavaContinuousProfiler::class.java)
assertThat(it)
.getBean(IProfileConverter::class.java)
.isSameAs(Sentry.getGlobalScope().options.profilerConverter)
assertThat(it)
.getBean(IContinuousProfiler::class.java)
.isSameAs(Sentry.getGlobalScope().options.continuousProfiler)
}
}

@Test
fun `when AgentMarker is on the classpath and ContinuousProfiling is enabled IContinuousProfiler and IProfileConverter exist beans are taken from options`() {
SentryIntegrationPackageStorage.getInstance().clearStorage()

contextRunner
.withPropertyValues(
"sentry.dsn=http://key@localhost/proj",
"sentry.profile-session-sample-rate=1.0",
"sentry.auto-init=false",
"debug=true",
)
.withUserConfiguration(CustomProfilerOptionsConfigurationConfiguration::class.java)
.run {
val profiler = it.getBean(IContinuousProfiler::class.java)
assertTrue(isMock(profiler))
assertThat(it).hasSingleBean(IContinuousProfiler::class.java)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this currently simply asserting it has noop instances?

assertThat(it).hasSingleBean(IProfileConverter::class.java)
assertThat(it)
.getBean(IProfileConverter::class.java)
.isSameAs(Sentry.getGlobalScope().options.profilerConverter)
assertThat(it)
.getBean(IContinuousProfiler::class.java)
.isSameAs(Sentry.getGlobalScope().options.continuousProfiler)
}
}

@Test
fun `when AgentMarker is on the classpath and ContinuousProfiling is disabled NoOp Beans are created`() {
SentryIntegrationPackageStorage.getInstance().clearStorage()

contextRunner
.withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.auto-init=false")
.run {
assertThat(it).hasSingleBean(IContinuousProfiler::class.java)
assertThat(it).hasSingleBean(IProfileConverter::class.java)
assertThat(it)
.getBean(IProfileConverter::class.java)
.isInstanceOf(NoOpProfileConverter::class.java)
assertThat(it)
.getBean(IContinuousProfiler::class.java)
.isInstanceOf(NoOpContinuousProfiler::class.java)
}
}

@Test
fun `when AgentMarker is not on the classpath and ContinuousProfiling is enabled IContinuousProfiler and IProfileConverter beans are not created`() {
SentryIntegrationPackageStorage.getInstance().clearStorage()
contextRunner
.withPropertyValues(
"sentry.dsn=http://key@localhost/proj",
"sentry.profile-session-sample-rate=1.0",
"debug=true",
)
.withClassLoader(FilteredClassLoader(AgentMarker::class.java, OpenTelemetry::class.java))
.run {
assertThat(it).doesNotHaveBean(IContinuousProfiler::class.java)
assertThat(it).doesNotHaveBean(IProfileConverter::class.java)
}
}

@Test
fun `when JavaContinuousProfiler is not on the classpath and ContinuousProfiling is enabled IProfileConverter beans are not created`() {
SentryIntegrationPackageStorage.getInstance().clearStorage()
contextRunner
.withPropertyValues(
"sentry.dsn=http://key@localhost/proj",
"sentry.profile-session-sample-rate=1.0",
"debug=true",
)
.withClassLoader(FilteredClassLoader(JavaContinuousProfiler::class.java))
.run {
assertThat(it).doesNotHaveBean(IContinuousProfiler::class.java)
assertThat(it).doesNotHaveBean(IProfileConverter::class.java)
}
}

@Configuration(proxyBeanMethods = false)
open class CustomSchedulerFactoryBeanCustomizerConfiguration {
class MyJobListener : JobListener {
Expand Down Expand Up @@ -1082,6 +1194,17 @@ class SentryAutoConfigurationTest {
@Bean open fun sentryOptionsConfiguration() = Sentry.OptionsConfiguration<SentryOptions> {}
}

@Configuration(proxyBeanMethods = false)
open class CustomProfilerOptionsConfigurationConfiguration {
private val profiler = mock<IContinuousProfiler>()

@Bean
open fun customOptionsConfiguration() =
Sentry.OptionsConfiguration<SentryOptions> { it.setContinuousProfiler(profiler) }

@Bean open fun beforeSendCallback() = CustomBeforeSendCallback()
}

@Configuration(proxyBeanMethods = false)
open class MockTransportConfiguration {

Expand Down
Loading
Loading