diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 2165a6c6a67..92ef48a38cb 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -64,9 +64,6 @@ jobs: - sample: "sentry-samples-console-otlp" agent: "false" agent-auto-init: "true" - - sample: "sentry-samples-logback" - agent: "false" - agent-auto-init: "true" - sample: "sentry-samples-log4j2" agent: "false" agent-auto-init: "true" diff --git a/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderIntegrationTest.kt b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderIntegrationTest.kt new file mode 100644 index 00000000000..8f654b3d038 --- /dev/null +++ b/sentry-logback/src/test/kotlin/io/sentry/logback/SentryAppenderIntegrationTest.kt @@ -0,0 +1,68 @@ +package io.sentry.logback + +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.joran.JoranConfigurator +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryLevel +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.logs +import kotlin.test.AfterTest +import kotlin.test.Test +import org.slf4j.MDC + +class SentryAppenderIntegrationTest { + private val server = MockSentryServer() + private val loggerContext = LoggerContext() + + @AfterTest + fun teardown() { + loggerContext.stop() + Sentry.close() + server.close() + MDC.clear() + } + + @Test + fun `appender configured from XML sends events, breadcrumbs and logs`() { + loggerContext.putProperty("dsn", server.dsn) + JoranConfigurator() + .apply { context = loggerContext } + .doConfigure(javaClass.getResource("/sentry-appender-integration-test.xml")) + val logger = loggerContext.getLogger(SentryAppenderIntegrationTest::class.java) + + logger.debug("Hello Sentry!") + MDC.put("userId", "user-1") + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.addFeatureFlag("my-feature-flag", true) + logger.warn("important warning") + logger.info("User has made a purchase of product: {}", 445) + logger.error("Something went wrong", RuntimeException("Invalid productId=445")) + Sentry.flush(5000) + + val warning = server.awaitEnvelope { it.event()?.level == SentryLevel.WARNING }.event()!! + assertThat(warning.message!!.message).isEqualTo("important warning") + assertThat(warning.tags).containsEntry("userId", "user-1") + assertThat(warning.hasFlag("my-feature-flag", true)).isTrue() + + val error = server.awaitEnvelope { it.event()?.level == SentryLevel.ERROR }.event()!! + assertThat(error.message!!.formatted).isEqualTo("Something went wrong") + assertThat(error.exceptions!!.map { it.type to it.value }) + .contains("RuntimeException" to "Invalid productId=445") + assertThat(error.breadcrumbs!!.map { it.message to it.level }) + .containsAtLeast( + "Hello Sentry!" to SentryLevel.DEBUG, + "User has made a purchase of product: 445" to SentryLevel.INFO, + ) + + val logs = server.awaitEnvelope { it.logs() != null }.logs()!! + assertThat(logs.items.map { it.body }).contains("User has made a purchase of product: 445") + val failureLog = logs.items.single { it.body == "Something went wrong" } + assertThat(failureLog.attributes!!.mapValues { it.value.value }) + .containsAtLeast("user.type", "admin", "feature.version", 2, "debug.enabled", true) + } +} diff --git a/sentry-logback/src/test/resources/sentry-appender-integration-test.xml b/sentry-logback/src/test/resources/sentry-appender-integration-test.xml new file mode 100644 index 00000000000..049317a1bcb --- /dev/null +++ b/sentry-logback/src/test/resources/sentry-appender-integration-test.xml @@ -0,0 +1,18 @@ + + + + ${dsn} + userId + + true + + + WARN + DEBUG + INFO + + + + + + diff --git a/sentry-samples/sentry-samples-logback/build.gradle.kts b/sentry-samples/sentry-samples-logback/build.gradle.kts index 6d6e7051030..12644bf2ded 100644 --- a/sentry-samples/sentry-samples-logback/build.gradle.kts +++ b/sentry-samples/sentry-samples-logback/build.gradle.kts @@ -1,32 +1,15 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { java application - alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) alias(libs.plugins.shadow) - id("io.sentry.systemtest") } application { mainClass.set("io.sentry.samples.logback.Main") } -tasks.withType().configureEach { - kotlin { - compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - } -} - dependencies { implementation(projects.sentryLogback) implementation(libs.logback.classic) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j.api) - testImplementation(libs.slf4j.jdk14) } // Configure the Shadow JAR (executable JAR with all dependencies) @@ -44,22 +27,3 @@ tasks.jar { // Fix the startScripts task dependency tasks.startScripts { dependsOn(tasks.shadowJar) } - -tasks.register("systemTest").configure { - group = "verification" - description = "Runs the System tests" - - val test = project.extensions.getByType()["test"] - testClassesDirs = test.output.classesDirs - classpath = test.runtimeClasspath - - maxParallelForks = 1 - - filter { includeTestsMatching("io.sentry.systemtest*") } -} - -tasks.named("test").configure { - require(this is Test) - - filter { excludeTestsMatching("io.sentry.systemtest.*") } -} diff --git a/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/DummyTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.sentry - -import kotlin.test.Test -import kotlin.test.assertTrue - -class DummyTest { - @Test - fun `the only test`() { - // only needed to have more than 0 tests and not fail the build - assertTrue(true) - } -} diff --git a/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt deleted file mode 100644 index ad28ff77762..00000000000 --- a/sentry-samples/sentry-samples-logback/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.SentryLevel -import io.sentry.systemtest.util.TestHelper -import java.util.concurrent.TimeUnit -import org.junit.Assert -import org.junit.Before -import org.junit.Test - -class ConsoleApplicationSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8000") - testHelper.reset() - } - - @Test - fun `logback application sends expected events when run as JAR`() { - val jarFile = testHelper.findJar("sentry-samples-logback") - val process = - testHelper.launch( - jarFile, - mapOf( - "SENTRY_DSN" to testHelper.dsn, - "SENTRY_TRACES_SAMPLE_RATE" to "1.0", - "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", - "SENTRY_DEBUG" to "true", - ), - ) - - process.waitFor(30, TimeUnit.SECONDS) - Assert.assertEquals(0, process.exitValue()) - - // Verify that we received the expected events - verifyExpectedEvents() - } - - private fun verifyExpectedEvents() { - // Verify we received the RuntimeException - testHelper.ensureErrorReceived { event -> - event.exceptions?.any { ex -> - ex.type == "RuntimeException" && ex.value == "Invalid productId=445" - } == true && - event.message?.formatted == "Something went wrong" && - event.level?.name == "ERROR" - } - - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.firstOrNull { - it.message == "Hello Sentry!" && it.level == SentryLevel.DEBUG - } != null - } - - testHelper.ensureErrorReceived { event -> - event.message?.message == "important warning" && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.firstOrNull { - it.message == "User has made a purchase of product: 445" && it.level == SentryLevel.INFO - } != null - } - - testHelper.ensureLogsReceived { logs, _ -> - testHelper.doesContainLogWithBody(logs, "User has made a purchase of product: 445") && - testHelper.doesContainLogWithBody(logs, "Something went wrong") && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "Something went wrong", - "user.type", - "admin", - ) && - testHelper.doesLogWithBodyHaveAttribute( - logs, - "Something went wrong", - "feature.version", - 2, - ) && - testHelper.doesLogWithBodyHaveAttribute(logs, "Something went wrong", "debug.enabled", true) - } - } -} diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt index 7f0dfc8c955..8ef74cae3c9 100644 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -4,9 +4,9 @@ import com.apollographql.apollo.api.ApolloResponse import com.apollographql.apollo.api.Operation import io.sentry.JsonSerializer import io.sentry.ProfileChunk +import io.sentry.SentryEnvelope import io.sentry.SentryEnvelopeHeader import io.sentry.SentryEvent -import io.sentry.SentryItemType import io.sentry.SentryLogEvents import io.sentry.SentryMetricsEvents import io.sentry.SentryOptions @@ -14,10 +14,22 @@ import io.sentry.protocol.FeatureFlag import io.sentry.protocol.SentrySpan import io.sentry.protocol.SentryTransaction import io.sentry.systemtest.graphql.GraphqlTestClient -import java.io.BufferedReader -import java.io.ByteArrayInputStream +import io.sentry.test.event +import io.sentry.test.hasExceptionMessage +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.hasOp +import io.sentry.test.hasSpanWithOp +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.hasTraceId +import io.sentry.test.logs +import io.sentry.test.metrics +import io.sentry.test.profileChunk +import io.sentry.test.transaction import java.io.File -import java.io.InputStreamReader import java.io.PrintWriter import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -107,24 +119,9 @@ class TestHelper(backendUrl: String) { envelopeString: String, callback: ((SentryLogEvents, SentryEnvelopeHeader) -> Boolean), ): Boolean { - val deserializeEnvelope = jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return false - } - - val envelopeHeader = deserializeEnvelope.header - - val logsItem = deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Log } - if (logsItem == null) { - return false - } - - val logs = logsItem.getLogs(jsonSerializer) - if (logs == null) { - return false - } - - return callback(logs, envelopeHeader) + val envelope = deserialize(envelopeString) ?: return false + val logs = envelope.logs() ?: return false + return callback(logs, envelope.header) } fun doesContainMetric( @@ -133,22 +130,7 @@ class TestHelper(backendUrl: String) { type: String, value: Double, unit: String? = null, - ): Boolean { - val metricItem = - metrics.items.firstOrNull { metricItem -> - metricItem.name == name && - metricItem.type == type && - metricItem.value == value && - (unit == null || metricItem.unit == unit) - } - if (metricItem == null) { - println("Unable to find metric item with name $name, type $type and value $value in metrics:") - logObject(metrics) - return false - } - - return true - } + ): Boolean = metrics.hasMetric(name, type, value, unit) fun ensureMetricsReceived(callback: ((SentryMetricsEvents, SentryEnvelopeHeader) -> Boolean)) { ensureEnvelopeReceived { envelopeString -> checkIfMetricsMatch(envelopeString, callback) } @@ -158,172 +140,49 @@ class TestHelper(backendUrl: String) { envelopeString: String, callback: ((SentryMetricsEvents, SentryEnvelopeHeader) -> Boolean), ): Boolean { - val deserializeEnvelope = jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return false - } - - val envelopeHeader = deserializeEnvelope.header - - val metricsItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.TraceMetric } - if (metricsItem == null) { - return false - } - - val metrics = metricsItem.getMetrics(jsonSerializer) - if (metrics == null) { - return false - } - - return callback(metrics, envelopeHeader) + val envelope = deserialize(envelopeString) ?: return false + val metrics = envelope.metrics() ?: return false + return callback(metrics, envelope.header) } - fun doesContainLogWithBody(logs: SentryLogEvents, body: String): Boolean { - val logItem = logs.items.firstOrNull { logItem -> logItem.body == body } - if (logItem == null) { - println("Unable to find log item with body $body in logs:") - logObject(logs) - return false - } - - return true - } + fun doesContainLogWithBody(logs: SentryLogEvents, body: String): Boolean = + logs.hasLogWithBody(body) fun doesLogWithBodyHaveAttribute( logs: SentryLogEvents, body: String, attributeKey: String, attributeValue: Any?, - ): Boolean { - val logItem = logs.items.firstOrNull { logItem -> logItem.body == body } - if (logItem == null) { - println("Unable to find log item with body $body in logs:") - logObject(logs) - return false - } - - val attr = logItem.attributes?.get(attributeKey) - if (attr == null) { - println("Unable to find attribute $attributeKey on log with body $body:") - logObject(logItem) - return false - } - - if (attr.value != attributeValue) { - println( - "Attribute $attributeKey has value ${attr.value} but expected $attributeValue on log with body $body:" - ) - logObject(logItem) - return false - } - - return true - } + ): Boolean = logs.hasLogWithAttribute(body, attributeKey, attributeValue) fun doesMetricHaveAttribute( metrics: SentryMetricsEvents, metricName: String, attributeKey: String, attributeValue: Any?, - ): Boolean { - val metricItem = metrics.items.firstOrNull { it.name == metricName } - if (metricItem == null) { - println("Unable to find metric with name $metricName in metrics:") - logObject(metrics) - return false - } - - val attr = metricItem.attributes?.get(attributeKey) - if (attr == null) { - println("Unable to find attribute $attributeKey on metric $metricName:") - logObject(metricItem) - return false - } - - if (attr.value != attributeValue) { - println( - "Attribute $attributeKey has value ${attr.value} but expected $attributeValue on metric $metricName:" - ) - logObject(metricItem) - return false - } - - return true - } + ): Boolean = metrics.hasMetricWithAttribute(metricName, attributeKey, attributeValue) private fun checkIfTransactionMatches( envelopeString: String, callback: ((SentryTransaction, SentryEnvelopeHeader) -> Boolean), ): Boolean { - val deserializeEnvelope = jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return false - } - - val envelopeHeader = deserializeEnvelope.header - - val transactionItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } - if (transactionItem == null) { - return false - } - - val transaction = transactionItem.getTransaction(jsonSerializer) - if (transaction == null) { - return false - } - - return callback(transaction, envelopeHeader) + val envelope = deserialize(envelopeString) ?: return false + val transaction = envelope.transaction() ?: return false + return callback(transaction, envelope.header) } private fun checkIfProfileMatches( envelopeString: String, callback: ((ProfileChunk, SentryEnvelopeHeader) -> Boolean), ): Boolean { - val deserializeEnvelope = jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return false - } - - val envelopeHeader = deserializeEnvelope.header - - val profileChunkItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.ProfileChunk } - - if (profileChunkItem == null) { - return false - } - - val chunk = - BufferedReader(InputStreamReader(ByteArrayInputStream(profileChunkItem.data), Charsets.UTF_8)) - .use { eventReader -> jsonSerializer.deserialize(eventReader, ProfileChunk::class.java) } - - if (chunk == null) { - return false - } - - return callback(chunk, envelopeHeader) + val envelope = deserialize(envelopeString) ?: return false + val chunk = envelope.profileChunk() ?: return false + return callback(chunk, envelope.header) } fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { ensureEnvelopeReceived(retryCount = 3) { envelopeString -> - val deserializeEnvelope = jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) - if (deserializeEnvelope == null) { - return@ensureEnvelopeReceived false - } - - val errorItem = - deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } - if (errorItem == null) { - return@ensureEnvelopeReceived false - } - - val error = errorItem.getEvent(jsonSerializer) - if (error == null) { - return@ensureEnvelopeReceived false - } - + val error = deserialize(envelopeString)?.event() ?: return@ensureEnvelopeReceived false val callbackResult = callback(error) if (!callbackResult) { println("found an error event but it did not match:") @@ -351,10 +210,17 @@ class TestHelper(backendUrl: String) { fun logObject(obj: Any?) { obj ?: return - PrintWriter(System.out).use { jsonSerializer.serialize(obj, it) } + // Flush rather than close: closing the writer would close System.out. + PrintWriter(System.out).apply { + jsonSerializer.serialize(obj, this) + flush() + } println() } + private fun deserialize(envelopeString: String): SentryEnvelope? = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + fun ensureNoErrors(response: ApolloResponse?) { response ?: throw RuntimeException("no response") assertFalse(response.hasErrors()) @@ -365,156 +231,39 @@ class TestHelper(backendUrl: String) { assertEquals(errorCount, response.errors?.size) } - fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { - val span = transaction.spans.firstOrNull { span -> span.op == op } - if (span == null) { - println("Unable to find span with op $op in transaction:") - logObject(transaction) - return false - } - - return true - } + fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean = + transaction.hasSpanWithOp(op) fun doesTransactionContainSpanWithOpAndDescription( transaction: SentryTransaction, op: String, description: String, - ): Boolean { - val span = - transaction.spans.firstOrNull { span -> span.op == op && span.description == description } - if (span == null) { - println("Unable to find span with op $op and description $description in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionContainSpanWithDescription( - transaction: SentryTransaction, - description: String, - ): Boolean { - val span = transaction.spans.firstOrNull { span -> span.description == description } - if (span == null) { - println("Unable to find span with description $description in transaction:") - logObject(transaction) - return false - } - - return true - } - - fun doesTransactionHaveTraceId(transaction: SentryTransaction, traceId: String): Boolean { - val spanContext = transaction.contexts.trace - if (spanContext?.traceId?.toString() != traceId) { - println("Unable to find trace ID $traceId in transaction:") - logObject(transaction) - return false - } + ): Boolean = transaction.hasSpanWithOpAndDescription(op, description) - return true - } - - fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { - val matches = transaction.contexts.trace?.operation == op - if (!matches) { - println("Unable to find transaction with op $op:") - logObject(transaction) - return false - } + fun doesTransactionHaveTraceId(transaction: SentryTransaction, traceId: String): Boolean = + transaction.hasTraceId(traceId) - return true - } + fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean = + transaction.hasOp(op) fun doesTransactionHave( transaction: SentryTransaction, op: String, featureFlag: FeatureFlag? = null, - ): Boolean { - val matches = transaction.contexts.trace?.operation == op - if (!matches) { - println("Unable to find transaction with op $op:") - logObject(transaction) - return false - } - - val foundFlag = transaction.contexts.trace?.data?.get(featureFlag?.flag) - if (featureFlag != null && foundFlag == null) { - println("Unable to find span with feature flag ${featureFlag?.flag}:") - logObject(transaction) - return false - } - if (featureFlag != null && foundFlag != featureFlag.result) { - println("Feature flag ${featureFlag?.flag} has unexpected result ${foundFlag}:") - logObject(transaction) - return false - } - - return true - } + ): Boolean = transaction.hasOp(op, featureFlag) fun doesTransactionHaveSpanWith( transaction: SentryTransaction, op: String, featureFlag: FeatureFlag? = null, noFeatureFlags: Boolean = false, - ): Boolean { - val foundSpan = transaction.spans.firstOrNull { span -> span.op == op } - if (foundSpan == null) { - println("Unable to find span with op $op:") - logObject(transaction) - return false - } + ): Boolean = transaction.hasSpanWithOp(op, featureFlag, noFeatureFlags) - val featureFlagNames = - foundSpan.data?.keys?.filter { it.startsWith("flag.evaluation.") } ?: emptyList() - if (noFeatureFlags && featureFlagNames.isNotEmpty()) { - println("Expected 0 feature flags but found ${featureFlagNames}:") - logObject(transaction) - return false - } - - val foundFlag = foundSpan.data?.get(featureFlag?.flag) - if (featureFlag != null && foundFlag == null) { - println("Unable to find span with feature flag ${featureFlag?.flag}:") - logObject(transaction) - return false - } - if (featureFlag != null && foundFlag != featureFlag.result) { - println("Feature flag ${featureFlag?.flag} has unexpected result ${foundFlag}:") - logObject(transaction) - return false - } + fun doesEventHaveExceptionMessage(event: SentryEvent, expectedMessage: String): Boolean = + event.hasExceptionMessage(expectedMessage) - return true - } - - fun doesEventHaveExceptionMessage(event: SentryEvent, expectedMessage: String): Boolean { - val exceptions = event.exceptions - if (exceptions == null) { - println("Unable to find exceptions in event") - return false - } - - val foundException = exceptions.firstOrNull { expectedMessage == it.value } - return foundException != null - } - - fun doesEventHaveFlag(event: SentryEvent, flag: String, result: Boolean): Boolean { - val featureFlags = event.contexts.featureFlags - if (featureFlags == null) { - println("Unable to find feature flags in event:") - return false - } - val foundFlag = - featureFlags.values.firstOrNull { featureFlag -> - println("checking flag ${featureFlag.flag}:${featureFlag.result}") - featureFlag.flag == flag && featureFlag.result == result - } - return foundFlag != null - } + fun doesEventHaveFlag(event: SentryEvent, flag: String, result: Boolean): Boolean = + event.hasFlag(flag, result) fun findJar(prefix: String, inDir: String = "build/libs"): File { val buildDir = File(inDir) diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/EnvelopeMatchers.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/EnvelopeMatchers.kt new file mode 100644 index 00000000000..16f7642f358 --- /dev/null +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/EnvelopeMatchers.kt @@ -0,0 +1,170 @@ +package io.sentry.test + +import io.sentry.JsonSerializer +import io.sentry.ProfileChunk +import io.sentry.SentryEnvelope +import io.sentry.SentryEnvelopeItem +import io.sentry.SentryEvent +import io.sentry.SentryItemType +import io.sentry.SentryLogEvents +import io.sentry.SentryMetricsEvents +import io.sentry.SentryOptions +import io.sentry.protocol.FeatureFlag +import io.sentry.protocol.SentryTransaction +import java.io.PrintWriter + +// Matchers return false instead of throwing, so tests can pick one envelope out of many. On a +// mismatch they print what they looked at, which is otherwise hard to see from a failed wait. + +private val serializer = JsonSerializer(SentryOptions.empty()) + +fun SentryEnvelope.event(): SentryEvent? = + firstItem(SentryItemType.Event) { it.getEvent(serializer) } + +fun SentryEnvelope.transaction(): SentryTransaction? = + firstItem(SentryItemType.Transaction) { it.getTransaction(serializer) } + +fun SentryEnvelope.logs(): SentryLogEvents? = + firstItem(SentryItemType.Log) { it.getLogs(serializer) } + +fun SentryEnvelope.metrics(): SentryMetricsEvents? = + firstItem(SentryItemType.TraceMetric) { it.getMetrics(serializer) } + +fun SentryEnvelope.profileChunk(): ProfileChunk? = + firstItem(SentryItemType.ProfileChunk) { item -> + item.data.inputStream().reader().use { serializer.deserialize(it, ProfileChunk::class.java) } + } + +/** Matches the transaction's own op and, if given, a feature flag in its trace data. */ +fun SentryTransaction.hasOp(op: String, featureFlag: FeatureFlag? = null): Boolean { + val trace = contexts.trace + if (trace?.operation != op) { + return mismatch("Unable to find transaction with op $op:", this) + } + return featureFlag == null || matchesFlag(trace.data, featureFlag) +} + +fun SentryTransaction.hasTraceId(traceId: String): Boolean { + if (contexts.trace?.traceId?.toString() != traceId) { + return mismatch("Unable to find trace ID $traceId in transaction:", this) + } + return true +} + +/** + * Matches a span with [op] and, if given, a feature flag in its data. With [noFeatureFlags] the + * span must carry no flag evaluations at all. + */ +fun SentryTransaction.hasSpanWithOp( + op: String, + featureFlag: FeatureFlag? = null, + noFeatureFlags: Boolean = false, +): Boolean { + val span = + spans.firstOrNull { it.op == op } ?: return mismatch("Unable to find span with op $op:", this) + val data = span.data ?: emptyMap() + if (noFeatureFlags && data.keys.any { it.startsWith("flag.evaluation.") }) { + return mismatch("Expected no feature flags on span with op $op:", this) + } + return featureFlag == null || matchesFlag(data, featureFlag) +} + +fun SentryTransaction.hasSpanWithOpAndDescription(op: String, description: String): Boolean { + if (spans.none { it.op == op && it.description == description }) { + return mismatch( + "Unable to find span with op $op and description $description in transaction:", + this, + ) + } + return true +} + +fun SentryLogEvents.hasLogWithBody(body: String): Boolean { + if (items.none { it.body == body }) { + return mismatch("Unable to find log item with body $body in logs:", this) + } + return true +} + +fun SentryLogEvents.hasLogWithAttribute(body: String, key: String, value: Any?): Boolean { + val log = + items.firstOrNull { it.body == body } + ?: return mismatch("Unable to find log item with body $body in logs:", this) + val attribute = + log.attributes?.get(key) + ?: return mismatch("Unable to find attribute $key on log with body $body:", log) + if (attribute.value != value) { + return mismatch("Attribute $key is ${attribute.value}, expected $value on log $body:", log) + } + return true +} + +fun SentryMetricsEvents.hasMetric( + name: String, + type: String, + value: Double, + unit: String? = null, +): Boolean { + val found = items.any { + it.name == name && it.type == type && it.value == value && (unit == null || it.unit == unit) + } + if (!found) { + return mismatch("Unable to find metric $name of type $type with value $value in metrics:", this) + } + return true +} + +fun SentryMetricsEvents.hasMetricWithAttribute(name: String, key: String, value: Any?): Boolean { + val metric = + items.firstOrNull { it.name == name } + ?: return mismatch("Unable to find metric with name $name in metrics:", this) + val attribute = + metric.attributes?.get(key) + ?: return mismatch("Unable to find attribute $key on metric $name:", metric) + if (attribute.value != value) { + return mismatch( + "Attribute $key is ${attribute.value}, expected $value on metric $name:", + metric, + ) + } + return true +} + +fun SentryEvent.hasExceptionMessage(message: String): Boolean { + if (exceptions?.none { it.value == message } != false) { + return mismatch("Unable to find exception with message $message in event:", this) + } + return true +} + +fun SentryEvent.hasFlag(flag: String, result: Boolean): Boolean { + if (contexts.featureFlags?.values?.none { it.flag == flag && it.result == result } != false) { + return mismatch("Unable to find feature flag $flag=$result in event:", this) + } + return true +} + +private inline fun SentryEnvelope.firstItem( + type: SentryItemType, + read: (SentryEnvelopeItem) -> T?, +): T? = items.firstOrNull { it.header.type == type }?.let(read) + +private fun matchesFlag(data: Map?, featureFlag: FeatureFlag): Boolean { + val result = data?.get(featureFlag.flag) + if (result != featureFlag.result) { + println("Feature flag ${featureFlag.flag} is $result, expected ${featureFlag.result}") + return false + } + return true +} + +private fun mismatch(message: String, subject: Any): Boolean { + println(message) + // Flush rather than close: closing the writer would close System.out. + PrintWriter(System.out).apply { + serializer.serialize(subject, this) + println() + flush() + } + return false +} diff --git a/sentry-test-support/src/test/kotlin/io/sentry/test/EnvelopeMatchersTest.kt b/sentry-test-support/src/test/kotlin/io/sentry/test/EnvelopeMatchersTest.kt new file mode 100644 index 00000000000..68a71809e29 --- /dev/null +++ b/sentry-test-support/src/test/kotlin/io/sentry/test/EnvelopeMatchersTest.kt @@ -0,0 +1,92 @@ +package io.sentry.test + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.protocol.FeatureFlag +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +class EnvelopeMatchersTest { + private val server = MockSentryServer() + + @BeforeTest + fun setup() { + initForTest { + it.dsn = server.dsn + it.tracesSampleRate = 1.0 + it.logs.isEnabled = true + } + } + + @AfterTest + fun teardown() { + Sentry.close() + server.close() + } + + @Test + fun `matches a transaction by op, trace ID and span`() { + val transaction = Sentry.startTransaction("name", "http.server") + transaction.startChild("db.query", "select 1").apply { + setData("flag.evaluation.my-flag", true) + finish() + } + transaction.finish() + Sentry.flush(5000) + + val received = server.awaitEnvelope { it.transaction() != null }.transaction()!! + + assertThat(received.hasOp("http.server")).isTrue() + assertThat(received.hasOp("other")).isFalse() + assertThat(received.hasTraceId(transaction.spanContext.traceId.toString())).isTrue() + assertThat(received.hasSpanWithOpAndDescription("db.query", "select 1")).isTrue() + assertThat(received.hasSpanWithOp("db.query", FeatureFlag("flag.evaluation.my-flag", true))) + .isTrue() + assertThat(received.hasSpanWithOp("db.query", FeatureFlag("flag.evaluation.my-flag", false))) + .isFalse() + assertThat(received.hasSpanWithOp("db.query", noFeatureFlags = true)).isFalse() + } + + @Test + fun `matches an event by exception message and feature flag`() { + Sentry.addFeatureFlag("my-flag", true) + Sentry.captureException(RuntimeException("boom")) + Sentry.flush(5000) + + val event = server.awaitEnvelope { it.event() != null }.event()!! + + assertThat(event.hasExceptionMessage("boom")).isTrue() + assertThat(event.hasExceptionMessage("other")).isFalse() + assertThat(event.hasFlag("my-flag", true)).isTrue() + assertThat(event.hasFlag("my-flag", false)).isFalse() + } + + @Test + fun `matches logs by body and attribute`() { + Sentry.setAttribute("user.type", "admin") + Sentry.logger().info("hello") + Sentry.flush(5000) + + val logs = server.awaitEnvelope { it.logs() != null }.logs()!! + + assertThat(logs.hasLogWithBody("hello")).isTrue() + assertThat(logs.hasLogWithBody("other")).isFalse() + assertThat(logs.hasLogWithAttribute("hello", "user.type", "admin")).isTrue() + assertThat(logs.hasLogWithAttribute("hello", "user.type", "guest")).isFalse() + } + + @Test + fun `matches metrics by value and attribute`() { + Sentry.setAttribute("user.type", "admin") + Sentry.metrics().gauge("my.gauge", 5.0) + Sentry.flush(5000) + + val metrics = server.awaitEnvelope { it.metrics() != null }.metrics()!! + + assertThat(metrics.hasMetric("my.gauge", "gauge", 5.0)).isTrue() + assertThat(metrics.hasMetric("my.gauge", "gauge", 6.0)).isFalse() + assertThat(metrics.hasMetricWithAttribute("my.gauge", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("my.gauge", "user.type", "guest")).isFalse() + } +} diff --git a/test/system-test-runner.py b/test/system-test-runner.py index ed3fda97dcd..33cef830a32 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -874,7 +874,6 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-console", "false", "true", "false"), ModuleConfig("sentry-samples-console-otlp", "false", "true", "false"), ModuleConfig("sentry-samples-console-opentelemetry-noagent", "false", "true", "false"), - ModuleConfig("sentry-samples-logback", "false", "true", "false"), ModuleConfig("sentry-samples-log4j2", "false", "true", "false"), ModuleConfig("sentry-samples-jul", "false", "true", "false"), ]