Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -126,6 +126,7 @@
- Add `DiscardReason.CALLBACK_ERROR` and use it for telemetry dropped when a `beforeSend*` callback throws. `OnDiscardCallback` can now receive this value.
- Drop telemetry and record `callback_error` when a customer event processor throws instead of continuing with a potentially partially processed item. SDK-owned processor failures are logged and processing continues without a `callback_error` client report.
- Drop breadcrumbs when `beforeBreadcrumb` throws instead of storing exception details on the breadcrumb.
- When `tracesSampler` throws, drop the transaction and record `callback_error` instead of inheriting the parent sampling decision or falling back to `tracesSampleRate` ([#6163](https://github.com/getsentry/sentry-java/pull/6163))
- Disable URL caching when reading `META-INF/MANIFEST.MF` files during version detection so that the SDK no longer keeps jar file handles open for the life of the process ([#6124](https://github.com/getsentry/sentry-java/pull/6124)
- Keep the `EventListener` wrapped by `SentryOkHttpEventListener` per `Call` ([#6003](https://github.com/getsentry/sentry-java/pull/6003))

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package io.sentry.opentelemetry

import com.google.common.truth.Truth.assertThat
import io.opentelemetry.api.OpenTelemetry
import io.opentelemetry.api.common.Attributes
import io.opentelemetry.api.trace.Span
import io.opentelemetry.api.trace.SpanKind
import io.opentelemetry.api.trace.TraceFlags
import io.opentelemetry.api.trace.TraceState
import io.opentelemetry.context.Context
import io.opentelemetry.sdk.trace.SdkTracerProvider
import io.opentelemetry.sdk.trace.samplers.Sampler
import io.opentelemetry.sdk.trace.samplers.SamplingDecision
import io.sentry.DataCategory
import io.sentry.IScopes
import io.sentry.SamplingContext
import io.sentry.SentryOptions
import io.sentry.SentryTraceHeader
import io.sentry.SpanId
import io.sentry.TransactionContext
import io.sentry.TransactionOptions
import io.sentry.clientreport.DiscardReason
import io.sentry.protocol.SentryId
import kotlin.test.AfterTest
import kotlin.test.Test
import org.mockito.AdditionalAnswers.delegatesTo
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoMoreInteractions
import org.mockito.kotlin.whenever

class SentrySamplerTest {
private val onDiscard = mock<SentryOptions.OnDiscardCallback>()
private val options =
SentryOptions().apply {
tracesSampleRate = 1.0
profilesSampleRate = 1.0
tracesSampler = SentryOptions.TracesSamplerCallback { throw IllegalStateException("sampler") }
this.onDiscard = this@SentrySamplerTest.onDiscard
}
private val scopes = mock<IScopes>().also { whenever(it.options).thenReturn(options) }
private val sampler = SentrySampler(scopes)

@AfterTest
fun tearDown() {
SentryWeakSpanStorage.getInstance().clear()
}

@Test
fun `throwing tracesSampler drops root and reports callback errors alongside sample rate losses`() {
for (parentSampled in listOf(null, false, true)) {
val traceId = SentryId()
val context =
if (parentSampled == null) Context.root()
else
Context.root()
.with(
SentryOtelKeys.SENTRY_TRACE_KEY,
SentryTraceHeader(traceId, SpanId(), parentSampled),
)
val result =
sampler.shouldSample(
context,
traceId.toString(),
"root",
SpanKind.INTERNAL,
Attributes.empty(),
emptyList(),
) as SentrySamplingResult

assertThat(result.decision).isEqualTo(SamplingDecision.RECORD_ONLY)
assertThat(result.sentryDecision.sampled).isFalse()
assertThat(result.sentryDecision.profileSampled).isFalse()
}

verify(onDiscard, times(3)).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction, 1)
verify(onDiscard, times(3)).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1)
verify(onDiscard, times(3)).execute(DiscardReason.SAMPLE_RATE, DataCategory.Transaction, 1)
verify(onDiscard, times(3)).execute(DiscardReason.SAMPLE_RATE, DataCategory.Span, 1)
verifyNoMoreInteractions(onDiscard)
}

@Test
fun `children of failed sampling decisions retain sample rate accounting`() {
val rootResult =
sampler.shouldSample(
Context.root(),
SentryId().toString(),
"root",
SpanKind.INTERNAL,
Attributes.empty(),
emptyList(),
) as SentrySamplingResult
val restored = OtelSamplingUtil.extractSamplingDecision(rootResult.attributes)!!
assertThat(restored.sampled).isFalse()
assertThat(restored.sampleRand).isEqualTo(rootResult.sentryDecision.sampleRand)

val parentContext =
io.opentelemetry.api.trace.SpanContext.create(
SentryId().toString(),
SpanId().toString(),
TraceFlags.getDefault(),
TraceState.getDefault(),
)
val parent =
mock<IOtelSpanWrapper>().also {
whenever(it.samplingDecision).thenReturn(restored)
}
SentryWeakSpanStorage.getInstance().storeSentrySpan(parentContext, parent)
val childResult =
sampler.shouldSample(
Span.wrap(parentContext).storeInContext(Context.root()),
parentContext.traceId,
"child",
SpanKind.INTERNAL,
Attributes.empty(),
emptyList(),
) as SentrySamplingResult

assertThat(childResult.decision).isEqualTo(SamplingDecision.RECORD_ONLY)
assertThat(childResult.sentryDecision.sampled).isFalse()
verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction, 1)
verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1)
verify(onDiscard).execute(DiscardReason.SAMPLE_RATE, DataCategory.Transaction, 1)
verify(onDiscard, times(2)).execute(DiscardReason.SAMPLE_RATE, DataCategory.Span, 1)
verifyNoMoreInteractions(onDiscard)
}

@Test
fun `Sentry API sampling failure reports before forwarding through span factory`() {
val context = TransactionContext("root", "op")
context.samplingDecision =
options.internalTracesSampler.sample(SamplingContext(context, null, 0.0, null))
verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction, 1)
verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1)
verifyNoMoreInteractions(onDiscard)
val recordingSampler = mock<Sampler>(defaultAnswer = delegatesTo(sampler))
SdkTracerProvider.builder().setSampler(recordingSampler).build().use { provider ->
val openTelemetry =
mock<OpenTelemetry>().also {
whenever(it.tracerProvider).thenReturn(provider)
}
OtelSpanFactory(openTelemetry).createTransaction(context, scopes, TransactionOptions(), null)
val attributes = argumentCaptor<Attributes>()
verify(recordingSampler).shouldSample(any(), any(), any(), any(), attributes.capture(), any())
val restored = OtelSamplingUtil.extractSamplingDecision(attributes.firstValue)!!
assertThat(restored.sampled).isFalse()
assertThat(restored.profileSampled).isFalse()
}

verifyNoMoreInteractions(onDiscard)
}

@Test
fun `null tracesSampler result uses normal sample rate accounting`() {
options.tracesSampler = SentryOptions.TracesSamplerCallback { null }
options.tracesSampleRate = 0.0
val result =
sampler.shouldSample(
Context.root(),
SentryId().toString(),
"root",
SpanKind.INTERNAL,
Attributes.empty(),
emptyList(),
) as SentrySamplingResult

assertThat(result.sentryDecision.sampled).isFalse()
verify(onDiscard).execute(DiscardReason.SAMPLE_RATE, DataCategory.Transaction, 1)
verify(onDiscard).execute(DiscardReason.SAMPLE_RATE, DataCategory.Span, 1)
verifyNoMoreInteractions(onDiscard)
}
}
10 changes: 9 additions & 1 deletion sentry/src/main/java/io/sentry/TracesSampler.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.sentry;

