diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d0cec6fc..fc1f5ccece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,39 @@ ### Features +- Drop incoming server transactions whose root HTTP response status matches `ignoredServerHttpStatusCodesForTracing`. Configure individual statuses such as `404`, inclusive ranges such as `400-499` or `500-599`, or combine them ([#6566](https://github.com/getsentry/sentry-java/pull/6566)). + + Java: + + ```java + import io.sentry.HttpStatusCodeRange; + import java.util.Arrays; + + options.setIgnoredServerHttpStatusCodesForTracing( + Arrays.asList(new HttpStatusCodeRange(404), new HttpStatusCodeRange(500, 599))); + ``` + + `sentry.properties`: + + ```properties + ignored-server-http-status-codes-for-tracing=404,500-599 + ``` + + Spring Boot `application.properties`: + + ```properties + sentry.ignored-server-http-status-codes-for-tracing=404,500-599 + ``` + + Spring Boot `application.yml`: + + ```yaml + sentry: + ignored-server-http-status-codes-for-tracing: + - "404" + - "500-599" + ``` + - Monitor every Spring `@Scheduled` method with Sentry Crons by setting `sentry.monitor-scheduled-tasks=true`, without adding `@SentryCheckIn` to each method. Sentry creates each monitor from the method's `@Scheduled` schedule. Exclude methods with `sentry.exclude-scheduled-tasks` ([#6275](https://github.com/getsentry/sentry-java/pull/6275), [#6276](https://github.com/getsentry/sentry-java/pull/6276), [#6277](https://github.com/getsentry/sentry-java/pull/6277)) - Quartz `SentryJobListener` can send a monitor config derived from the trigger with check-ins, so Sentry creates or updates the monitor from code. Set the `sentry-upsert-monitor-config` job or trigger data key to `"true"` to send the schedule ([#6216](https://github.com/getsentry/sentry-java/pull/6216)) - Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = true)` to send the schedule ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt index f61ad6c382..3ca549b3b9 100644 --- a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/IntegrationTest.kt @@ -29,6 +29,7 @@ import org.springframework.test.context.junit4.SpringRunner properties = [ "sentry.traces-sample-rate=1.0", + "sentry.ignored-server-http-status-codes-for-tracing=404", "sentry.clients.ok-http-enabled=true", "sentry.enable-cache-tracing=true", "sentry.enable-queue-tracing=true", diff --git a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt index afb633e66e..ee18a3b723 100644 --- a/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTest/kotlin/io/sentry/spring/boot4/integrationtest/PersonIntegrationTest.kt @@ -102,6 +102,17 @@ class PersonIntegrationTest : IntegrationTest() { .isTrue() } + @Test + fun `configured not found response does not send a transaction`() { + val path = "/missing-ignored-status" + + val response = restTemplate.getForEntity(path, String::class.java) + + assertThat(response.statusCode.value()).isEqualTo(404) + Sentry.flush(5000) + server.assertNoEnvelope { it.transaction()?.request?.url?.endsWith(path) == true } + } + @Test fun `creating a person starts a profile linked to the transaction`() { val response = diff --git a/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt index 15aab17c41..7af3dbcfdf 100644 --- a/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt @@ -66,6 +66,17 @@ class PersonWebfluxIntegrationTest : WebfluxIntegrationTest() { .isEqualTo(200) } + @Test + fun `configured not found response does not send a transaction`() { + val path = "/missing-ignored-status" + + val response = restTemplate.getForEntity(path, String::class.java) + + assertThat(response.statusCode.value()).isEqualTo(404) + Sentry.flush(5000) + server.assertNoEnvelope { it.transaction()?.request?.url?.endsWith(path) == true } + } + @Test fun `creating a person starts a profile linked to the transaction`() { val response = diff --git a/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTest.kt index 360dcbb161..2a9bb4d671 100644 --- a/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTest.kt +++ b/sentry-spring-boot-4/src/integrationTestWebflux/kotlin/io/sentry/spring/boot4/webfluxintegrationtest/WebfluxIntegrationTest.kt @@ -26,6 +26,7 @@ import org.springframework.test.context.junit4.SpringRunner properties = [ "sentry.traces-sample-rate=1.0", + "sentry.ignored-server-http-status-codes-for-tracing=404", "sentry.enable-cache-tracing=true", "sentry.logs.enabled=true", "sentry.reactive.thread-local-accessor-enabled=true", diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt index d539470ae9..cb67a5321e 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/IntegrationTest.kt @@ -29,6 +29,7 @@ import org.springframework.test.context.junit4.SpringRunner properties = [ "sentry.traces-sample-rate=1.0", + "sentry.ignored-server-http-status-codes-for-tracing=404", "sentry.clients.ok-http-enabled=true", "sentry.enable-cache-tracing=true", "sentry.enable-queue-tracing=true", diff --git a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt index 1728424fbc..919034786d 100644 --- a/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTest/kotlin/io/sentry/spring/boot/jakarta/integrationtest/PersonIntegrationTest.kt @@ -102,6 +102,17 @@ class PersonIntegrationTest : IntegrationTest() { .isTrue() } + @Test + fun `configured not found response does not send a transaction`() { + val path = "/missing-ignored-status" + + val response = restTemplate.getForEntity(path, String::class.java) + + assertThat(response.statusCode.value()).isEqualTo(404) + Sentry.flush(5000) + server.assertNoEnvelope { it.transaction()?.request?.url?.endsWith(path) == true } + } + @Test fun `creating a person starts a profile linked to the transaction`() { val response = diff --git a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt index 662ffc961d..f437453e3b 100644 --- a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt @@ -66,6 +66,17 @@ class PersonWebfluxIntegrationTest : WebfluxIntegrationTest() { .isEqualTo(200) } + @Test + fun `configured not found response does not send a transaction`() { + val path = "/missing-ignored-status" + + val response = restTemplate.getForEntity(path, String::class.java) + + assertThat(response.statusCode.value()).isEqualTo(404) + Sentry.flush(5000) + server.assertNoEnvelope { it.transaction()?.request?.url?.endsWith(path) == true } + } + @Test fun `creating a person starts a profile linked to the transaction`() { val response = diff --git a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTest.kt index 4d314719e3..8f0ff2d0de 100644 --- a/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTest.kt +++ b/sentry-spring-boot-jakarta/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/jakarta/webfluxintegrationtest/WebfluxIntegrationTest.kt @@ -26,6 +26,7 @@ import org.springframework.test.context.junit4.SpringRunner properties = [ "sentry.traces-sample-rate=1.0", + "sentry.ignored-server-http-status-codes-for-tracing=404", "sentry.enable-cache-tracing=true", "sentry.logs.enabled=true", "sentry.reactive.thread-local-accessor-enabled=true", diff --git a/sentry-spring-boot/README.md b/sentry-spring-boot/README.md index 45ac8d7264..beb1aa3d22 100644 --- a/sentry-spring-boot/README.md +++ b/sentry-spring-boot/README.md @@ -4,3 +4,16 @@ This integration is ideal to Spring Boot users. It buids on top of [Sentry's cor and also integrates well with logback or log4j2 if those are used in the application. For more information check out [Sentry Spring Boot documentation](https://docs.sentry.io/platforms/java/guides/spring-boot/). + +## Ignoring server transactions by HTTP status + +Set `sentry.ignored-server-http-status-codes-for-tracing` to a comma-separated list of individual +status codes and inclusive ranges: + +```properties +sentry.ignored-server-http-status-codes-for-tracing=404,500-599 +``` + +Matching incoming server transactions are not sent to Sentry. The filter reads the root +transaction's HTTP response status; child span statuses do not affect it. The option is disabled +when it is unset or empty. diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt index a83f50a73f..31e49fdbb6 100644 --- a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/IntegrationTest.kt @@ -29,6 +29,7 @@ import org.springframework.test.context.junit4.SpringRunner properties = [ "sentry.traces-sample-rate=1.0", + "sentry.ignored-server-http-status-codes-for-tracing=404", "sentry.clients.ok-http-enabled=true", "sentry.enable-cache-tracing=true", "sentry.enable-queue-tracing=true", diff --git a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/PersonIntegrationTest.kt b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/PersonIntegrationTest.kt index 045bfa37a9..fa17b781b0 100644 --- a/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/PersonIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTest/kotlin/io/sentry/spring/boot/integrationtest/PersonIntegrationTest.kt @@ -102,6 +102,17 @@ class PersonIntegrationTest : IntegrationTest() { .isTrue() } + @Test + fun `configured not found response does not send a transaction`() { + val path = "/missing-ignored-status" + + val response = restTemplate.getForEntity(path, String::class.java) + + assertThat(response.statusCode.value()).isEqualTo(404) + Sentry.flush(5000) + server.assertNoEnvelope { it.transaction()?.request?.url?.endsWith(path) == true } + } + @Test fun `creating a person starts a profile linked to the transaction`() { val response = diff --git a/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt index 0210da8ba6..35c00694d6 100644 --- a/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/PersonWebfluxIntegrationTest.kt @@ -66,6 +66,17 @@ class PersonWebfluxIntegrationTest : WebfluxIntegrationTest() { .isEqualTo(200) } + @Test + fun `configured not found response does not send a transaction`() { + val path = "/missing-ignored-status" + + val response = restTemplate.getForEntity(path, String::class.java) + + assertThat(response.statusCode.value()).isEqualTo(404) + Sentry.flush(5000) + server.assertNoEnvelope { it.transaction()?.request?.url?.endsWith(path) == true } + } + @Test fun `creating a person starts a profile linked to the transaction`() { val response = diff --git a/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTest.kt b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTest.kt index f222e37b4e..b2ee197964 100644 --- a/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTest.kt +++ b/sentry-spring-boot/src/integrationTestWebflux/kotlin/io/sentry/spring/boot/webfluxintegrationtest/WebfluxIntegrationTest.kt @@ -26,6 +26,7 @@ import org.springframework.test.context.junit4.SpringRunner properties = [ "sentry.traces-sample-rate=1.0", + "sentry.ignored-server-http-status-codes-for-tracing=404", "sentry.logs.enabled=true", "sentry.profile-session-sample-rate=1.0", "sentry.profile-lifecycle=TRACE", diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index e0b1901f9b..22673f1312 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -21,6 +21,7 @@ import io.sentry.transport.RateLimiter; import io.sentry.util.*; import java.io.Closeable; +import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; @@ -1024,6 +1025,30 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint return SentryId.EMPTY_ID; } + final @Nullable Integer ignoredHttpStatusCode = getIgnoredHttpStatusCode(transaction); + if (ignoredHttpStatusCode != null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Transaction was dropped as HTTP status code %d is ignored", + ignoredHttpStatusCode); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.Transaction); + options + .getClientReportRecorder() + .recordLostEvent( + DiscardReason.EVENT_PROCESSOR, DataCategory.Span, transaction.getSpans().size() + 1); + if (profilingTraceData != null) { + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.Profile); + } + deleteProfilingTraceFile(profilingTraceData); + return SentryId.EMPTY_ID; + } + SentryId sentryId = SentryId.EMPTY_ID; if (transaction.getEventId() != null) { sentryId = transaction.getEventId(); @@ -1096,6 +1121,71 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint return sentryId; } + private @Nullable Integer getIgnoredHttpStatusCode(final @NotNull SentryTransaction transaction) { + final @Nullable List ignoredHttpStatusCodes = + options.getIgnoredServerHttpStatusCodesForTracing(); + if (ignoredHttpStatusCodes == null || ignoredHttpStatusCodes.isEmpty()) { + return null; + } + + final @Nullable SpanContext trace = transaction.getContexts().getTrace(); + if (trace == null + || !"server".equals(trace.getData().get(SpanDataConvention.SENTRY_KIND_KEY))) { + return null; + } + + final @Nullable Object httpStatusCodeValue = + trace.getData().get(SpanDataConvention.HTTP_STATUS_CODE_KEY); + final int httpStatusCode; + if (httpStatusCodeValue instanceof Integer) { + httpStatusCode = (Integer) httpStatusCodeValue; + } else if (httpStatusCodeValue instanceof Long) { + final long longHttpStatusCode = (Long) httpStatusCodeValue; + if (longHttpStatusCode < 100 || longHttpStatusCode > 599) { + return null; + } + httpStatusCode = (int) longHttpStatusCode; + } else { + return null; + } + + if (httpStatusCode < 100 || httpStatusCode > 599) { + return null; + } + for (final @Nullable HttpStatusCodeRange ignoredHttpStatusCode : ignoredHttpStatusCodes) { + if (ignoredHttpStatusCode != null && ignoredHttpStatusCode.isInRange(httpStatusCode)) { + return httpStatusCode; + } + } + return null; + } + + private void deleteProfilingTraceFile(final @Nullable ProfilingTraceData profilingTraceData) { + if (profilingTraceData == null) { + return; + } + + final @NotNull File traceFile = profilingTraceData.getTraceFile(); + try { + if (!traceFile.delete()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Failed to delete profiling trace file %s for an ignored transaction", + traceFile.getPath()); + } + } catch (SecurityException e) { + options + .getLogger() + .log( + SentryLevel.WARNING, + e, + "Failed to delete profiling trace file %s for an ignored transaction", + traceFile.getPath()); + } + } + @ApiStatus.Internal @Override public @NotNull SentryId captureProfileChunk( diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 6e359e56b9..e11920d5e0 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -89,6 +89,7 @@ class SentryClientTest { var loggerBatchProcessorFactory = mock() var metricsBatchProcessor = mock() var metricsBatchProcessorFactory = mock() + val logger = mock() val maxAttachmentSize: Long = (5 * 1024 * 1024).toLong() val scopes = mock() val sentryTracer: SentryTracer @@ -102,7 +103,7 @@ class SentryClientTest { isDebug = true setDiagnosticLevel(SentryLevel.DEBUG) setSerializer(JsonSerializer(this)) - setLogger(mock()) + setLogger(this@Fixture.logger) maxAttachmentSize = this@Fixture.maxAttachmentSize setTransportFactory(factory) logs.setLoggerBatchProcessorFactory(loggerBatchProcessorFactory) @@ -1383,6 +1384,161 @@ class SentryClientTest { assertEquals(SentryId.EMPTY_ID, eventId) } + @Test + fun `server transaction with ignored HTTP status is dropped before callbacks`() { + fixture.sentryOptions.setIgnoredServerHttpStatusCodesForTracing( + listOf(HttpStatusCodeRange(404)) + ) + val processor = mock() + val beforeSend = mock() + val replayController = mock() + fixture.sentryOptions.addEventProcessor(processor) + fixture.sentryOptions.beforeSendTransaction = beforeSend + fixture.sentryOptions.setReplayController(replayController) + val transaction = createServerTransaction(404) + + val eventId = + fixture + .getSut() + .captureTransaction( + transaction, + fixture.sentryTracer.traceContext(), + null, + null, + fixture.profilingTraceData, + ) + + assertThat(eventId).isEqualTo(SentryId.EMPTY_ID) + assertThat(fixture.profilingTraceFile.exists()).isFalse() + verify(processor, never()).process(any(), anyOrNull()) + verify(beforeSend, never()).execute(any(), anyOrNull()) + verifyNoInteractions(replayController) + verify(fixture.transport, never()).send(any(), anyOrNull()) + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf( + DiscardedEvent(DiscardReason.EVENT_PROCESSOR.reason, DataCategory.Transaction.category, 1), + DiscardedEvent(DiscardReason.EVENT_PROCESSOR.reason, DataCategory.Span.category, 2), + DiscardedEvent(DiscardReason.EVENT_PROCESSOR.reason, DataCategory.Profile.category, 1), + ), + ) + } + + @Test + fun `server transaction with ignored HTTP status range accepts OTel long value`() { + fixture.sentryOptions.setIgnoredServerHttpStatusCodesForTracing( + listOf(HttpStatusCodeRange(500, 599)) + ) + + val eventId = + fixture + .getSut() + .captureTransaction(createServerTransaction(503L), fixture.sentryTracer.traceContext()) + + assertThat(eventId).isEqualTo(SentryId.EMPTY_ID) + verify(fixture.transport, never()).send(any(), anyOrNull()) + } + + @Test + fun `HTTP status filter is disabled for an empty configuration`() { + fixture.sentryOptions.setIgnoredServerHttpStatusCodesForTracing(emptyList()) + + fixture + .getSut() + .captureTransaction(createServerTransaction(404), fixture.sentryTracer.traceContext()) + + verify(fixture.transport).send(any(), anyOrNull()) + } + + @Test + fun `HTTP status filter only applies to server transactions`() { + fixture.sentryOptions.setIgnoredServerHttpStatusCodesForTracing( + listOf(HttpStatusCodeRange(404)) + ) + val clientTransaction = createServerTransaction(404) + clientTransaction.contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY] = "client" + val missingKindTransaction = createServerTransaction(404) + missingKindTransaction.contexts.trace!!.data.remove(SpanDataConvention.SENTRY_KIND_KEY) + val sut = fixture.getSut() + + sut.captureTransaction(clientTransaction, fixture.sentryTracer.traceContext()) + sut.captureTransaction(missingKindTransaction, fixture.sentryTracer.traceContext()) + + verify(fixture.transport, times(2)).send(any(), anyOrNull()) + } + + @Test + fun `HTTP status filter ignores missing and invalid root status values`() { + fixture.sentryOptions.setIgnoredServerHttpStatusCodesForTracing( + listOf(HttpStatusCodeRange(404)) + ) + val invalidStatusValues = listOf("404", 404.0, 404.5f, 99, 600, Long.MAX_VALUE) + val sut = fixture.getSut() + + invalidStatusValues.forEach { statusCode -> + sut.captureTransaction( + createServerTransaction(statusCode), + fixture.sentryTracer.traceContext(), + ) + } + val missingStatusTransaction = createServerTransaction(200) + missingStatusTransaction.contexts.trace!!.data.remove(SpanDataConvention.HTTP_STATUS_CODE_KEY) + sut.captureTransaction(missingStatusTransaction, fixture.sentryTracer.traceContext()) + + verify(fixture.transport, times(invalidStatusValues.size + 1)).send(any(), anyOrNull()) + } + + @Test + fun `HTTP status filter does not read child span attributes`() { + fixture.sentryOptions.setIgnoredServerHttpStatusCodesForTracing( + listOf(HttpStatusCodeRange(404)) + ) + val tracer = + SentryTracer( + TransactionContext("child-status-transaction", "op", TracesSamplingDecision(true)), + fixture.scopes, + ) + tracer.startChild("child").apply { + setData(SpanDataConvention.SENTRY_KIND_KEY, "server") + setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, 404) + finish() + } + val transaction = SentryTransaction(tracer) + + fixture.getSut().captureTransaction(transaction, tracer.traceContext()) + + verify(fixture.transport).send(any(), anyOrNull()) + } + + @Test + fun `profiling trace deletion failure for ignored transaction is logged`() { + fixture.sentryOptions.setIgnoredServerHttpStatusCodesForTracing( + listOf(HttpStatusCodeRange(404)) + ) + val traceFile = mock() + whenever(traceFile.delete()).thenReturn(false) + whenever(traceFile.path).thenReturn("undeletable.trace") + val profilingTraceData = ProfilingTraceData(traceFile, fixture.sentryTracer) + + fixture + .getSut() + .captureTransaction( + createServerTransaction(404), + fixture.sentryTracer.traceContext(), + null, + null, + profilingTraceData, + ) + + verify(traceFile).delete() + verify(fixture.logger) + .log( + SentryLevel.WARNING, + "Failed to delete profiling trace file %s for an ignored transaction", + "undeletable.trace", + ) + } + @Test fun `backfillable events are only wired through backfilling processors`() { val backfillingProcessor = mock() @@ -4536,6 +4692,13 @@ class SentryClientTest { } } + private fun createServerTransaction(statusCode: Any): SentryTransaction { + return SentryTransaction(fixture.sentryTracer).apply { + contexts.trace!!.data[SpanDataConvention.SENTRY_KIND_KEY] = "server" + contexts.trace!!.data[SpanDataConvention.HTTP_STATUS_CODE_KEY] = statusCode + } + } + private fun createSession(release: String = "rel"): Session { return Session("dis", User(), "env", release) } diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 20232f56b4..0643e76036 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -239,6 +239,34 @@ class SentryTracerTest { verify(transactionProfiler).onTransactionFinish(any(), anyOrNull(), anyOrNull()) } + @Test + fun `ignored HTTP status configuration does not skip profiling and performance finalization`() { + val transactionProfiler = mock() + val performanceCollector = mock() + val profilingTraceData = mock() + whenever(transactionProfiler.onTransactionFinish(any(), anyOrNull(), anyOrNull())) + .thenReturn(profilingTraceData) + val tracer = + fixture.getSut( + optionsConfiguration = { + it.profilesSampleRate = 1.0 + it.setTransactionProfiler(transactionProfiler) + it.setIgnoredServerHttpStatusCodesForTracing(listOf(HttpStatusCodeRange(404))) + }, + samplingDecision = TracesSamplingDecision(true, null, true, null), + performanceCollector = performanceCollector, + ) + tracer.setData(SpanDataConvention.SENTRY_KIND_KEY, "server") + tracer.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, 404) + + tracer.finish() + + verify(performanceCollector).stop(tracer) + verify(transactionProfiler).onTransactionFinish(eq(tracer), anyOrNull(), eq(fixture.options)) + verify(fixture.scopes) + .captureTransaction(any(), anyOrNull(), anyOrNull(), eq(profilingTraceData)) + } + @Test fun `when continuous profiler is running, profile context is set`() { val continuousProfiler = mock()