From e9514f9ed6e2d4273510f4542bb369116bbbe029 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 11 Aug 2026 10:10:04 +0200 Subject: [PATCH 1/2] feat(core): Add timestamped child spans to ISpan Expose explicit start timestamps for nested spans and forward them through core and OpenTelemetry span implementations. Use a default interface method to preserve compatibility with external ISpan implementations. Fixes #5896 Co-Authored-By: Claude --- .../api/sentry-opentelemetry-bootstrap.api | 1 + .../build.gradle.kts | 1 + .../OtelStrongRefSpanWrapper.java | 6 ++ .../OtelStrongRefSpanWrapperTest.kt | 26 ++++++++ .../api/sentry-opentelemetry-core.api | 1 + .../build.gradle.kts | 1 + .../sentry/opentelemetry/OtelSpanWrapper.java | 6 ++ .../src/test/kotlin/OtelSpanWrapperTest.kt | 60 +++++++++++++++++++ sentry/api/sentry.api | 4 +- sentry/src/main/java/io/sentry/ISpan.java | 14 +++++ .../src/main/java/io/sentry/ITransaction.java | 12 ---- sentry/src/main/java/io/sentry/NoOpSpan.java | 6 ++ sentry/src/main/java/io/sentry/Span.java | 6 ++ .../src/test/java/io/sentry/NoOpSpanTest.kt | 6 ++ sentry/src/test/java/io/sentry/SpanTest.kt | 14 +++++ 15 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/test/kotlin/io/sentry/opentelemetry/OtelStrongRefSpanWrapperTest.kt create mode 100644 sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSpanWrapperTest.kt diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api index ba43abdbec7..1f81e4324d4 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/api/sentry-opentelemetry-bootstrap.api @@ -85,6 +85,7 @@ public final class io/sentry/opentelemetry/OtelStrongRefSpanWrapper : io/sentry/ public fun startChild (Lio/sentry/SpanContext;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts index b69ae1be7b4..3585aa40d4a 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java index 995fe7f787b..907d71a278b 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/main/java/io/sentry/opentelemetry/OtelStrongRefSpanWrapper.java @@ -125,6 +125,12 @@ public void setTransactionName(@NotNull String name, @NotNull TransactionNameSou return delegate.startChild(spanContext, spanOptions); } + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable SentryDate timestamp) { + return delegate.startChild(operation, description, timestamp); + } + @Override public @NotNull ISpan startChild( @NotNull String operation, diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/test/kotlin/io/sentry/opentelemetry/OtelStrongRefSpanWrapperTest.kt b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/test/kotlin/io/sentry/opentelemetry/OtelStrongRefSpanWrapperTest.kt new file mode 100644 index 00000000000..c8547d8220f --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/src/test/kotlin/io/sentry/opentelemetry/OtelStrongRefSpanWrapperTest.kt @@ -0,0 +1,26 @@ +package io.sentry.opentelemetry + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.api.trace.Span +import io.sentry.ISpan +import io.sentry.SentryLongDate +import kotlin.test.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class OtelStrongRefSpanWrapperTest { + @Test + fun `startChild with timestamp forwards to delegate`() { + val delegate = mock() + val wrapper = OtelStrongRefSpanWrapper(mock(), delegate) + val timestamp = SentryLongDate(1234) + val expectedChild = mock() + whenever(delegate.startChild("child-op", "description", timestamp)).thenReturn(expectedChild) + + val child = wrapper.startChild("child-op", "description", timestamp) + + verify(delegate).startChild("child-op", "description", timestamp) + assertThat(child).isSameInstanceAs(expectedChild) + } +} 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 847d69bca1b..3ed25d1a9cf 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api +++ b/sentry-opentelemetry/sentry-opentelemetry-core/api/sentry-opentelemetry-core.api @@ -99,6 +99,7 @@ public final class io/sentry/opentelemetry/OtelSpanWrapper : io/sentry/opentelem public fun startChild (Lio/sentry/SpanContext;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index 7c92b8a87a5..a252628c1a2 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java index a72084ad67f..80da51f9db7 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSpanWrapper.java @@ -141,6 +141,12 @@ public OtelSpanWrapper( return childSpan; } + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable SentryDate timestamp) { + return startChild(operation, description, timestamp, Instrumenter.SENTRY); + } + @Override public @NotNull ISpan startChild( @NotNull String operation, diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSpanWrapperTest.kt b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSpanWrapperTest.kt new file mode 100644 index 00000000000..98d3989aaaf --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSpanWrapperTest.kt @@ -0,0 +1,60 @@ +package io.sentry.opentelemetry + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.TraceFlags +import io.opentelemetry.api.trace.TraceState +import io.opentelemetry.sdk.trace.ReadWriteSpan +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.ISpanFactory +import io.sentry.Instrumenter +import io.sentry.SentryLongDate +import io.sentry.SentryOptions +import io.sentry.SpanOptions +import kotlin.test.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class OtelSpanWrapperTest { + @Test + fun `startChild with timestamp forwards timestamp and Sentry instrumenter`() { + val otelSpan = mock() + whenever(otelSpan.spanContext) + .thenReturn( + SpanContext.create( + "2722d9f6ec019ade60c776169d9a8904", + "cedf5b7571cb4972", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + ) + whenever(otelSpan.name).thenReturn("parent") + + val spanFactory = mock() + val options = SentryOptions().apply { this.spanFactory = spanFactory } + val scopes = mock() + whenever(scopes.options).thenReturn(options) + + val parent = OtelSpanWrapper(otelSpan, scopes, SentryLongDate(0), null, null, null, null) + val expectedChild = mock() + whenever(spanFactory.createSpan(eq(scopes), any(), any(), eq(parent))).thenReturn(expectedChild) + val timestamp = SentryLongDate(1234) + + val child = parent.startChild("child-op", "description", timestamp) + + val spanOptions = argumentCaptor() + val spanContext = argumentCaptor() + verify(spanFactory) + .createSpan(eq(scopes), spanOptions.capture(), spanContext.capture(), eq(parent)) + assertThat(child).isSameInstanceAs(expectedChild) + assertThat(spanOptions.firstValue.startTimestamp).isSameInstanceAs(timestamp) + assertThat(spanContext.firstValue.operation).isEqualTo("child-op") + assertThat(spanContext.firstValue.description).isEqualTo("description") + assertThat(spanContext.firstValue.instrumenter).isEqualTo(Instrumenter.SENTRY) + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 43de4164a4f..a0aef1a4219 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1197,6 +1197,7 @@ public abstract interface class io/sentry/ISpan { public abstract fun startChild (Lio/sentry/SpanContext;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public abstract fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public abstract fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;)Lio/sentry/ISpan; public abstract fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;)Lio/sentry/ISpan; public abstract fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public abstract fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; @@ -1223,7 +1224,6 @@ public abstract interface class io/sentry/ITransaction : io/sentry/ISpan { public abstract fun scheduleFinish ()V public abstract fun setName (Ljava/lang/String;)V public abstract fun setName (Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;)V - public abstract fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;)Lio/sentry/ISpan; } public abstract interface class io/sentry/ITransactionProfiler { @@ -1939,6 +1939,7 @@ public final class io/sentry/NoOpSpan : io/sentry/ISpan { public fun startChild (Lio/sentry/SpanContext;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; @@ -4375,6 +4376,7 @@ public final class io/sentry/Span : io/sentry/ISpan { public fun startChild (Lio/sentry/SpanContext;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; diff --git a/sentry/src/main/java/io/sentry/ISpan.java b/sentry/src/main/java/io/sentry/ISpan.java index ee8fbe1a895..fbf6ecf70cb 100644 --- a/sentry/src/main/java/io/sentry/ISpan.java +++ b/sentry/src/main/java/io/sentry/ISpan.java @@ -53,6 +53,20 @@ ISpan startChild( @NotNull ISpan startChild(@NotNull String operation, @Nullable String description); + /** + * Starts a child Span. + * + * @param operation - new span operation name + * @param description - the span description + * @param timestamp - the start timestamp of the span + * @return a new transaction span + */ + @NotNull + default ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable SentryDate timestamp) { + return startChild(operation, description, timestamp, Instrumenter.SENTRY); + } + /** * Returns the trace information that could be sent as a sentry-trace header. * diff --git a/sentry/src/main/java/io/sentry/ITransaction.java b/sentry/src/main/java/io/sentry/ITransaction.java index 403b21187b7..f6902502b97 100644 --- a/sentry/src/main/java/io/sentry/ITransaction.java +++ b/sentry/src/main/java/io/sentry/ITransaction.java @@ -40,18 +40,6 @@ public interface ITransaction extends ISpan { @TestOnly List getSpans(); - /** - * Starts a child Span. - * - * @param operation - new span operation name - * @param description - the span description - * @param timestamp - the start timestamp of the span - * @return a new transaction span - */ - @NotNull - ISpan startChild( - @NotNull String operation, @Nullable String description, @Nullable SentryDate timestamp); - /** * Returns if the profile of a transaction is sampled. * diff --git a/sentry/src/main/java/io/sentry/NoOpSpan.java b/sentry/src/main/java/io/sentry/NoOpSpan.java index eff9f4f875c..6d9b373200f 100644 --- a/sentry/src/main/java/io/sentry/NoOpSpan.java +++ b/sentry/src/main/java/io/sentry/NoOpSpan.java @@ -58,6 +58,12 @@ public static NoOpSpan getInstance() { return NoOpSpan.getInstance(); } + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable SentryDate timestamp) { + return NoOpSpan.getInstance(); + } + @Override public @NotNull SentryTraceHeader toSentryTrace() { return new SentryTraceHeader(SentryId.EMPTY_ID, SpanId.EMPTY_ID, false); diff --git a/sentry/src/main/java/io/sentry/Span.java b/sentry/src/main/java/io/sentry/Span.java index 59a925d7a0a..7ee7eed1900 100644 --- a/sentry/src/main/java/io/sentry/Span.java +++ b/sentry/src/main/java/io/sentry/Span.java @@ -118,6 +118,12 @@ public Span( context.getSpanId(), operation, description, timestamp, instrumenter, spanOptions); } + @Override + public @NotNull ISpan startChild( + @NotNull String operation, @Nullable String description, @Nullable SentryDate timestamp) { + return startChild(operation, description, timestamp, Instrumenter.SENTRY); + } + @Override public @NotNull ISpan startChild( final @NotNull String operation, final @Nullable String description) { diff --git a/sentry/src/test/java/io/sentry/NoOpSpanTest.kt b/sentry/src/test/java/io/sentry/NoOpSpanTest.kt index 78e1ada0b24..0b3c24fc055 100644 --- a/sentry/src/test/java/io/sentry/NoOpSpanTest.kt +++ b/sentry/src/test/java/io/sentry/NoOpSpanTest.kt @@ -1,5 +1,6 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertIs @@ -15,6 +16,11 @@ class NoOpSpanTest { assertNotNull(span.startChild("op", "desc")) } + @Test + fun `startChild with timestamp returns no-op span`() { + assertThat(span.startChild("op", "desc", SentryLongDate(1234))).isSameInstanceAs(span) + } + @Test fun `getSpanContext does not return null`() { assertNotNull(span.spanContext) diff --git a/sentry/src/test/java/io/sentry/SpanTest.kt b/sentry/src/test/java/io/sentry/SpanTest.kt index cab10b80a9a..b61e12ad87f 100644 --- a/sentry/src/test/java/io/sentry/SpanTest.kt +++ b/sentry/src/test/java/io/sentry/SpanTest.kt @@ -1,5 +1,6 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import io.sentry.protocol.SentryId import io.sentry.test.injectForField import kotlin.test.Test @@ -94,6 +95,19 @@ class SpanTest { assertEquals("description", child.description) } + @Test + fun `starting a child with timestamp uses exact timestamp`() { + val parent: ISpan = fixture.getSut() + val timestamp = SentryLongDate(1234) + + val child = parent.startChild("child-op", "description", timestamp) as Span + + assertThat(child.parentSpanId).isEqualTo(parent.spanContext.spanId) + assertThat(child.operation).isEqualTo("child-op") + assertThat(child.description).isEqualTo("description") + assertThat(child.startDate).isSameInstanceAs(timestamp) + } + @Test fun `converts to Sentry trace header`() { val traceId = SentryId() From dde07292ce1f24a8493b6c98b950659c0108b68d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 11 Aug 2026 10:11:14 +0200 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ef91d1cc7..476041155c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Allow child spans to use explicit start timestamps through `ISpan` ([#5929](https://github.com/getsentry/sentry-java/pull/5929)) + ### Fixes - Clear contexts when calling `Scope.clear()` ([#5902](https://github.com/getsentry/sentry-java/pull/5902))