import io.sentry.clientreport.DiscardReason;
import io.sentry.util.Objects;
import io.sentry.util.SampleRateUtils;
import org.jetbrains.annotations.ApiStatus;
Expand Down Expand Up @@ -40,13 +41,20 @@ public TracesSamplingDecision sample(final @NotNull SamplingContext samplingCont
Boolean profilesSampled = profilesSampleRate != null && sample(profilesSampleRate, sampleRand);

if (options.getTracesSampler() != null) {
Double samplerResult = null;
final Double samplerResult;
try {
samplerResult = options.getTracesSampler().sample(samplingContext);
} catch (Throwable t) {
options
.getLogger()
.log(SentryLevel.ERROR, "Error in the 'TracesSamplerCallback' callback.", t);
options

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This causes double reporting for transactions and spans with reason callback_error and again with sample_rate or backpressure. We discussed internally and are accepting this for now as the alternative would be more complexity.

If we reconsider, we can add a DiscardReason to TracesSamplingDecision. It also needs to be transported into our io.sentry.opentelemetry.SentrySampler.

.getClientReportRecorder()
.recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction);
options
.getClientReportRecorder()
.recordLostEvent(DiscardReason.CALLBACK_ERROR, DataCategory.Span);
return new TracesSamplingDecision(false, null, sampleRand, false, null);
}
if (samplerResult != null) {
return new TracesSamplingDecision(
Expand Down
115 changes: 115 additions & 0 deletions sentry/src/test/java/io/sentry/ScopesTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1660,6 +1660,121 @@ class ScopesTest {
)
}

@Test
fun `tracesSampler failures report callback errors and retain normal sampling loss accounting`() {
for (parentSampled in listOf(null, false, true)) {
for (downsampleFactor in listOf(0, 1)) {
val onDiscard = mock<SentryOptions.OnDiscardCallback>()
val profiler = mock<ITransactionProfiler>()
val options =
SentryOptions().apply {
dsn = "https://key@sentry.io/proj"
tracesSampleRate = 1.0
profilesSampleRate = 1.0
tracesSampler = SentryOptions.TracesSamplerCallback {
throw IllegalStateException("sampler")
}
this.onDiscard = onDiscard
setTransactionProfiler(profiler)
backpressureMonitor =
mock<IBackpressureMonitor>().also {
whenever(it.downsampleFactor).thenReturn(downsampleFactor)
}
}
val scopes = createScopes(options)
val client = createSentryClientMock()
scopes.bindClient(client)
val context =
TransactionContext("name", "op").apply {
setParentSampled(parentSampled, true)
}

val transaction = scopes.startTransaction(context)
assertThat(transaction.isSampled).isFalse()
assertThat(transaction.isProfileSampled).isFalse()
transaction.startChild("child").finish()
verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Transaction, 1)
verify(onDiscard).execute(DiscardReason.CALLBACK_ERROR, DataCategory.Span, 1)
verifyNoMoreInteractions(onDiscard)
transaction.finish()
transaction.finish()

verify(client, never())
.captureTransaction(any(), anyOrNull(), any(), anyOrNull(), anyOrNull())
verify(profiler, never()).start()
val samplingReason =
if (downsampleFactor > 0) DiscardReason.BACKPRESSURE else DiscardReason.SAMPLE_RATE
verify(onDiscard).execute(samplingReason, DataCategory.Transaction, 1)
verify(onDiscard).execute(samplingReason, DataCategory.Span, 1)
verifyNoMoreInteractions(onDiscard)
assertClientReport(
options.clientReportRecorder,
listOf(
DiscardedEvent(
DiscardReason.CALLBACK_ERROR.reason,
DataCategory.Transaction.category,
1,
),
DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Span.category, 1),
DiscardedEvent(samplingReason.reason, DataCategory.Transaction.category, 1),
DiscardedEvent(samplingReason.reason, DataCategory.Span.category, 1),
),
)
}
}
}

@Test
fun `tracesSampler failure does not affect subsequent successful sampling`() {
var fail = true
val options =
SentryOptions().apply {
dsn = "https://key@sentry.io/proj"
tracesSampler = SentryOptions.TracesSamplerCallback {
if (fail) throw IllegalStateException("sampler") else 1.0
}
}
val scopes = createScopes(options)
val client = createSentryClientMock()
scopes.bindClient(client)
scopes.startTransaction("failed", "op").finish()
fail = false
val transaction = scopes.startTransaction("successful", "op")
transaction.finish()

assertThat(transaction.isSampled).isTrue()
verify(client).captureTransaction(any(), anyOrNull(), any(), anyOrNull(), anyOrNull())
assertClientReport(
options.clientReportRecorder,
listOf(
DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Transaction.category, 1),
DiscardedEvent(DiscardReason.CALLBACK_ERROR.reason, DataCategory.Span.category, 1),
DiscardedEvent(DiscardReason.SAMPLE_RATE.reason, DataCategory.Transaction.category, 1),
DiscardedEvent(DiscardReason.SAMPLE_RATE.reason, DataCategory.Span.category, 1),
),
)
}

@Test
fun `null tracesSampler results still use normal sampling loss accounting`() {
val options =
SentryOptions().apply {
dsn = "https://key@sentry.io/proj"
tracesSampleRate = 0.0
tracesSampler = SentryOptions.TracesSamplerCallback { null }
}
val scopes = createScopes(options)
scopes.startTransaction("name", "op").finish()

assertClientReport(
options.clientReportRecorder,
listOf(
DiscardedEvent(DiscardReason.SAMPLE_RATE.reason, DataCategory.Transaction.category, 1),
DiscardedEvent(DiscardReason.SAMPLE_RATE.reason, DataCategory.Span.category, 1),
),
)
}

@Test
fun `transactions lost due to sampling caused by backpressure are recorded as lost`() {
val options = SentryOptions()
Expand Down
Loading
Loading