diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md index d7b28f6d260..0a066c76de0 100644 --- a/.claude/skills/test/SKILL.md +++ b/.claude/skills/test/SKILL.md @@ -1,13 +1,14 @@ --- name: test -description: Run tests for a specific SDK module. Use when asked to "run tests", "test module", "run unit tests", "run system tests", "run e2e tests", or test a specific class. Auto-detects unit vs system tests. Supports interactive mode. +description: Run tests for a specific SDK module. Use when asked to "run tests", "test module", "run unit tests", "run integration tests", "run e2e tests", or test a specific class. Auto-detects unit tests vs integration test suites. Supports interactive mode. allowed-tools: Bash, Read, Glob, AskUserQuestion argument-hint: [interactive] [test-class-filter] --- # Run Tests -Run tests for a specific module. Auto-detects whether to run unit tests or system tests. +Run tests for a specific module. Auto-detects whether to run unit tests or an integration test +suite. ## Step 0: Check for Interactive Mode @@ -19,7 +20,7 @@ In interactive mode, use AskUserQuestion at decision points as described in the The argument can be either: - A **file path** (e.g., `@sentry/src/test/java/io/sentry/ScopesTest.kt`) -- A **module name** (e.g., `sentry-android-core`, `sentry-samples-spring-boot-4`) +- A **module name** (e.g., `sentry-android-core`, `sentry-spring-boot-4`) - A **module name + test filter** (e.g., `sentry ScopesTest`) Extract the module name and optional test class filter from the argument. @@ -30,10 +31,13 @@ Extract the module name and optional test class filter from the argument. | Signal | Test Type | |--------|-----------| -| Path contains `sentry-samples/` | System test | -| Module name starts with `sentry-samples-` | System test | +| Path contains `/src/integrationTest*/` or `/src/agentFixture/` | Integration test suite | +| Path contains `sentry-samples/`, or module name starts with `sentry-samples-` | None: samples have no tests | | Everything else | Unit test | +For a sample, tell the user that samples have no tests, and that the end-to-end tests for what +the sample shows live in the module it uses (see the `e2e_tests` rule). + ## Step 3a: Run Unit Tests Determine the Gradle test task: @@ -60,30 +64,20 @@ Without a filter: ./gradlew '::' --info ``` -## Step 3b: Run System Tests - -System tests require the Python-based test runner which manages a mock Sentry server and sample app lifecycle. - -1. Ensure the Python venv exists: -```bash -test -d .venv || make setupPython -``` +## Step 3b: Run an Integration Test Suite -2. Extract the sample module name. For file paths like `sentry-samples//src/...`, the sample module is the directory name (e.g., `sentry-samples-spring`). +The suite's task is named after its source set directory: `src/integrationTestWebflux/...` runs +with `integrationTestWebflux`. The OpenTelemetry agent suite runs in two tasks, one per init mode, +so for `src/integrationTestOtelAgent/` or `src/agentFixture/` run both: -3. Run the system test: ```bash -.venv/bin/python test/system-test-runner.py test --module +./gradlew '::integrationTestOtelAgent' '::integrationTestOtelAgentWithoutAutoInit' ``` -This starts the mock Sentry server, starts the sample app (Spring Boot/Tomcat/CLI), runs tests via `./gradlew :sentry-samples::systemTest`, and cleans up afterwards. - -To run **every** system test instead of one module, use the Makefile targets — they also create the -venv for you: +Otherwise, with or without a test class filter: ```bash -make systemTest # all system tests (--all) -make systemTestInteractive # pick the setups to run (--interactive) +./gradlew '::' --tests="**" ``` ## Step 4: Report Results diff --git a/.cursor/rules/e2e_tests.mdc b/.cursor/rules/e2e_tests.mdc index 17e088774b5..1785e61b5c1 100644 --- a/.cursor/rules/e2e_tests.mdc +++ b/.cursor/rules/e2e_tests.mdc @@ -3,26 +3,82 @@ alwaysApply: false description: Java SDK End to End Tests --- -# Java SDK End to End Tests (System Tests) +# Java SDK End to End Tests -The samples in the `sentry-samples` directory are used to run end to end tests against them. +End-to-end tests live in the module whose code they test, not in `sentry-samples`. Samples have no +tests. A test runs the code the way an app would and asserts what reaches Sentry over the real HTTP +transport. -There is a python script (`system-test-runner.py`) that can be used to run one (using `--module SAMPLE_NAME`) or all (using `--all`) system tests. +## MockSentryServer -The script has an interactive mode (`-i`) which allows selection of test setups to execute, whether to run the tests or just prepare infrastructure for testing from IDE. +`MockSentryServer` (`sentry-test-support`) is an in-process HTTP server on a free port. Point the +SDK at `server.dsn`, then: -The tests run a mock Sentry server via `system-test-sentry-server.py`. Any system under test will then have a DSN set that reflects this local mock server like `http://502f25099c204a2fbf4cb16edc5975d1@localhost:8000/0`. -By using this local DSN, the system under test sends events to the local mock server. -The tests can then use `TestHelper` to assert envelopes that were received by the mock server. -`TestHelper` uses HTTP requests to retrieve the JSON payload of the received events and deserialize them back to objects for easier assertion. -Tests can then assert events, transactions, logs etc. similar to how they would appear in `beforeSend` and similar callbacks. +- `awaitEnvelope(timeoutMillis) { predicate }` polls for a matching envelope and fails with what it + received on timeout. +- `assertNoEnvelope(millis) { predicate }` checks that nothing matching arrives. +- `MockSentryServer { ... }` takes a hook that runs before each check while waiting, for example to + flush an OpenTelemetry tracer provider. -`TestHelper` has a lot of helper methods for asserting, e.g. by span name, log body etc. +`EnvelopeMatchers.kt` has the accessors and matchers: `event()`, `transaction()`, `logs()`, +`metrics()`, `profileChunk()`, `hasSpanWithOp`, `hasFlag`, `hasLogWithAttribute`, `hasMetric` and +so on. -The end to end tests either expect the system under test to either be running on a server or call `java -jar` to execute a CLI system under test. +Call `Sentry.flush()` before awaiting when Sentry runs in the test's JVM: logs and metrics are +batched. Envelopes that arrive late can outlive a server's `reset()`, so tests that share a server +match on values only they produce. -For Spring Boot, we spin up the Spring Boot server. The tests then send requests to that server and assert what is sent to Sentry. +## Where the tests are -End to end tests are also executed on CI using a matrix build, as defined in `.github/workflows/system-tests-backend.yml`. +- **Core and plain integrations** use their module's `src/test`. Examples: `SentryEndToEndTest` + (`sentry`), `SentryAppenderIntegrationTest` (`sentry-logback`, `sentry-log4j2`, loading the + appender from XML), `SentryHandlerIntegrationTest` (`sentry-jul`), + `ContinuousProfilerEndToEndTest` (`sentry-async-profiler`), `SentryKafkaEndToEndTest` + (`sentry-kafka`), `AgentlessEndToEndTest` and `OtlpEndToEndTest` (`sentry-opentelemetry-*`). +- **Spring Boot** (`sentry-spring-boot`, `sentry-spring-boot-jakarta`, `sentry-spring-boot-4`) has + one JVM test suite per flavor, because a flavor is a classpath: Sentry's auto-configuration and + OpenTelemetry mode switch on which classes are present. + - `integrationTest`: servlet app. + - `integrationTestWebflux`: reactive app. + - `integrationTestOtelNoAgent`: `sentry-opentelemetry-agentless-spring`. + - `integrationTestOtelAgent` and `integrationTestOtelAgentWithoutAutoInit`: the built agent. + - `integrationTestOtlp` (Boot 4): `sentry-opentelemetry-otlp-spring`. + - `integrationTestLog4j2` (Boot 3, 4): Log4j2 instead of Logback, for the auto-configured + Log4j2 appender. -Some of the samples are tested in multiple ways, e.g. with OpenTelemetry Agent auto init turned on and off. + Each suite has a base class with `@SpringBootTest(RANDOM_PORT)` and a `@DynamicPropertySource` + that sets `sentry.dsn`. Subclasses share one context and one server. +- **Plain Spring** (`sentry-spring`, `sentry-spring-jakarta`, `sentry-spring-7`) has an + `integrationTest` suite that starts the app in an embedded Tomcat, the way a WAR starts. Spring + Boot stays off its classpath, because `SentrySpringServletContainerInitializer` behaves + differently with it. + +## The OpenTelemetry agent + +The agent must not run in the test's JVM, because it would instrument the test's own HTTP client +and server. The Spring Boot modules have an `agentFixture` source set with the app. `AgentApp` +starts it once per test JVM in a child process, with `ChildJvmApp` and the agent jar passed through +`ChildJvmTestArguments`. The suite's task runs with the agent initializing Sentry, and +`integrationTestOtelAgentWithoutAutoInit` runs the same tests with the Spring Boot starter doing +it. Gradle runs the two in parallel. + +## Running them + +- The `spring-boot-{2,3,4}-matrix.yml` workflows run the suites against each supported Spring Boot + version, by setting the version in `gradle/libs.versions.toml`. The suites aren't part of + `check`, so a new suite only runs on CI once it's added to its matrix. + +## Gotchas + +- Don't use the context's `TestRestTemplate`. It comes from the `RestTemplateBuilder` that Sentry + customizes, and that the OpenTelemetry starter instruments, so it adds or replaces trace headers + on the requests a test sends. Build one outside the context. +- Sentry exports OpenTelemetry spans in batches every 5 seconds. In-process, flush the tracer + provider from the server's hook. With the agent this isn't possible, so each test waits for a + batch and negative checks wait at least 6 seconds. +- Sentry is global, so a second live Spring context in an OpenTelemetry suite takes it over. Keep + one live context per JVM (`@DirtiesContext` when a test needs its own). +- The async profiler rejects a traces path with a `.` anywhere in its absolute form. Put the path + in `java.io.tmpdir`. +- Kafka uses an in-JVM broker (`@EmbeddedKafka` or spring-kafka-test's broker classes), so no + Docker is needed. diff --git a/.cursor/rules/new_module.mdc b/.cursor/rules/new_module.mdc index d499acf03a0..b9fdde3df6a 100644 --- a/.cursor/rules/new_module.mdc +++ b/.cursor/rules/new_module.mdc @@ -69,8 +69,8 @@ static { addIntegrationToSdkVersion("{IntegrationName}"); ``` -7. If adding a JVM sample, add E2E (system) tests, following the structure we have in the existing JVM examples. - The test should then be added to `test/system-test-runner.py` and `.github/workflows/system-tests-backend.yml`. +7. Samples have no tests. Add end-to-end coverage of what the sample shows to the module it uses, + against `MockSentryServer`, as described in the `e2e_tests` rule. `sentry-bom` and the root `build.gradle.kts` need no change — they iterate over subprojects. diff --git a/.github/workflows/spring-boot-4-matrix.yml b/.github/workflows/spring-boot-4-matrix.yml index 5c197a243b9..8f91289db98 100644 --- a/.github/workflows/spring-boot-4-matrix.yml +++ b/.github/workflows/spring-boot-4-matrix.yml @@ -66,7 +66,8 @@ jobs: :sentry-spring-boot-4:integrationTestOtelNoAgent \ :sentry-spring-boot-4:integrationTestLog4j2 \ :sentry-spring-boot-4:integrationTestOtelAgent \ - :sentry-spring-boot-4:integrationTestOtelAgentWithoutAutoInit + :sentry-spring-boot-4:integrationTestOtelAgentWithoutAutoInit \ + :sentry-spring-boot-4:integrationTestOtlp - name: Upload test results if: always() diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml deleted file mode 100644 index db4c9bb8457..00000000000 --- a/.github/workflows/system-tests-backend.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: 'System Tests Backend' - -on: - push: - branches: - - main - pull_request: - paths-ignore: - - '*android*/**' - - 'sentry-compose/**' - - 'sentry-samples/sentry-samples-android/**' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - system-test: - runs-on: ubuntu-latest - continue-on-error: true - env: - SENTRY_URL: http://127.0.0.1:8000 - GRADLE_ENCRYPTION_KEY: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - strategy: - fail-fast: false - matrix: - sample: [ "sentry-samples-console" ] - agent: [ "false" ] - agent-auto-init: [ "true" ] - include: - - sample: "sentry-samples-console-otlp" - agent: "false" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-4-otlp" - agent: "false" - agent-auto-init: "true" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: 'recursive' - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.10' - - - name: Install Python dependencies - run: | - python3 -m pip install --upgrade pip - python3 -m pip install -r requirements.txt - - - name: Set up Java - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 - with: - distribution: 'temurin' - java-version: '21' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - with: - cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - - - name: Build and run system tests - run: | - python3 test/system-test-runner.py test --module "${{ matrix.sample }}" --agent "${{ matrix.agent }}" --auto-init "${{ matrix.agent-auto-init }}" --build "true" - - - name: Upload test results - if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 - with: - name: test-results-${{ matrix.sample }}-${{ matrix.agent }}-${{ matrix.agent-auto-init }}-system-test - path: | - **/build/reports/* - sentry-mock-server.txt - spring-server.txt - - - name: Test Report - uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 - if: always() - with: - name: JUnit System Tests ${{ matrix.sample }} - path: | - **/build/test-results/**/*.xml - reporter: java-junit - output-to: step-summary - fail-on-error: false diff --git a/AGENTS.md b/AGENTS.md index badfba1b3e4..1ffec6c02b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ rule file in `.cursor/rules/`: | `continuous_profiling_jvm` | `sentry-async-profiler`, `IContinuousProfiler`, `ProfileChunk`, JFR files, `ProfileLifecycle` | | `opentelemetry` | `sentry-opentelemetry-*`, agent vs agentless, span processing, sampling, context propagation | | `new_module` | Adding a new integration or sample module | -| `e2e_tests` | System tests, sample applications, `system-test-runner.py`, mock Sentry server | +| `e2e_tests` | End-to-end tests, `MockSentryServer`, integration test suites, the OpenTelemetry agent fixture | Rules can be combined — a tracing scope issue may need both `scopes` and `opentelemetry`. There is no rule for Android profiling yet; read the `sentry-android-core` profiling code @@ -55,7 +55,7 @@ The project uses **Gradle** with Kotlin DSL. Key build files: ``` To run tests, use the `test` skill rather than composing the Gradle invocation by hand — it -resolves the per-module test task and the unit-test vs system-test split for you. +resolves the per-module test task, including integration test suites, for you. ### Android-Specific Commands ```bash @@ -109,8 +109,7 @@ The repository is organized into multiple modules: - **Other**: `sentry-spotlight`, `sentry-kotlin-extensions`, `sentry-android-distribution` ### Utility Modules -- **`sentry-test-support`** - Shared test utilities -- **`sentry-system-test-support`** - System testing infrastructure +- **`sentry-test-support`** - Shared test utilities, including `MockSentryServer` and envelope matchers - **`sentry-samples`** - Example applications - **`sentry-bom`** - Bill of Materials for dependency management @@ -158,7 +157,7 @@ PR description why the broad catch is necessary. ### Testing Requirements - Write comprehensive unit tests for new features - Android modules require both unit tests and instrumented tests where applicable -- System tests validate end-to-end functionality with sample applications +- End-to-end tests run in the module they test, against `MockSentryServer`. Samples have no tests - **Assertions**: For new unit tests, prefer [Google Truth](https://truth.dev/) (`com.google.common.truth.Truth.assertThat`) over `kotlin.test`/JUnit assertions for its readable, fluent API. Keep using `kotlin.test` for test structure (`@Test`, `assertFailsWith`). See `sentry/src/test/java/io/sentry/DsnTest.kt` for the style. Don't rewrite existing `kotlin.test` assertions solely to switch libraries. - Truth is wired into the `sentry` module. When adding Truth-based tests to another module, add `testImplementation(libs.google.truth)` to that module's `build.gradle.kts`. diff --git a/Makefile b/Makefile index 3941eb93ec3..7a57ed54e09 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical setupPython systemTest systemTestInteractive check preMerge publish +.PHONY: all clean compile javadocs dryRelease update checkFormat api assembleUiTestRelease assembleUiTestCriticalRelease runUiTestCritical check preMerge publish all: stop clean javadocs compile assembleUiTests: assembleUiTestRelease @@ -46,20 +46,6 @@ assembleUiTestCriticalRelease: runUiTestCritical: ./scripts/test-ui-critical.sh -# Create the Python virtual environment for system tests, and install the necessary dependencies -setupPython: - @test -d .venv || python3 -m venv .venv - .venv/bin/pip install --upgrade pip - .venv/bin/pip install -r requirements.txt - -# Run system tests for sample applications -systemTest: setupPython - .venv/bin/python test/system-test-runner.py test --all - -# Run system tests with interactive module selection -systemTestInteractive: setupPython - .venv/bin/python test/system-test-runner.py test --interactive - # Run tests and lint check: ./gradlew check diff --git a/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts b/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts deleted file mode 100644 index a21079e1336..00000000000 --- a/build-logic/src/main/kotlin/io.sentry.systemtest.gradle.kts +++ /dev/null @@ -1,38 +0,0 @@ -import io.sentry.gradle.SystemTestExtension -import org.gradle.api.tasks.ClasspathNormalizer - -val systemTest = extensions.create("sentrySystemTest") - -// The sample system tests launch the packaged app (war/shadowJar/bootJar) from build/libs as a -// separate process, so the archive is a real input even though it is not on the test classpath. -// Agent-based samples are additionally launched with -javaagent:, another runtime -// input not on the classpath. See test/system-test-runner.py. -tasks.matching { it.name == "systemTest" }.configureEach { - val archiveTask = - listOf("war", "shadowJar", "bootJar").firstOrNull { it in tasks.names } - ?: throw GradleException( - "io.sentry.systemtest is applied to $path but none of war/shadowJar/bootJar " + - "exist to provide the launched app archive for the systemTest task" - ) - // Declaring the archive as an input also wires the dependency on its producing task. - inputs - .files(tasks.named(archiveTask)) - .withPropertyName("appArchive") - .withNormalizer(ClasspathNormalizer::class.java) - - if (systemTest.usesOpenTelemetryAgent.get()) { - // The runner builds the agent and launches the app with -javaagent before invoking this task, - // so the agent jar is tracked for content only (by path, no cross-project task dependency): a - // change to it makes systemTest out of date even though it runs outside the test JVM. - val version = providers.gradleProperty("versionName").get() - inputs - .files( - rootProject.layout.projectDirectory.file( - "sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/" + - "sentry-opentelemetry-agent-$version.jar" - ) - ) - .withPropertyName("openTelemetryAgent") - .withNormalizer(ClasspathNormalizer::class.java) - } -} diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt b/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt deleted file mode 100644 index 9111ce17b1f..00000000000 --- a/build-logic/src/main/kotlin/io/sentry/gradle/SystemTestExtension.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.sentry.gradle - -import org.gradle.api.provider.Property - -/** Configuration for the `io.sentry.systemtest` convention plugin. */ -abstract class SystemTestExtension { - /** - * Set to `true` for samples that the system-test runner launches with the Sentry OpenTelemetry - * Java agent (`-javaagent`). The agent jar is then tracked as a `systemTest` input so the task - * re-runs when the agent changes, even though it is started outside the test JVM. - */ - abstract val usesOpenTelemetryAgent: Property - - init { - usesOpenTelemetryAgent.convention(false) - } -} diff --git a/build.gradle.kts b/build.gradle.kts index 898b429cb5d..3aceb123fba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -87,8 +87,7 @@ apiValidation { "test-app-size", "sentry-samples-netflix-dgs", "sentry-samples-console-otlp", - "sentry-test-support", - "sentry-system-test-support" + "sentry-test-support" ) ) } @@ -126,7 +125,7 @@ subprojects { } } - if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-system-test-support" && this.name != "sentry-test-support") { + if (!this.name.contains("sample") && !this.name.contains("integration-tests") && this.name != "sentry-test-support") { apply() apply() diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 62c248385cd..00000000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -certifi==2026.10.19 -charset-normalizer==3.4.2 -idna==3.15 -requests==2.33.0 -urllib3==2.8.0 diff --git a/sentry-async-profiler/build.gradle.kts b/sentry-async-profiler/build.gradle.kts index 3bcdefd1d82..fbb4a8fc353 100644 --- a/sentry-async-profiler/build.gradle.kts +++ b/sentry-async-profiler/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { // tests testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(projects.sentryTestSupport) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) diff --git a/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/ContinuousProfilerEndToEndTest.kt b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/ContinuousProfilerEndToEndTest.kt new file mode 100644 index 00000000000..25f8d0b9940 --- /dev/null +++ b/sentry-async-profiler/src/test/java/io/sentry/asyncprofiler/ContinuousProfilerEndToEndTest.kt @@ -0,0 +1,48 @@ +package io.sentry.asyncprofiler + +import com.google.common.truth.Truth.assertThat +import io.sentry.ProfileLifecycle +import io.sentry.Sentry +import io.sentry.test.MockSentryServer +import io.sentry.test.initForTest +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import kotlin.test.AfterTest +import kotlin.test.Test + +/** Profiles a transaction with the async profiler and sends both over the real HTTP transport. */ +class ContinuousProfilerEndToEndTest { + private val server = MockSentryServer() + + @AfterTest + fun teardown() { + Sentry.close() + server.close() + } + + @Test + fun `a transaction starts a profile whose chunk reaches Sentry`() { + initForTest { + it.dsn = server.dsn + it.tracesSampleRate = 1.0 + it.profileSessionSampleRate = 1.0 + it.profileLifecycle = ProfileLifecycle.TRACE + // The profiler rejects a traces path with a dot anywhere in it, which the module's absolute + // path can have. + it.profilingTracesDirPath = + "${System.getProperty("java.io.tmpdir")}/sentry-profiler-end-to-end-test" + } + + val transaction = Sentry.startTransaction("transaction name", "op") + val span = transaction.startChild("child") + Thread.sleep(100) + span.finish() + transaction.finish() + + val sent = + server.awaitEnvelope { it.transaction()?.transaction == "transaction name" }.transaction()!! + val profilerId = sent.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + } +} diff --git a/sentry-kafka/build.gradle.kts b/sentry-kafka/build.gradle.kts index 37240c8305e..eb293ada057 100644 --- a/sentry-kafka/build.gradle.kts +++ b/sentry-kafka/build.gradle.kts @@ -28,10 +28,13 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.google.truth) testImplementation(libs.kotlin.test.junit) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) testImplementation(libs.kafka.clients) + // For an in-JVM broker. It brings no Spring application into the tests. + testImplementation(libs.spring.kafka3.test) } tasks.withType().configureEach { diff --git a/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaEndToEndTest.kt b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaEndToEndTest.kt new file mode 100644 index 00000000000..2c42817a4ec --- /dev/null +++ b/sentry-kafka/src/test/kotlin/io/sentry/kafka/SentryKafkaEndToEndTest.kt @@ -0,0 +1,103 @@ +package io.sentry.kafka + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.test.MockSentryServer +import io.sentry.test.initForTest +import io.sentry.test.transaction +import java.time.Duration +import java.util.Properties +import kotlin.test.AfterTest +import kotlin.test.Test +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.clients.consumer.KafkaConsumer +import org.apache.kafka.clients.producer.KafkaProducer +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.clients.producer.ProducerRecord +import org.apache.kafka.common.serialization.StringDeserializer +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.kafka.test.EmbeddedKafkaKraftBroker + +/** + * Sends a message through the plain Kafka client to an in-JVM KRaft broker, so no Docker is needed, + * and checks that both sides reach Sentry over the real HTTP transport. + */ +class SentryKafkaEndToEndTest { + private val server = MockSentryServer() + private val kafka = EmbeddedKafkaKraftBroker(1, 1, TOPIC).apply { afterPropertiesSet() } + + @AfterTest + fun teardown() { + Sentry.close() + kafka.destroy() + server.close() + } + + @Test + fun `a message is traced where it's published and where it's processed`() { + initForTest { + it.dsn = server.dsn + it.tracesSampleRate = 1.0 + it.isEnableQueueTracing = true + } + + val transaction = Sentry.startTransaction("kafka-demo", "demo") + transaction.makeCurrent().use { + SentryKafkaProducer.wrap(KafkaProducer(producerProperties())).use { producer + -> + producer.send(ProducerRecord(TOPIC, "sentry-kafka message")).get() + } + } + transaction.finish() + + KafkaConsumer(consumerProperties()).use { consumer -> + consumer.subscribe(listOf(TOPIC)) + val record = pollOne(consumer) + SentryKafkaConsumerTracing.withTracing(record, Runnable {}) + } + + val producer = + server.awaitEnvelope { it.transaction()?.transaction == "kafka-demo" }.transaction()!! + assertThat(producer.spans.map { it.op }).contains("queue.publish") + + val consumer = + server + .awaitEnvelope { it.transaction()?.contexts?.trace?.operation == "queue.process" } + .transaction()!! + val trace = consumer.contexts.trace!! + assertThat(trace.origin).isEqualTo(SentryKafkaConsumerTracing.TRACE_ORIGIN) + assertThat(trace.data).containsEntry("messaging.system", "kafka") + // The producer's span puts the trace in the message's headers, and the consumer continues it. + assertThat(trace.traceId).isEqualTo(producer.contexts.trace!!.traceId) + } + + private fun pollOne(consumer: KafkaConsumer): ConsumerRecord { + repeat(20) { + consumer.poll(Duration.ofMillis(500)).firstOrNull()?.let { + return it + } + } + error("No message arrived") + } + + private fun producerProperties(): Properties = + Properties().apply { + put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.brokersAsString) + put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java.name) + put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java.name) + } + + private fun consumerProperties(): Properties = + Properties().apply { + put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.brokersAsString) + put(ConsumerConfig.GROUP_ID_CONFIG, "sentry-kafka-end-to-end-test") + put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest") + put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java.name) + put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java.name) + } + + private companion object { + const val TOPIC = "sentry-topic" + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/api/sentry-opentelemetry-agentless.api b/sentry-opentelemetry/sentry-opentelemetry-agentless/api/sentry-opentelemetry-agentless.api new file mode 100644 index 00000000000..45011c308e2 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/api/sentry-opentelemetry-agentless.api @@ -0,0 +1,9 @@ +public final class io/sentry/opentelemetry/agent/AgentlessMarker { + public fun ()V +} + +public final class io/sentry/opentelemetry/agentless/BuildConfig { + public static final field SENTRY_OPENTELEMETRY_AGENTLESS_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts index 72508d73737..32d029746c1 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/build.gradle.kts @@ -1,10 +1,17 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { `java-library` id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.buildconfig) id("io.sentry.animalsniffer") } +tasks.named("compileKotlin") { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + dependencies { api(projects.sentry) implementation(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) @@ -13,8 +20,17 @@ dependencies { api(libs.otel.semconv) api(libs.otel.semconv.incubating) api(libs.otel.extension.autoconfigure) + + testImplementation(projects.sentryAsyncProfiler) + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.google.truth) } +// Sentry's OpenTelemetry customizer only initializes Sentry when the environment asks it to. +tasks.test { environment("SENTRY_AUTO_INIT", "true") } + buildConfig { useJavaOutput() packageName("io.sentry.opentelemetry.agentless") diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentless/src/test/kotlin/io/sentry/opentelemetry/agentless/AgentlessEndToEndTest.kt b/sentry-opentelemetry/sentry-opentelemetry-agentless/src/test/kotlin/io/sentry/opentelemetry/agentless/AgentlessEndToEndTest.kt new file mode 100644 index 00000000000..8f9d5d0fb8a --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-agentless/src/test/kotlin/io/sentry/opentelemetry/agentless/AgentlessEndToEndTest.kt @@ -0,0 +1,163 @@ +package io.sentry.opentelemetry.agentless + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.api.GlobalOpenTelemetry +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk +import io.sentry.Breadcrumb +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.Sentry +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.protocol.Message +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.metrics +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * Sets Sentry up the way an app without the agent does: the OpenTelemetry SDK's autoconfiguration + * loads Sentry's customizer, which initializes Sentry and has it trace through OpenTelemetry. + */ +class AgentlessEndToEndTest { + private val server = MockSentryServer() + private var openTelemetry: OpenTelemetrySdk? = null + + @AfterTest + fun teardown() { + openTelemetry?.close() + GlobalOpenTelemetry.resetForTest() + Sentry.close() + server.close() + SENTRY_PROPERTIES.forEach { System.clearProperty(it) } + } + + @Test + fun `Sentry set up by OpenTelemetry reports events, a profiled transaction and metrics`() { + // The customizer's Sentry.init reads these as external configuration. + System.setProperty("sentry.dsn", server.dsn) + System.setProperty("sentry.traces-sample-rate", "1.0") + System.setProperty("sentry.profile-session-sample-rate", "1.0") + System.setProperty("sentry.profile-lifecycle", "TRACE") + // The profiler rejects a traces path with a dot anywhere in it, which the module's absolute + // path can have. + System.setProperty( + "sentry.profiling-traces-dir-path", + "${System.getProperty("java.io.tmpdir")}/sentry-agentless-end-to-end-test", + ) + val openTelemetry = + AutoConfiguredOpenTelemetrySdk.builder() + .setResultAsGlobal() + .addPropertiesSupplier { + mapOf( + "otel.logs.exporter" to "none", + "otel.metrics.exporter" to "none", + "otel.traces.exporter" to "none", + ) + } + .build() + .openTelemetrySdk + this.openTelemetry = openTelemetry + + Sentry.configureScope { + it.addEventProcessor( + object : EventProcessor { + override fun process(event: SentryEvent, hint: Hint): SentryEvent = event.apply { + if ((level?.ordinal ?: 0) > SentryLevel.INFO.ordinal) { + addBreadcrumb(Breadcrumb("Processed by the event processor")) + } + } + } + ) + } + Sentry.withScope { + it.level = SentryLevel.FATAL + Sentry.captureMessage("Fatal message!") + } + Sentry.captureMessage("Some warning!", SentryLevel.WARNING) + Sentry.addFeatureFlag("my-feature-flag", true) + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + Sentry.metrics().gauge("gaugeMetric", 5.0) + Sentry.metrics().distribution("distributionMetric", 7.0) + Sentry.captureException(RuntimeException("Some error!")) + Sentry.captureEvent( + SentryEvent().apply { + message = Message().apply { message = "Detailed event" } + fingerprints = listOf("NewClientDebug") + level = SentryLevel.DEBUG + } + ) + repeat(10) { i -> + Sentry.captureEvent( + SentryEvent().apply { message = Message().apply { message = "event $i" } } + ) + } + val transaction = Sentry.startTransaction("transaction name", "op") + val span = transaction.startChild("child") + Thread.sleep(100) + span.finish() + transaction.finish() + Sentry.flush(5000) + // Sentry exports OpenTelemetry spans in batches every 5 seconds. + openTelemetry.sdkTracerProvider.forceFlush().join(5, TimeUnit.SECONDS) + + val fatal = + server.awaitEnvelope { it.event()?.message?.formatted == "Fatal message!" }.event()!! + assertThat(fatal.level).isEqualTo(SentryLevel.FATAL) + assertThat(fatal.breadcrumbs!!.map { it.message }).contains("Processed by the event processor") + + val warning = + server.awaitEnvelope { it.event()?.message?.formatted == "Some warning!" }.event()!! + assertThat(warning.level).isEqualTo(SentryLevel.WARNING) + + val error = + server + .awaitEnvelope { it.event()?.exceptions?.any { e -> e.value == "Some error!" } == true } + .event()!! + assertThat(error.exceptions!!.map { it.type to it.value }) + .contains("RuntimeException" to "Some error!") + assertThat(error.hasFlag("my-feature-flag", true)).isTrue() + + val detailed = + server.awaitEnvelope { it.event()?.message?.message == "Detailed event" }.event()!! + assertThat(detailed.fingerprints).containsExactly("NewClientDebug") + assertThat(detailed.level).isEqualTo(SentryLevel.DEBUG) + + repeat(10) { i -> server.awaitEnvelope { it.event()?.message?.message == "event $i" } } + + val sent = + server.awaitEnvelope { it.transaction()?.transaction == "transaction name" }.transaction()!! + assertThat(sent.spans.map { it.op }).contains("child") + val profilerId = sent.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + + val metrics = server.awaitEnvelope { it.metrics() != null }.metrics()!! + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetric("gaugeMetric", "gauge", 5.0)).isTrue() + assertThat(metrics.hasMetric("distributionMetric", "distribution", 7.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } + + private companion object { + val SENTRY_PROPERTIES = + listOf( + "sentry.dsn", + "sentry.traces-sample-rate", + "sentry.profile-session-sample-rate", + "sentry.profile-lifecycle", + "sentry.profiling-traces-dir-path", + ) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index afab9ef180f..fb95d722d6f 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -34,6 +34,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-otlp/src/test/kotlin/OtlpEndToEndTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtlpEndToEndTest.kt new file mode 100644 index 00000000000..fb3db9731e8 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtlpEndToEndTest.kt @@ -0,0 +1,66 @@ +package io.sentry.opentelemetry.otlp + +import com.google.common.truth.Truth.assertThat +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.sentry.Sentry +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasMetric +import io.sentry.test.initForTest +import io.sentry.test.logs +import io.sentry.test.metrics +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * An app that exports its OpenTelemetry spans over OTLP and reports everything else to Sentry, as + * the console OTLP sample does. + */ +class OtlpEndToEndTest { + private val server = MockSentryServer() + private val openTelemetry = + OpenTelemetrySdk.builder().setTracerProvider(SdkTracerProvider.builder().build()).build() + + @AfterTest + fun teardown() { + Sentry.close() + openTelemetry.close() + server.close() + } + + @Test + fun `an event inside an OpenTelemetry span is linked to it`() { + initForTest { + it.dsn = server.dsn + it.logs.isEnabled = true + it.addEventProcessor(OpenTelemetryOtlpEventProcessor()) + } + + val span = openTelemetry.getTracer("otlp-end-to-end-test").spanBuilder("otelSpan").startSpan() + span.makeCurrent().use { + Sentry.captureMessage("this message is connected to the outerSpan") + Sentry.logger().error("Some error log message") + Sentry.metrics().count("invocations") + } + span.end() + Sentry.flush(5000) + + val event = + server + .awaitEnvelope { + it.event()?.message?.formatted == "this message is connected to the outerSpan" + } + .event()!! + // The span's trace goes over OTLP, so the event points to it rather than to a Sentry span. + val trace = event.contexts.trace!! + assertThat(trace.traceId.toString()).isEqualTo(span.spanContext.traceId) + assertThat(trace.spanId.toString()).isEqualTo(span.spanContext.spanId) + + val logs = server.awaitEnvelope { it.logs() != null }.logs()!! + assertThat(logs.items.map { it.body }).contains("Some error log message") + + val metrics = server.awaitEnvelope { it.metrics() != null }.metrics()!! + assertThat(metrics.hasMetric("invocations", "counter", 1.0)).isTrue() + } +} diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts index 57d1dea0e8d..7bac9a5a200 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/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.console.Main") } -tasks.withType().configureEach { - kotlin { - compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - } -} - dependencies { implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentless) implementation(projects.sentryAsyncProfiler) - - 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) @@ -45,22 +28,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-console-opentelemetry-noagent/src/test/kotlin/sentry/DummyTest.kt b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/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-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt deleted file mode 100644 index 4f21ebefd70..00000000000 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/src/test/kotlin/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import java.util.concurrent.TimeUnit -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -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 `console application sends expected events when run as JAR`() { - val jarFile = testHelper.findJar("sentry-samples-console-opentelemetry-noagent") - val process = - testHelper.launch( - jarFile, - mapOf( - "SENTRY_DSN" to testHelper.dsn, - // "SENTRY_AUTO_INIT" to "true", - "SENTRY_TRACES_SAMPLE_RATE" to "1.0", - "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", - "SENTRY_DEBUG" to "true", - "SENTRY_PROFILE_SESSION_SAMPLE_RATE" to "1.0", - "SENTRY_PROFILE_LIFECYCLE" to "TRACE", - "OTEL_METRICS_EXPORTER" to "none", - "OTEL_LOGS_EXPORTER" to "none", - "OTEL_TRACES_EXPORTER" to "none", - ), - ) - - process.waitFor(30, TimeUnit.SECONDS) - assertEquals(0, process.exitValue()) - - // Verify that we received the expected events - verifyExpectedEvents() - } - - private fun verifyExpectedEvents() { - var profilerId: SentryId? = null - - // Verify we received a "Fatal message!" event - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Fatal message!" && event.level?.name == "FATAL" - } - - // Verify we received a "Some warning!" event - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Some warning!" && event.level?.name == "WARNING" - } - - // Verify we received the RuntimeException - testHelper.ensureErrorReceived { event -> - event.exceptions?.any { ex -> ex.type == "RuntimeException" && ex.value == "Some error!" } == - true && testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - // Verify we received the detailed event with fingerprint - testHelper.ensureErrorReceived { event -> - event.message?.message == "Detailed event" && - event.fingerprints?.contains("NewClientDebug") == true && - event.level?.name == "DEBUG" - } - - // Verify we received transaction events - testHelper.ensureTransactionReceived { transaction, _ -> - profilerId = transaction.contexts.profile?.profilerId - transaction.transaction == "transaction name" && - transaction.spans?.any { span -> span.op == "child" } == true - } - - testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> - profileChunk.profilerId == profilerId - } - - // Verify we received the loop messages (should be 10 of them) - var loopMessageCount = 0 - try { - for (i in 0..9) { - testHelper.ensureErrorReceived { event -> - val matches = - event.message?.message?.contains("items we'll wait to flush to Sentry!") == true - if (matches) loopMessageCount++ - matches - } - } - } catch (e: Exception) { - // Some loop messages might be missing, but we should have at least some - } - - assertTrue( - "Should receive at least 5 loop messages, got $loopMessageCount", - loopMessageCount >= 5, - ) - - // Verify we have breadcrumbs - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.any { breadcrumb -> - breadcrumb.message?.contains("Processed by") == true - } == true - } - - testHelper.ensureMetricsReceived { metricsEvents, sentryEnvelopeHeader -> - testHelper.doesContainMetric(metricsEvents, "countMetric", "counter", 1.0) && - testHelper.doesContainMetric(metricsEvents, "gaugeMetric", "gauge", 5.0) && - testHelper.doesContainMetric(metricsEvents, "distributionMetric", "distribution", 7.0) && - testHelper.doesMetricHaveAttribute(metricsEvents, "countMetric", "user.type", "admin") && - testHelper.doesMetricHaveAttribute(metricsEvents, "countMetric", "feature.version", 2) - } - } -} diff --git a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts index 58a8578c7d1..134697014b7 100644 --- a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts @@ -1,35 +1,18 @@ -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.console.Main") } -tasks.withType().configureEach { - kotlin { - compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - } -} - dependencies { implementation(projects.sentryOpentelemetry.sentryOpentelemetryOtlp) implementation(projects.sentryAsyncProfiler) implementation(libs.otel.semconv) implementation(libs.otel.semconv.incubating) implementation(libs.otel.exporter.logging) - - 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) @@ -48,22 +31,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-console-otlp/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-console-otlp/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-console-otlp/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt deleted file mode 100644 index 1333fa50387..00000000000 --- a/sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import java.util.concurrent.TimeUnit -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -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 `console application sends expected events when run as JAR`() { - val jarFile = testHelper.findJar("sentry-samples-console-otlp") - val process = - testHelper.launch( - jarFile, - mapOf( - "SENTRY_DSN" to testHelper.dsn, - "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", - "SENTRY_DEBUG" to "true", - ), - ) - - process.waitFor(30, TimeUnit.SECONDS) - assertEquals(0, process.exitValue()) - - // Verify that we received the expected events - verifyExpectedEvents() - } - - private fun verifyExpectedEvents() { - // Verify we received a "Fatal message!" event - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Fatal message!" && event.level?.name == "FATAL" - } - - // Verify we received a "Some warning!" event - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Some warning!" && event.level?.name == "WARNING" - } - - // Verify we received the RuntimeException - testHelper.ensureErrorReceived { event -> - event.exceptions?.any { ex -> ex.type == "RuntimeException" && ex.value == "Some error!" } == - true && testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - // Verify we received the detailed event with fingerprint - testHelper.ensureErrorReceived { event -> - event.message?.message == "Detailed event" && - event.fingerprints?.contains("NewClientDebug") == true && - event.level?.name == "DEBUG" - } - - // Verify we received the loop messages (should be 10 of them) - var loopMessageCount = 0 - try { - for (i in 0..9) { - testHelper.ensureErrorReceived { event -> - val matches = - event.message?.message?.contains("items we'll wait to flush to Sentry!") == true - if (matches) loopMessageCount++ - matches - } - } - } catch (e: Exception) { - // Some loop messages might be missing, but we should have at least some - } - - assertTrue( - "Should receive at least 5 loop messages, got $loopMessageCount", - loopMessageCount >= 5, - ) - - // Verify we received the message captured within the OTel span - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "this message is connected to the outerSpan" - } - - // Verify we have breadcrumbs - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.any { breadcrumb -> - breadcrumb.message?.contains("Processed by") == true - } == true - } - - testHelper.ensureMetricsReceived { metricsEvents, sentryEnvelopeHeader -> - testHelper.doesContainMetric(metricsEvents, "countMetric", "counter", 1.0) && - testHelper.doesContainMetric(metricsEvents, "gaugeMetric", "gauge", 5.0) && - testHelper.doesContainMetric(metricsEvents, "distributionMetric", "distribution", 7.0) && - testHelper.doesContainMetric(metricsEvents, "invocations", "counter", 1.0) - } - - // Verify we received the log message captured within the OTel span - testHelper.ensureLogsReceived { logs, _ -> - testHelper.doesContainLogWithBody(logs, "Some error log message") - } - } -} diff --git a/sentry-samples/sentry-samples-console/build.gradle.kts b/sentry-samples/sentry-samples-console/build.gradle.kts index f0b0d862896..d691d7ceb24 100644 --- a/sentry-samples/sentry-samples-console/build.gradle.kts +++ b/sentry-samples/sentry-samples-console/build.gradle.kts @@ -1,22 +1,12 @@ -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.console.Main") } -tasks.withType().configureEach { - kotlin { - compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") - } -} - dependencies { implementation(projects.sentry) implementation(projects.sentryAsyncProfiler) @@ -25,13 +15,6 @@ dependencies { implementation(libs.jcache) implementation(libs.caffeine.jcache) implementation(libs.kafka.clients) - - 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) @@ -49,22 +32,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-console/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-console/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-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt deleted file mode 100644 index db6f54a616b..00000000000 --- a/sentry-samples/sentry-samples-console/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import java.util.concurrent.TimeUnit -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -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 `console application sends expected events when run as JAR`() { - val process = launchConsoleProcess() - - process.waitFor(30, TimeUnit.SECONDS) - assertEquals(0, process.exitValue()) - - // Verify that we received the expected events - verifyExpectedEvents() - } - - @Test - fun `console application sends kafka producer and consumer tracing when kafka is enabled`() { - val process = - launchConsoleProcess(mapOf("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS" to "localhost:9092")) - - process.waitFor(30, TimeUnit.SECONDS) - assertEquals(0, process.exitValue()) - - testHelper.ensureTransactionReceived { transaction, _ -> - transaction.transaction == "kafka-demo" && - testHelper.doesTransactionContainSpanWithOp(transaction, "queue.publish") - } - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionHaveOp(transaction, "queue.process") && - transaction.contexts.trace?.origin == "manual.queue.kafka.consumer" && - transaction.contexts.trace?.data?.get("messaging.system") == "kafka" - } - } - - private fun launchConsoleProcess(overrides: Map = emptyMap()): Process { - val jarFile = testHelper.findJar("sentry-samples-console") - val env = - mutableMapOf( - "SENTRY_DSN" to testHelper.dsn, - "SENTRY_TRACES_SAMPLE_RATE" to "1.0", - "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", - "SENTRY_DEBUG" to "true", - "SENTRY_PROFILE_SESSION_SAMPLE_RATE" to "1.0", - "SENTRY_PROFILE_LIFECYCLE" to "TRACE", - ) - env.putAll(overrides) - return testHelper.launch(jarFile, env) - } - - private fun verifyExpectedEvents() { - var profilerId: SentryId? = null - // Verify we received a "Fatal message!" event - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Fatal message!" && event.level?.name == "FATAL" - } - - // Verify we received a "Some warning!" event - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Some warning!" && event.level?.name == "WARNING" - } - - // Verify we received the RuntimeException - testHelper.ensureErrorReceived { event -> - event.exceptions?.any { ex -> ex.type == "RuntimeException" && ex.value == "Some error!" } == - true && testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - // Verify we received the detailed event with fingerprint - testHelper.ensureErrorReceived { event -> - event.message?.message == "Detailed event" && - event.fingerprints?.contains("NewClientDebug") == true && - event.level?.name == "DEBUG" - } - - // Verify we received transaction events - testHelper.ensureTransactionReceived { transaction, _ -> - profilerId = transaction.contexts.profile?.profilerId - transaction.transaction == "transaction name" && - transaction.spans?.any { span -> span.op == "child" } == true - } - - testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> - profileChunk.profilerId == profilerId - } - - // Verify we received the loop messages (should be 10 of them) - var loopMessageCount = 0 - try { - for (i in 0..9) { - testHelper.ensureErrorReceived { event -> - val matches = - event.message?.message?.contains("items we'll wait to flush to Sentry!") == true - if (matches) loopMessageCount++ - matches - } - } - } catch (e: Exception) { - // Some loop messages might be missing, but we should have at least some - } - - assertTrue( - "Should receive at least 5 loop messages, got $loopMessageCount", - loopMessageCount >= 5, - ) - - // Verify we have breadcrumbs - testHelper.ensureErrorReceived { event -> - event.breadcrumbs?.any { breadcrumb -> - breadcrumb.message?.contains("Processed by") == true - } == true - } - - testHelper.ensureMetricsReceived { metricsEvents, sentryEnvelopeHeader -> - testHelper.doesContainMetric(metricsEvents, "countMetric", "counter", 1.0) && - testHelper.doesContainMetric(metricsEvents, "gaugeMetric", "gauge", 5.0) && - testHelper.doesContainMetric(metricsEvents, "distributionMetric", "distribution", 7.0) && - testHelper.doesMetricHaveAttribute(metricsEvents, "countMetric", "user.type", "admin") && - testHelper.doesMetricHaveAttribute(metricsEvents, "countMetric", "feature.version", 2) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts index c5bc2b96fb3..bad23b8167a 100644 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts @@ -6,7 +6,6 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) - id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-4-otlp" @@ -54,37 +53,6 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentry) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.apollo3.kotlin) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j2.api) - testImplementation(libs.springboot4.starter.test) { - exclude(group = "org.junit.vintage", module = "junit-vintage-engine") - } - testImplementation("ch.qos.logback:logback-classic:1.5.16") - testImplementation("ch.qos.logback:logback-core:1.5.16") } dependencyManagement { imports { mavenBom(libs.otel.instrumentation.bom.get().toString()) } } - -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-spring-boot-4-otlp/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/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-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt deleted file mode 100644 index b45e9c10853..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class CacheSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `cache put and get produce spans`() { - val restClient = testHelper.restClient - - // Save a todo (triggers @CachePut -> cache.put span) - val todo = Todo(1L, "test-todo", false) - restClient.saveCachedTodo(todo) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.put") - } - - testHelper.reset() - - // Get the todo (triggers @Cacheable -> cache.get span, should be a hit) - restClient.getCachedTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.get") - } - } - - @Test - fun `cache evict produces span`() { - val restClient = testHelper.restClient - - restClient.deleteCachedTodo(1L) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.evict") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt deleted file mode 100644 index 3cd16003024..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt +++ /dev/null @@ -1,197 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import org.junit.Before - -class DistributedTracingSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } - - @Test - fun `get person distributed tracing with sampled false`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-0", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /tracing/{id}" - } - - testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "GET /person/{id}" - } - } - - @Test - fun `get person distributed tracing without sample_rand`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRand1: String? = null - var sampleRand2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand1 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRand2 = envelopeHeader.traceContext?.sampleRand - } - - matches - } - - assertEquals(sampleRand1, sampleRand2) - } - - @Test - fun `get person distributed tracing updates sample_rate on deferred decision`() { - val traceId = SentryId() - val restClient = testHelper.restClient - restClient.getPersonDistributedTracing( - 1L, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(500, restClient.lastKnownStatusCode) - - var sampleRate1: String? = null - var sampleRate2: String? = null - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /tracing/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate1 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - val matches = - transaction.transaction == "GET /person/{id}" && - envelopeHeader.traceContext!!.traceId == traceId && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - - if (matches) { - testHelper.logObject(envelopeHeader) - testHelper.logObject(transaction) - sampleRate2 = envelopeHeader.traceContext?.sampleRate - } - - matches - } - - assertEquals(sampleRate1, sampleRate2) - assertNotEquals(sampleRate1, "0.5") - } - - @Test - fun `create person distributed tracing`() { - val traceId = SentryId() - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = - restClient.createPersonDistributedTracing( - person, - mapOf( - "sentry-trace" to "$traceId-424cffc8f94feeee-1", - "baggage" to - "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", - ), - ) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /tracing/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - transaction.transaction == "POST /person/" && - testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt deleted file mode 100644 index 76a6024decc..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import org.junit.Before - -class GraphqlGreetingSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `greeting works`() { - val response = testHelper.graphqlClient.greet("world") - - testHelper.ensureNoErrors(response) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.greeting", - ) - } - } - - @Test - fun `greeting error`() { - val response = testHelper.graphqlClient.greet("crash") - - testHelper.ensureErrorCount(response, 1) - testHelper.ensureErrorReceived { error -> - error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false - } - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.greeting", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt deleted file mode 100644 index fca3956717c..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import org.junit.Before - -class GraphqlProjectSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `project query works`() { - val response = testHelper.graphqlClient.project("proj-slug") - - testHelper.ensureNoErrors(response) - assertEquals("proj-slug", response?.data?.project?.slug) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.project", - ) - } - } - - @Test - fun `project mutation works`() { - val response = testHelper.graphqlClient.addProject("proj-slug") - - testHelper.ensureNoErrors(response) - assertNotNull(response?.data?.addProject) - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Mutation.addProject", - ) - } - } - - @Test - fun `project mutation error`() { - val response = testHelper.graphqlClient.addProject("addprojectcrash") - - testHelper.ensureErrorCount(response, 1) - assertNull(response?.data?.addProject) - testHelper.ensureErrorReceived { error -> - error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false - } - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Mutation.addProject", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt deleted file mode 100644 index 940709c0778..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class GraphqlTaskSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `tasks and assignees query works`() { - val response = testHelper.graphqlClient.tasksAndAssignees("project-slug") - - testHelper.ensureNoErrors(response) - - assertEquals(5, response?.data?.tasks?.size) - - val firstTask = response?.data?.tasks?.firstOrNull() ?: throw RuntimeException("no task") - assertEquals("T1", firstTask.id) - assertEquals("A3", firstTask.assigneeId) - assertEquals("A3", firstTask.assignee?.id) - assertEquals("C3", firstTask.creatorId) - assertEquals("C3", firstTask.creator?.id) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Query.tasks", - ) && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Task.assignee", - ) && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "graphql", - "Task.creator", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index dc2ca2a10ae..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class MetricsSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `count metric`() { - val restClient = testHelper.restClient - assertEquals("count metric increased", restClient.getCountMetric()) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) - } - } - - @Test - fun `gauge metric`() { - val restClient = testHelper.restClient - assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) - } - } - - @Test - fun `distribution metric`() { - val restClient = testHelper.restClient - assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt deleted file mode 100644 index 362a8577148..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ /dev/null @@ -1,96 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.protocol.FeatureFlag -import io.sentry.protocol.SentryId -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class PersonSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get person fails`() { - val restClient = testHelper.restClient - restClient.getPerson(1L) - assertEquals(500, restClient.lastKnownStatusCode) - - testHelper.ensureErrorReceived { event -> - event.message?.formatted == "Trying person with id=1" && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureErrorReceived { event -> - testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && - testHelper.doesEventHaveFlag(event, "my-feature-flag", true) - } - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionHave( - transaction, - op = "http.server", - featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true), - ) && - testHelper.doesTransactionHaveSpanWith( - transaction, - op = "spanCreatedThroughSentryApi", - featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), - ) - } - - Thread.sleep(10000) - - testHelper.ensureLogsReceived { logs, envelopeHeader -> - testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && - testHelper.doesContainLogWithBody(logs, "error Sentry logging") && - testHelper.doesContainLogWithBody(logs, "hello there world!") - } - } - - @Test - fun `create person works`() { - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "db.query", - "insert into person (firstName, lastName) values (?, ?)", - ) - } - } - - @Test - fun `create person starts a profile linked to the transaction`() { - var profilerId: SentryId? = null - val restClient = testHelper.restClient - val person = Person("firstA", "lastB") - val returnedPerson = restClient.createPerson(person) - assertEquals(200, restClient.lastKnownStatusCode) - - assertEquals(person.firstName, returnedPerson!!.firstName) - assertEquals(person.lastName, returnedPerson!!.lastName) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - profilerId = transaction.contexts.profile?.profilerId - transaction.transaction == "POST /person/" - } - testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> - profileChunk.profilerId == profilerId - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt deleted file mode 100644 index d34485e1388..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package io.sentry.systemtest - -import io.sentry.systemtest.util.TestHelper -import kotlin.test.Test -import kotlin.test.assertEquals -import org.junit.Before - -class TodoSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `get todo works`() { - val restClient = testHelper.restClient - restClient.getTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo webclient works`() { - val restClient = testHelper.restClient - restClient.getTodoWebclient(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } - - @Test - fun `get todo restclient works`() { - val restClient = testHelper.restClient - restClient.getTodoRestClient(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, envelopeHeader -> - testHelper.doesTransactionContainSpanWithOpAndDescription( - transaction, - "http.client", - "GET https://jsonplaceholder.typicode.com/todos/1", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/resources/logback.xml deleted file mode 100644 index a36b8f80f76..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/resources/logback.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n - - - - - - - diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts index 79f252125d3..7f4428551b2 100644 --- a/sentry-spring-boot-4/build.gradle.kts +++ b/sentry-spring-boot-4/build.gradle.kts @@ -348,6 +348,39 @@ testing { } targets.all { testTask.configure { runAgentFixture(autoInit = true) } } } + + // The servlet app with the OpenTelemetry Spring Boot starter, set up by + // sentry-opentelemetry-otlp-spring to export traces over OTLP while Sentry handles the rest. + register("integrationTestOtlp") { + useJUnit() + dependencies { + implementation(platform(SpringBootPlugin.BOM_COORDINATES)) + implementation(projects.sentrySpringBoot4Starter) + implementation(projects.sentryOpentelemetry.sentryOpentelemetryOtlpSpring) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryGraphql22) + implementation(projects.sentryJdbc) + implementation(projects.sentryLogback) + implementation(projects.sentryTestSupport) + // Reactor's automatic context propagation needs it to carry the Sentry scope across + // threads. + implementation(libs.context.propagation) + implementation(libs.kotlin.test.junit) + implementation(libs.google.truth) + implementation(libs.caffeine) + implementation(libs.springboot4.resttestclient) + implementation(libs.springboot4.starter.aspectj) + implementation(libs.springboot4.starter.cache) + implementation(libs.springboot4.starter.graphql) + implementation(libs.springboot4.starter.jdbc) + implementation(libs.springboot4.starter.restclient) + implementation(libs.springboot4.starter.test) + implementation(libs.springboot4.starter.web) + implementation(libs.springboot4.starter.webclient) + implementation(libs.springboot4.starter.webflux) + runtimeOnly(libs.hsqldb) + } + } } } @@ -385,6 +418,7 @@ listOf( "compileIntegrationTestWebfluxKotlin", "compileIntegrationTestOtelNoAgentKotlin", "compileAgentFixtureKotlin", + "compileIntegrationTestOtlpKotlin", ) .forEach { tasks.named(it) { compilerOptions.javaParameters = true } diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheController.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheController.kt new file mode 100644 index 00000000000..c401a20506f --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheController.kt @@ -0,0 +1,43 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import java.util.concurrent.ConcurrentHashMap +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.CachePut +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/cache/") +class CacheController(private val todoCache: TodoCache) { + @GetMapping("{id}") fun get(@PathVariable("id") id: Long): Todo? = todoCache.get(id) + + @PostMapping fun save(@RequestBody todo: Todo): Todo = todoCache.save(todo) + + @DeleteMapping("{id}") fun delete(@PathVariable("id") id: Long) = todoCache.delete(id) +} + +/** Reads and writes todos through Spring's cache abstraction, which Sentry traces. */ +@Service +class TodoCache { + private val store = ConcurrentHashMap() + + @Cacheable(value = ["todos"], key = "#id") fun get(id: Long): Todo? = store[id] + + @CachePut(value = ["todos"], key = "#todo.id") + fun save(todo: Todo): Todo { + store[todo.id!!] = todo + return todo + } + + @CacheEvict(value = ["todos"], key = "#id") + fun delete(id: Long) { + store.remove(id) + } +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheIntegrationTest.kt new file mode 100644 index 00000000000..b0bbf4d08dd --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/CacheIntegrationTest.kt @@ -0,0 +1,35 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOp +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod + +class CacheIntegrationTest : IntegrationTest() { + + @Test + fun `cache put and get produce spans`() { + val saved = restTemplate.postForEntity("/cache/", Todo(1L, "test-todo"), Todo::class.java) + assertThat(saved.statusCode.value()).isEqualTo(200) + assertThat(awaitTransaction("POST /cache/").hasSpanWithOp("cache.put")).isTrue() + + val fetched = restTemplate.getForEntity("/cache/1", Todo::class.java) + assertThat(fetched.statusCode.value()).isEqualTo(200) + assertThat(fetched.body!!.title).isEqualTo("test-todo") + assertThat(awaitTransaction("GET /cache/{id}").hasSpanWithOp("cache.get")).isTrue() + } + + @Test + fun `cache evict produces span`() { + val response = + restTemplate.exchange("/cache/1", HttpMethod.DELETE, HttpEntity.EMPTY, Void::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + + assertThat(awaitTransaction("DELETE /cache/{id}").hasSpanWithOp("cache.evict")).isTrue() + } + + private fun awaitTransaction(name: String) = + server.awaitEnvelope { it.transaction()?.transaction == name }.transaction()!! +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingController.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingController.kt new file mode 100644 index 00000000000..6f2066006b6 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingController.kt @@ -0,0 +1,22 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient + +/** Forwards to this app's own Person endpoints, so a trace continues through outgoing requests. */ +@RestController +@RequestMapping("/tracing/") +class DistributedTracingController(private val restClient: RestClient) { + @GetMapping("{id}") + fun person(@PathVariable("id") id: Long): Person? = + restClient.get().uri(selfUrl("/person/$id")).retrieve().body(Person::class.java) + + @PostMapping + fun create(@RequestBody person: Person): Person? = + restClient.post().uri(selfUrl("/person/")).body(person).retrieve().body(Person::class.java) +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingIntegrationTest.kt new file mode 100644 index 00000000000..468f05fe2de --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/DistributedTracingIntegrationTest.kt @@ -0,0 +1,148 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.SentryEnvelope +import io.sentry.protocol.SentryId +import io.sentry.test.transaction +import kotlin.random.Random +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod + +/** + * Sends requests that continue an incoming trace, and checks that both the request's transaction + * and the one for the app's own outgoing request carry the trace. + */ +class DistributedTracingIntegrationTest : IntegrationTest() { + + @Test + fun `sampled trace continues through the outgoing request`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).transaction()!! + assertThat(incoming.request?.url).endsWith("/tracing/1") + awaitTransaction("GET /person/{id}", traceId) + } + + @Test + fun `unsampled trace sends no transactions`() { + val traceId = SentryId() + // An id only this request uses, because a mishandled header could start a trace of its own. + val id = Random.nextLong(2, Long.MAX_VALUE) + val status = + get( + "/tracing/$id", + sentryTrace = "$traceId-424cffc8f94feeee-0", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + server.assertNoEnvelope { isForRequest(it, id) } + } + + @Test + fun `sample_rand generated for baggage without one is propagated`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) + assertThat(incoming.sampleRand).isNotNull() + assertThat(outgoing.sampleRand).isEqualTo(incoming.sampleRand) + } + + @Test + fun `deferred sampling decision updates the propagated sample_rate`() { + val traceId = SentryId() + val status = + get( + "/tracing/1", + sentryTrace = "$traceId-424cffc8f94feeee", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + assertThat(status).isEqualTo(500) + + val incoming = awaitTransaction("GET /tracing/{id}", traceId).header.traceContext!! + val outgoing = awaitTransaction("GET /person/{id}", traceId).header.traceContext!! + assertThat(incoming.traceId).isEqualTo(traceId) + assertThat(outgoing.traceId).isEqualTo(traceId) + assertThat(incoming.sampleRate).isNotEqualTo("0.5") + assertThat(outgoing.sampleRate).isEqualTo(incoming.sampleRate) + } + + @Test + fun `sampled trace continues through an outgoing POST`() { + val traceId = SentryId() + val headers = + traceHeaders( + sentryTrace = "$traceId-424cffc8f94feeee-1", + baggage = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ) + val response = + restTemplate.postForEntity( + "/tracing/", + HttpEntity(Person("firstA", "lastB"), headers), + Person::class.java, + ) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + awaitTransaction("POST /tracing/", traceId) + awaitTransaction("POST /person/", traceId) + } + + private fun get(path: String, sentryTrace: String, baggage: String): Int = + restTemplate + .exchange( + path, + HttpMethod.GET, + HttpEntity(traceHeaders(sentryTrace, baggage)), + String::class.java, + ) + .statusCode + .value() + + private fun traceHeaders(sentryTrace: String, baggage: String): HttpHeaders = + HttpHeaders().apply { + set("sentry-trace", sentryTrace) + set("baggage", baggage) + } + + private fun awaitTransaction(name: String, traceId: SentryId): SentryEnvelope = + server.awaitEnvelope { + isTransaction(it, name, traceId) + } + + /** Whether [envelope] is a transaction for `/tracing/[id]` or the `/person/[id]` it calls. */ + private fun isForRequest(envelope: SentryEnvelope, id: Long): Boolean { + val url = envelope.transaction()?.request?.url ?: return false + return url.endsWith("/tracing/$id") || url.endsWith("/person/$id") + } + + private fun isTransaction(envelope: SentryEnvelope, name: String, traceId: SentryId): Boolean { + val transaction = envelope.transaction() ?: return false + return transaction.transaction == name && transaction.contexts.trace?.traceId == traceId + } +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlControllers.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlControllers.kt new file mode 100644 index 00000000000..7d3375453d2 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlControllers.kt @@ -0,0 +1,83 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import java.util.UUID +import java.util.concurrent.CompletableFuture +import org.dataloader.DataLoader +import org.springframework.graphql.data.method.annotation.Argument +import org.springframework.graphql.data.method.annotation.BatchMapping +import org.springframework.graphql.data.method.annotation.MutationMapping +import org.springframework.graphql.data.method.annotation.QueryMapping +import org.springframework.graphql.data.method.annotation.SchemaMapping +import org.springframework.graphql.execution.BatchLoaderRegistry +import org.springframework.stereotype.Controller +import reactor.core.publisher.Mono + +class Project(val slug: String) + +enum class ProjectStatus { + ACTIVE, + COMMUNITY, +} + +class Task(val id: String, val name: String, val assigneeId: String?, val creatorId: String?) + +class Assignee(val id: String, val name: String) + +class Creator(val id: String, val name: String) + +/** Resolvers that throw when given a "crash" argument, so tests can check error reporting. */ +@Controller +class ProjectController { + @QueryMapping + fun greeting(@Argument name: String): String { + if (name == "crash") { + throw RuntimeException("causing an error for $name") + } + return "Hello $name!" + } + + @QueryMapping fun project(@Argument slug: String): Project = Project(slug) + + @SchemaMapping(typeName = "Project", field = "status") + fun status(project: Project): ProjectStatus = ProjectStatus.COMMUNITY + + @MutationMapping + fun addProject(@Argument slug: String): String { + if (slug == "addprojectcrash") { + throw RuntimeException("causing a project add error for $slug") + } + return UUID.randomUUID().toString() + } + + @QueryMapping + fun tasks(@Argument projectSlug: String): List = + listOf( + Task("T1", "Create a new API", "A3", "C3"), + Task("T2", "Update dependencies", "A1", "C1"), + Task("T3", "Document API", "A1", "C1"), + Task("T4", "Merge community PRs", "A2", "C2"), + Task("T5", "Plan more work", null, null), + ) +} + +/** Loads a task's assignee with a batch mapping and its creator with a data loader. */ +@Controller +class TaskRelationsController(batchLoaderRegistry: BatchLoaderRegistry) { + init { + batchLoaderRegistry + .forTypePair(String::class.java, Creator::class.java) + .registerMappedBatchLoader { ids, _ -> + Mono.just(ids.associateWith { Creator(it, "Name$it") }) + } + } + + @BatchMapping(typeName = "Task", field = "assignee") + fun assignee(tasks: List): Map = + tasks + .filter { it.assigneeId != null } + .associateWith { Assignee(it.assigneeId!!, "Name${it.assigneeId}") } + + @SchemaMapping(typeName = "Task") + fun creator(task: Task, creators: DataLoader): CompletableFuture? = + task.creatorId?.let { creators.load(it) } +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlIntegrationTest.kt new file mode 100644 index 00000000000..b6ebc1e18f6 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/GraphqlIntegrationTest.kt @@ -0,0 +1,109 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.event +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test +import tools.jackson.databind.JsonNode + +class GraphqlIntegrationTest : IntegrationTest() { + + @Test + fun `query is traced`() { + val response = graphql("""query { greeting(name: "world") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["greeting"].asString()).isEqualTo("Hello world!") + awaitTransactionWithSpan("Query.greeting") + } + + @Test + fun `resolver error is reported and the query traced`() { + val response = graphql("""query { greeting(name: "crash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + awaitError("causing an error for crash") + awaitTransactionWithSpan("Query.greeting") + } + + @Test + fun `query with a schema mapping is traced`() { + val response = graphql("""query { project(slug: "proj-slug") { slug status } }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["project"]["slug"].asString()).isEqualTo("proj-slug") + awaitTransactionWithSpan("Query.project") + } + + @Test + fun `mutation is traced`() { + val response = graphql("""mutation { addProject(slug: "proj-slug") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["addProject"].asString()).isNotEmpty() + awaitTransactionWithSpan("Mutation.addProject") + } + + @Test + fun `mutation error is reported and the mutation traced`() { + val response = graphql("""mutation { addProject(slug: "addprojectcrash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + assertThat(response.path("data").path("addProject").asString(null)).isNull() + awaitError("causing a project add error for addprojectcrash") + awaitTransactionWithSpan("Mutation.addProject") + } + + @Test + fun `batch loaded fields are traced`() { + val response = + graphql( + """query { tasks(projectSlug: "project-slug") { id assigneeId assignee { id } creatorId creator { id } } }""" + ) + + assertThat(response.has("errors")).isFalse() + val tasks = response["data"]["tasks"] + assertThat(tasks.size()).isEqualTo(5) + assertThat(tasks[0]["id"].asString()).isEqualTo("T1") + assertThat(tasks[0]["assigneeId"].asString()).isEqualTo("A3") + assertThat(tasks[0]["creatorId"].asString()).isEqualTo("C3") + assertThat(tasks[0]["assignee"]["id"].asString()).isEqualTo("A3") + assertThat(tasks[0]["creator"]["id"].asString()).isEqualTo("C3") + + val transaction = awaitTransactionWithSpan("Query.tasks") + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Task.assignee")).isTrue() + assertThat(transaction.hasSpanWithOpAndDescription("graphql", "Task.creator")).isTrue() + } + + private fun graphql(query: String): JsonNode { + val response = + restTemplate.postForEntity("/graphql", mapOf("query" to query), JsonNode::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + return response.body!! + } + + private fun awaitTransactionWithSpan(description: String): SentryTransaction = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "POST /graphql" && + t.spans.any { span -> span.op == "graphql" && span.description == description } + } == true + } + .transaction()!! + + /** + * Waits for the error for [exceptionMessage]. It points at the OpenTelemetry trace rather than at + * Sentry's transaction (see PersonIntegrationTest), so it can't be tied to the transaction. + */ + private fun awaitError(exceptionMessage: String) { + server.awaitEnvelope { envelope -> + envelope.event()?.let { event -> + event.message?.message?.startsWith("Unresolved RuntimeException for executionId ") == + true && event.exceptions?.any { it.value == exceptionMessage } == true + } == true + } + } +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTest.kt new file mode 100644 index 00000000000..e944d2ba3d4 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTest.kt @@ -0,0 +1,68 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import io.sentry.test.MockSentryServer +import org.junit.Before +import org.junit.runner.RunWith +import org.springframework.boot.restclient.RestTemplateBuilder +import org.springframework.boot.resttestclient.TestRestTemplate +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.context.junit4.SpringRunner + +/** + * Drives [IntegrationTestApplication] over HTTP and asserts what reaches [server]. The app has the + * OpenTelemetry Spring Boot starter, which would export its spans over OTLP, and Sentry handles the + * rest, as set up by sentry-opentelemetry-otlp-spring. + * + * Subclasses share one application context and one server, so the app starts once per suite. + * Envelopes that arrive late can outlive [server]'s reset, so tests match on values only they + * produce. + */ +@RunWith(SpringRunner::class) +@SpringBootTest( + classes = [IntegrationTestApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = + [ + // Sentry also traces, as the sample did in CI with SENTRY_TRACES_SAMPLE_RATE, so the tests + // can check its transactions next to OpenTelemetry. + "sentry.traces-sample-rate=1.0", + "sentry.enable-cache-tracing=true", + "sentry.logs.enabled=true", + "sentry.profile-session-sample-rate=1.0", + "sentry.profile-lifecycle=TRACE", + "sentry.profiling-traces-dir-path=\${java.io.tmpdir}/sentry-integration-test/profiling-traces", + "spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb", + "spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver", + "otel.propagators=tracecontext,baggage,sentry", + "otel.traces.exporter=none", + "otel.metrics.exporter=none", + "otel.logs.exporter=none", + ], +) +abstract class IntegrationTest { + @LocalServerPort private var port = 0 + + // The context's TestRestTemplate is instrumented by the OpenTelemetry starter, which would + // replace the trace headers tests send with its own, so tests use one built outside the context. + val restTemplate: TestRestTemplate by lazy { + TestRestTemplate(RestTemplateBuilder().rootUri("http://localhost:$port")) + } + + @Before + fun resetServer() { + server.reset() + } + + companion object { + val server = MockSentryServer() + + @JvmStatic + @DynamicPropertySource + fun sentryDsn(registry: DynamicPropertyRegistry) { + registry.add("sentry.dsn") { server.dsn } + } + } +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTestApplication.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTestApplication.kt new file mode 100644 index 00000000000..0f82a35c00e --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/IntegrationTestApplication.kt @@ -0,0 +1,99 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.opentelemetry.otlp.OpenTelemetryOtlpEventProcessor +import io.sentry.spring7.tracing.SentrySpan +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.restclient.RestTemplateBuilder +import org.springframework.cache.annotation.EnableCaching +import org.springframework.context.annotation.Bean +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.servlet.support.ServletUriComponentsBuilder + +@SpringBootApplication +@EnableCaching +class IntegrationTestApplication { + // Built from Spring Boot's builders, which Sentry customizes to trace outgoing requests. + @Bean fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build() + + @Bean fun restClient(builder: RestClient.Builder): RestClient = builder.build() + + @Bean fun webClient(builder: WebClient.Builder): WebClient = builder.build() + + // Links events to the OpenTelemetry span that's current when they happen. + @Bean + fun otlpEventProcessor() = + Sentry.OptionsConfiguration { + it.addEventProcessor(OpenTelemetryOtlpEventProcessor()) + } +} + +/** + * The URL of [path] on this app, which listens on a random port. Only works on a request thread, + * because it reads the address from the current request. + */ +fun selfUrl(path: String): String = + ServletUriComponentsBuilder.fromCurrentContextPath().path(path).toUriString() + +class Person(var firstName: String? = null, var lastName: String? = null) + +@RestController +@RequestMapping("/person/") +class PersonController(private val personService: PersonService) { + private val logger = LoggerFactory.getLogger(PersonController::class.java) + + /** Reports to Sentry through every API an app uses, then fails the request. */ + @GetMapping("{id}") + fun person(@PathVariable("id") id: Long): Person { + Sentry.addFeatureFlag("transaction-feature-flag", true) + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.setAttribute("debug.enabled", true) + Sentry.logger().warn("warn Sentry logging") + Sentry.logger().error("error Sentry logging") + Sentry.logger().info("hello %s %s", "there", "world!") + Sentry.addFeatureFlag("my-feature-flag", true) + logger.error("Trying person with id={}", id, RuntimeException("error while loading")) + throw IllegalArgumentException("Something went wrong [id=$id]") + } finally { + sentrySpan.finish() + } + } + + @PostMapping + fun create(@RequestBody person: Person): Person { + val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi") + try { + return personService.create(person) + } finally { + sentrySpan.finish() + } + } +} + +@Service +@SentrySpan +class PersonService(private val jdbcTemplate: JdbcTemplate) { + fun create(person: Person): Person { + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.firstName, + person.lastName, + ) + return person + } +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricController.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricController.kt new file mode 100644 index 00000000000..96771413aa4 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricController.kt @@ -0,0 +1,33 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import io.sentry.Sentry +import io.sentry.metrics.MetricsUnit +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/metric/") +class MetricController { + @GetMapping("count") + fun count(): String { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + return "count metric increased" + } + + @GetMapping("gauge/{value}") + fun gauge(@PathVariable("value") value: Long): String { + Sentry.metrics().gauge("memory.free", value.toDouble(), MetricsUnit.Information.BYTE) + return "gauge metric tracked" + } + + @GetMapping("distribution/{value}") + fun distribution(@PathVariable("value") value: Long): String { + Sentry.metrics() + .distribution("distributionMetric", value.toDouble(), MetricsUnit.Duration.MILLISECOND) + return "distribution metric tracked" + } +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricsIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricsIntegrationTest.kt new file mode 100644 index 00000000000..074f4e022cb --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/MetricsIntegrationTest.kt @@ -0,0 +1,50 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.SentryMetricsEvents +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.metrics +import kotlin.test.Test + +class MetricsIntegrationTest : IntegrationTest() { + + @Test + fun `count metric carries the scope's attributes`() { + assertThat(restTemplate.getForObject("/metric/count", String::class.java)) + .isEqualTo("count metric increased") + + val metrics = awaitMetric("countMetric") + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } + + @Test + fun `gauge metric`() { + assertThat(restTemplate.getForObject("/metric/gauge/14", String::class.java)) + .isEqualTo("gauge metric tracked") + + assertThat(awaitMetric("memory.free").hasMetric("memory.free", "gauge", 14.0)).isTrue() + } + + @Test + fun `distribution metric`() { + assertThat(restTemplate.getForObject("/metric/distribution/23", String::class.java)) + .isEqualTo("distribution metric tracked") + + assertThat( + awaitMetric("distributionMetric").hasMetric("distributionMetric", "distribution", 23.0) + ) + .isTrue() + } + + /** Flushes first because metrics are batched before they're sent. */ + private fun awaitMetric(name: String): SentryMetricsEvents { + Sentry.flush(5000) + return server + .awaitEnvelope { it.metrics()?.items?.any { metric -> metric.name == name } == true } + .metrics()!! + } +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/PersonIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/PersonIntegrationTest.kt new file mode 100644 index 00000000000..3c0e5ab9feb --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/PersonIntegrationTest.kt @@ -0,0 +1,129 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.Sentry +import io.sentry.protocol.FeatureFlag +import io.sentry.protocol.SentryId +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasLogWithAttribute +import io.sentry.test.hasLogWithBody +import io.sentry.test.hasOp +import io.sentry.test.hasSpanWithOp +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.logs +import io.sentry.test.profileChunk +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod + +class PersonIntegrationTest : IntegrationTest() { + + @Test + fun `error in a request is linked to the request's OpenTelemetry trace`() { + val traceId = SentryId().toString() + val headers = HttpHeaders().apply { set("traceparent", "00-$traceId-00f067aa0ba902b7-01") } + val response = + restTemplate.exchange( + "/person/2", + HttpMethod.GET, + HttpEntity(headers), + String::class.java, + ) + assertThat(response.statusCode.value()).isEqualTo(500) + Sentry.flush(5000) + + // OpenTelemetry continues the incoming trace, and OpenTelemetryOtlpEventProcessor puts its span + // on + // the event, so the error points at the trace that OTLP exports. + server.awaitEnvelope { + it.event()?.let { event -> + event.exceptions?.any { e -> e.value == "Something went wrong [id=2]" } == true && + event.contexts.trace?.traceId?.toString() == traceId + } == true + } + } + + @Test + fun `failed request reports errors, the transaction and logs`() { + val response = restTemplate.getForEntity("/person/1", String::class.java) + assertThat(response.statusCode.value()).isEqualTo(500) + Sentry.flush(5000) + + val logged = + server.awaitEnvelope { it.event()?.message?.formatted == "Trying person with id=1" }.event()!! + assertThat(logged.hasFlag("my-feature-flag", true)).isTrue() + + val thrown = + server + .awaitEnvelope { + it.event()?.exceptions?.any { e -> e.value == "Something went wrong [id=1]" } == true + } + .event()!! + assertThat(thrown.hasFlag("my-feature-flag", true)).isTrue() + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!! + assertThat( + transaction.hasOp( + "http.server", + FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) + ) + .isTrue() + assertThat( + transaction.hasSpanWithOp( + "spanCreatedThroughSentryApi", + FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + ) + .isTrue() + + val logs = + server + .awaitEnvelope { + it.logs()?.items?.any { log -> log.body == "warn Sentry logging" } == true + } + .logs()!! + assertThat(logs.hasLogWithBody("error Sentry logging")).isTrue() + assertThat(logs.hasLogWithBody("hello there world!")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "user.type", "admin")).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "feature.version", 2)).isTrue() + assertThat(logs.hasLogWithAttribute("warn Sentry logging", "debug.enabled", true)).isTrue() + } + + @Test + fun `creating a person traces the service method and the query`() { + val response = + restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.firstName).isEqualTo("firstA") + assertThat(response.body!!.lastName).isEqualTo("lastB") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + assertThat(transaction.hasSpanWithOp("PersonService.create")).isTrue() + assertThat( + transaction.hasSpanWithOpAndDescription( + "db.query", + "insert into person (firstName, lastName) values (?, ?)", + ) + ) + .isTrue() + } + + @Test + fun `creating a person starts a profile linked to the transaction`() { + val response = + restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!! + val profilerId = transaction.contexts.profile?.profilerId + assertThat(profilerId).isNotNull() + server.awaitEnvelope(timeoutMillis = 30_000) { it.profileChunk()?.profilerId == profilerId } + } +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoController.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoController.kt new file mode 100644 index 00000000000..08c23022f73 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoController.kt @@ -0,0 +1,52 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import io.sentry.reactor.SentryReactorUtils +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Hooks +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers + +class Todo(var id: Long? = null, var title: String? = null, var completed: Boolean = false) + +/** Fetches a todo through each HTTP client that Sentry traces. */ +@RestController +class TodoController( + private val restTemplate: RestTemplate, + private val webClient: WebClient, + private val restClient: RestClient, +) { + @GetMapping("/todo/{id}") + fun todo(@PathVariable("id") id: Long): Todo? = + restTemplate.getForObject(todoUrl(id), Todo::class.java) + + /** Requests from another thread, which the Sentry scope has to follow through Reactor. */ + @GetMapping("/todo-webclient/{id}") + fun todoWebClient(@PathVariable("id") id: Long): Todo? { + val url = todoUrl(id) + Hooks.enableAutomaticContextPropagation() + return SentryReactorUtils.withSentry( + Mono.just(true).publishOn(Schedulers.boundedElastic()).flatMap { + webClient.get().uri(url).retrieve().bodyToMono(Todo::class.java) + } + ) + .block() + } + + @GetMapping("/todo-restclient/{id}") + fun todoRestClient(@PathVariable("id") id: Long): Todo? = + restClient.get().uri(todoUrl(id)).retrieve().body(Todo::class.java) + + private fun todoUrl(id: Long): String = selfUrl("/stub/todos/$id") +} + +/** Stands in for the public API the sample fetches todos from, so tests don't need the internet. */ +@RestController +class TodoStubController { + @GetMapping("/stub/todos/{id}") + fun todo(@PathVariable("id") id: Long): Todo = Todo(id, "delectus aut autem") +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoIntegrationTest.kt new file mode 100644 index 00000000000..b2a158aa13c --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/kotlin/io/sentry/spring/boot4/otlpintegrationtest/TodoIntegrationTest.kt @@ -0,0 +1,42 @@ +package io.sentry.spring.boot4.otlpintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOpAndDescription +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.boot.test.web.server.LocalServerPort + +class TodoIntegrationTest : IntegrationTest() { + @LocalServerPort private var port = 0 + + @Test + fun `RestTemplate request is traced`() { + assertOutgoingRequestIsTraced("/todo/1", "GET /todo/{id}") + } + + @Test + fun `WebClient request is traced`() { + assertOutgoingRequestIsTraced("/todo-webclient/1", "GET /todo-webclient/{id}") + } + + @Test + fun `RestClient request is traced`() { + assertOutgoingRequestIsTraced("/todo-restclient/1", "GET /todo-restclient/{id}") + } + + private fun assertOutgoingRequestIsTraced(path: String, transactionName: String) { + val response = restTemplate.getForEntity(path, Todo::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + assertThat(response.body!!.title).isEqualTo("delectus aut autem") + + val transaction = + server.awaitEnvelope { it.transaction()?.transaction == transactionName }.transaction()!! + assertThat( + transaction.hasSpanWithOpAndDescription( + "http.client", + "GET http://localhost:$port/stub/todos/1", + ) + ) + .isTrue() + } +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/resources/graphql/schema.graphqls b/sentry-spring-boot-4/src/integrationTestOtlp/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..fc2a8fd4c27 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/resources/graphql/schema.graphqls @@ -0,0 +1,38 @@ +type Query { + greeting(name: String!): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Project { + slug: ID! + status: ProjectStatus! +} + +enum ProjectStatus { + ACTIVE + COMMUNITY +} + +type Task { + id: String! + name: String! + assigneeId: String + assignee: Assignee + creatorId: String + creator: Creator +} + +type Assignee { + id: String! + name: String! +} + +type Creator { + id: String! + name: String! +} diff --git a/sentry-spring-boot-4/src/integrationTestOtlp/resources/schema.sql b/sentry-spring-boot-4/src/integrationTestOtlp/resources/schema.sql new file mode 100644 index 00000000000..7ca8a5cbf42 --- /dev/null +++ b/sentry-spring-boot-4/src/integrationTestOtlp/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE person ( + id INTEGER IDENTITY PRIMARY KEY, + firstName VARCHAR(50) NOT NULL, + lastName VARCHAR(50) NOT NULL +); diff --git a/sentry-system-test-support/build.gradle.kts b/sentry-system-test-support/build.gradle.kts deleted file mode 100644 index ecfdc6011f8..00000000000 --- a/sentry-system-test-support/build.gradle.kts +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - `java-library` - id("io.sentry.javadoc") - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.errorprone) - alias(libs.plugins.gradle.versions) - id("com.apollographql.apollo") version "4.1.1" -} - -tasks.withType().configureEach { - compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 - compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 -} - -dependencies { - api(projects.sentry) - api(projects.sentryTestSupport) - api(libs.apollo4.kotlin) - - compileOnly(libs.jetbrains.annotations) - compileOnly(libs.nopen.annotations) - - compileOnly(libs.springboot.starter.web) - - implementation(libs.jackson.databind) - implementation(libs.jackson.kotlin) - implementation(libs.okhttp) - - errorprone(libs.errorprone.core) - errorprone(libs.nopen.checker) - - // tests - implementation(kotlin(Config.kotlinStdLib)) - implementation(libs.kotlin.test.junit) - implementation(libs.mockito.kotlin) -} - -apollo { - service("service") { - srcDir("src/main/graphql") - packageName.set("io.sentry.samples.graphql") - outputDirConnection { connectToKotlinSourceSet("main") } - } -} diff --git a/sentry-system-test-support/src/main/graphql/greeting.graphql b/sentry-system-test-support/src/main/graphql/greeting.graphql deleted file mode 100644 index 06c866a65fa..00000000000 --- a/sentry-system-test-support/src/main/graphql/greeting.graphql +++ /dev/null @@ -1,3 +0,0 @@ -query GreetingQuery($name: String!) { - greeting(name: $name) -} diff --git a/sentry-system-test-support/src/main/graphql/project.graphql b/sentry-system-test-support/src/main/graphql/project.graphql deleted file mode 100644 index bff62ed2c2c..00000000000 --- a/sentry-system-test-support/src/main/graphql/project.graphql +++ /dev/null @@ -1,11 +0,0 @@ -query ProjectQuery($slug: ID!) { - project(slug: $slug) { - slug - name - status - } -} - -mutation AddProjectMutation($slug: ID!) { - addProject(slug: $slug) -} diff --git a/sentry-system-test-support/src/main/graphql/schema.graphqls b/sentry-system-test-support/src/main/graphql/schema.graphqls deleted file mode 100644 index d76aca4756a..00000000000 --- a/sentry-system-test-support/src/main/graphql/schema.graphqls +++ /dev/null @@ -1,70 +0,0 @@ -type Query { - greeting(name: String! = "Spring"): String! - project(slug: ID!): Project - tasks(projectSlug: ID!): [Task] -} - -type Mutation { - addProject(slug: ID!): String! -} - -type Subscription { - notifyNewTask(projectSlug: ID!): Task -} - -""" A Project in the Spring portfolio """ -type Project { - """ Unique string id used in URLs """ - slug: ID! - """ Project name """ - name: String - """ URL of the git repository """ - repositoryUrl: String! - """ Current support status """ - status: ProjectStatus! -} - -""" A task """ -type Task { - """ ID """ - id: String! - """ Name """ - name: String! - """ ID of the Assignee """ - assigneeId: String - """ Assignee """ - assignee: Assignee - """ ID of the Creator """ - creatorId: String - """ Creator """ - creator: Creator -} - -""" An Assignee """ -type Assignee { - """ ID """ - id: String! - """ Name """ - name: String! -} - -""" An Creator """ -type Creator { - """ ID """ - id: String! - """ Name """ - name: String! -} - -enum ProjectStatus { - """ Actively supported by the Spring team """ - ACTIVE - """ Supported by the community """ - COMMUNITY - """ Prototype, not officially supported yet """ - INCUBATING - """ Project being retired, in maintenance mode """ - ATTIC - """ End-Of-Lifed """ - EOL -} diff --git a/sentry-system-test-support/src/main/graphql/task.graphql b/sentry-system-test-support/src/main/graphql/task.graphql deleted file mode 100644 index 11ae18574d3..00000000000 --- a/sentry-system-test-support/src/main/graphql/task.graphql +++ /dev/null @@ -1,16 +0,0 @@ -query TasksAndAssigneesQuery($slug: ID!) { - tasks(projectSlug: $slug) { - id - name - assigneeId - assignee { - id - name - } - creatorId - creator { - id - name - } - } -} diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt deleted file mode 100644 index 4c9a4d903ad..00000000000 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/ResponseTypes.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.sentry.systemtest - -data class Todo(val id: Long, val title: String, val completed: Boolean) - -data class Person(val firstName: String, val lastName: String) { - override fun toString(): String = "Person{firstName='$firstName', lastName='$lastName'}" -} diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt deleted file mode 100644 index c1b0a409bda..00000000000 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt +++ /dev/null @@ -1,38 +0,0 @@ -package io.sentry.systemtest.graphql - -import com.apollographql.apollo.ApolloClient -import com.apollographql.apollo.api.ApolloResponse -import com.apollographql.apollo.api.Mutation -import com.apollographql.apollo.api.Query -import io.sentry.samples.graphql.AddProjectMutation -import io.sentry.samples.graphql.GreetingQuery -import io.sentry.samples.graphql.ProjectQuery -import io.sentry.samples.graphql.TasksAndAssigneesQuery -import kotlinx.coroutines.runBlocking - -class GraphqlTestClient(backendUrl: String) { - val apollo = - ApolloClient.Builder() - .serverUrl("$backendUrl/graphql") - .addHttpHeader("Authorization", "Basic dXNlcjpwYXNzd29yZA==") - .build() - - fun greet(name: String): ApolloResponse? = executeQuery(GreetingQuery(name)) - - fun project(slug: String): ApolloResponse? = executeQuery(ProjectQuery(slug)) - - fun tasksAndAssignees(slug: String): ApolloResponse? = - executeQuery(TasksAndAssigneesQuery(slug)) - - fun addProject(slug: String): ApolloResponse? = - executeMutation(AddProjectMutation(slug)) - - private fun executeQuery(query: Query): ApolloResponse? = runBlocking { - apollo.query(query).execute() - } - - private fun executeMutation(mutation: Mutation): ApolloResponse? = - runBlocking { - apollo.mutation(mutation).execute() - } -} diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt deleted file mode 100644 index 8c4f27345f8..00000000000 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt +++ /dev/null @@ -1,83 +0,0 @@ -package io.sentry.systemtest.util - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import java.util.concurrent.TimeUnit -import okhttp3.Credentials -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import org.slf4j.LoggerFactory - -open class LoggingInsecureRestClient { - val logger = LoggerFactory.getLogger(LoggingInsecureRestClient::class.java) - var lastKnownStatusCode: Int? = null - - protected inline fun callTyped( - requestBuilder: Request.Builder, - useAuth: Boolean, - extraHeaders: Map? = null, - ): T? { - val response = call(requestBuilder, useAuth, extraHeaders) - val responseBody = response?.body?.string() - if (response?.isSuccessful != true) { - return null - } - if (T::class == String::class) { - return responseBody as? T - } - return responseBody?.let { objectMapper().readValue(it, T::class.java) } - } - - protected fun call( - requestBuilder: Request.Builder, - useAuth: Boolean, - extraHeaders: Map? = null, - ): Response? { - try { - val request = - requestBuilder - .also { originalRequest -> - var modifiedRequest = originalRequest - - if (useAuth) { - modifiedRequest = - modifiedRequest.header("Authorization", Credentials.basic("user", "password")) - } - - extraHeaders?.forEach { key, value -> - modifiedRequest = modifiedRequest.header(key, value) - } - - modifiedRequest - } - .build() - val call = client().newCall(request) - val response = call.execute() - lastKnownStatusCode = response.code - return response - } catch (e: Exception) { - lastKnownStatusCode = null - logger.error("Request failed", e) - return null - } - } - - protected fun client(): OkHttpClient = - OkHttpClient.Builder() - .callTimeout(60, TimeUnit.SECONDS) - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .build() - - protected fun objectMapper(): ObjectMapper = jacksonObjectMapper() - - protected fun toRequestBody(o: Any?): RequestBody { - val stringValue = objectMapper().writeValueAsString(o) - return stringValue.toRequestBody("application/json; charset=utf-8".toMediaType()) - } -} diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt deleted file mode 100644 index ac2c69f5ed2..00000000000 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ /dev/null @@ -1,115 +0,0 @@ -package io.sentry.systemtest.util - -import io.sentry.systemtest.Person -import io.sentry.systemtest.Todo -import okhttp3.Request - -class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestClient() { - fun getPerson(id: Long): Person? { - val request = Request.Builder().url("$backendBaseUrl/person/$id") - - return callTyped(request, true) - } - - fun createPerson(person: Person, extraHeaders: Map? = null): Person? { - val request = Request.Builder().url("$backendBaseUrl/person/").post(toRequestBody(person)) - - return callTyped(request, true, extraHeaders) - } - - fun getPersonDistributedTracing(id: Long, extraHeaders: Map? = null): Person? { - val request = Request.Builder().url("$backendBaseUrl/tracing/$id") - - return callTyped(request, true, extraHeaders) - } - - fun createPersonDistributedTracing( - person: Person, - extraHeaders: Map? = null, - ): Person? { - val request = Request.Builder().url("$backendBaseUrl/tracing/").post(toRequestBody(person)) - - return callTyped(request, true, extraHeaders) - } - - fun getTodo(id: Long): Todo? { - val request = Request.Builder().url("$backendBaseUrl/todo/$id") - - return callTyped(request, true) - } - - fun getTodoWebclient(id: Long): Todo? { - val request = Request.Builder().url("$backendBaseUrl/todo-webclient/$id") - - return callTyped(request, true) - } - - fun getTodoRestClient(id: Long): Todo? { - val request = Request.Builder().url("$backendBaseUrl/todo-restclient/$id") - - return callTyped(request, true) - } - - fun getTodoOkHttp(id: Long): Todo? { - val request = Request.Builder().url("$backendBaseUrl/todo-okhttp/$id") - - return callTyped(request, true) - } - - fun getCachedTodo(id: Long): Todo? { - val request = Request.Builder().url("$backendBaseUrl/cache/$id") - - return callTyped(request, true) - } - - fun saveCachedTodo(todo: Todo): Todo? { - val request = Request.Builder().url("$backendBaseUrl/cache/").post(toRequestBody(todo)) - - return callTyped(request, true) - } - - fun deleteCachedTodo(id: Long) { - val request = Request.Builder().url("$backendBaseUrl/cache/$id").delete() - - call(request, true) - } - - fun checkFeatureFlag(flagKey: String): FeatureFlagResponse? { - val request = Request.Builder().url("$backendBaseUrl/feature-flag/check/$flagKey") - - return callTyped(request, true) - } - - fun errorWithFeatureFlag(flagKey: String): String? { - val request = Request.Builder().url("$backendBaseUrl/feature-flag/error/$flagKey") - - val response = call(request, true) - return response?.body?.string() - } - - fun produceKafkaMessage(message: String = "hello from sentry!"): String? { - val request = Request.Builder().url("$backendBaseUrl/kafka/produce?message=$message") - - return callTyped(request, true) - } - - fun getCountMetric(): String? { - val request = Request.Builder().url("$backendBaseUrl/metric/count") - - return callTyped(request, true) - } - - fun getGaugeMetric(value: Long): String? { - val request = Request.Builder().url("$backendBaseUrl/metric/gauge/$value") - - return callTyped(request, true) - } - - fun getDistributionMetric(value: Long): String? { - val request = Request.Builder().url("$backendBaseUrl/metric/distribution/$value") - - return callTyped(request, true) - } -} - -data class FeatureFlagResponse(val flagKey: String, val value: Boolean) diff --git a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt deleted file mode 100644 index 389f3bb5b80..00000000000 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ /dev/null @@ -1,35 +0,0 @@ -package io.sentry.systemtest.util - -import okhttp3.Request - -class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestClient() { - fun getEnvelopeCount(): EnvelopeCounts { - val request = Request.Builder().url("$baseUrl/envelope-count") - - return callTyped(request, false)!! - } - - fun reset() { - val request = Request.Builder().url("$baseUrl/reset") - - call(request, false) - } - - fun getEnvelopes(): EnvelopesReceived { - val request = Request.Builder().url("$baseUrl/envelopes-received") - - return callTyped(request, false)!! - } -} - -class EnvelopeCounts { - val envelopes: Long? = null - - override fun toString(): String = "EnvelopeCounts{envelopes=$envelopes}" -} - -class EnvelopesReceived { - val envelopes: List? = null - - override fun toString(): String = "EnvelopesReceived{envelopes=$envelopes}" -} 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 deleted file mode 100644 index 8ef74cae3c9..00000000000 --- a/sentry-system-test-support/src/main/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ /dev/null @@ -1,298 +0,0 @@ -package io.sentry.systemtest.util - -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.SentryLogEvents -import io.sentry.SentryMetricsEvents -import io.sentry.SentryOptions -import io.sentry.protocol.FeatureFlag -import io.sentry.protocol.SentrySpan -import io.sentry.protocol.SentryTransaction -import io.sentry.systemtest.graphql.GraphqlTestClient -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.PrintWriter -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class TestHelper(backendUrl: String) { - val restClient: RestTestClient - val graphqlClient: GraphqlTestClient - val sentryClient: SentryMockServerClient - val jsonSerializer: JsonSerializer - val dsn = "http://502f25099c204a2fbf4cb16edc5975d1@localhost:8000/0" - - var envelopeCounts: EnvelopeCounts? = null - - init { - restClient = RestTestClient(backendUrl) - sentryClient = SentryMockServerClient("http://localhost:8000") - graphqlClient = GraphqlTestClient(backendUrl) - jsonSerializer = JsonSerializer(SentryOptions.empty()) - } - - fun snapshotEnvelopeCount() { - envelopeCounts = sentryClient.getEnvelopeCount() - } - - fun ensureEnvelopeCountIncreased() { - Thread.sleep(1000) - val envelopeCountsAfter = sentryClient.getEnvelopeCount() - assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) - } - - fun ensureEnvelopeReceived(retryCount: Int = 1, callback: ((String) -> Boolean)) { - val envelopes = sentryClient.getEnvelopes() - assertNotNull(envelopes.envelopes) - envelopes.envelopes.forEach { envelopeString -> - val didMatch = callback(envelopeString) - if (didMatch) { - return - } - } - if (retryCount <= 0) { - throw RuntimeException("Unable to find matching envelope received by relay") - } else { - Thread.sleep(10000) - ensureEnvelopeReceived(retryCount - 1, callback) - } - } - - fun ensureNoEnvelopeReceived(callback: ((String) -> Boolean)) { - Thread.sleep(10000) - val envelopes = sentryClient.getEnvelopes() - - if (envelopes.envelopes.isNullOrEmpty()) { - return - } - - envelopes.envelopes.forEach { envelopeString -> - val didMatch = callback(envelopeString) - if (didMatch) { - throw RuntimeException("Found unexpected matching envelope received by relay") - } - } - } - - fun ensureTransactionReceived(callback: ((SentryTransaction, SentryEnvelopeHeader) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> checkIfTransactionMatches(envelopeString, callback) } - } - - fun ensureProfileChunkReceived(callback: ((ProfileChunk, SentryEnvelopeHeader) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> checkIfProfileMatches(envelopeString, callback) } - } - - fun ensureNoTransactionReceived( - callback: ((SentryTransaction, SentryEnvelopeHeader) -> Boolean) - ) { - ensureNoEnvelopeReceived { envelopeString -> - checkIfTransactionMatches(envelopeString, callback) - } - } - - fun ensureLogsReceived(callback: ((SentryLogEvents, SentryEnvelopeHeader) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> checkIfLogsMatch(envelopeString, callback) } - } - - private fun checkIfLogsMatch( - envelopeString: String, - callback: ((SentryLogEvents, SentryEnvelopeHeader) -> Boolean), - ): Boolean { - val envelope = deserialize(envelopeString) ?: return false - val logs = envelope.logs() ?: return false - return callback(logs, envelope.header) - } - - fun doesContainMetric( - metrics: SentryMetricsEvents, - name: String, - type: String, - value: Double, - unit: String? = null, - ): Boolean = metrics.hasMetric(name, type, value, unit) - - fun ensureMetricsReceived(callback: ((SentryMetricsEvents, SentryEnvelopeHeader) -> Boolean)) { - ensureEnvelopeReceived { envelopeString -> checkIfMetricsMatch(envelopeString, callback) } - } - - private fun checkIfMetricsMatch( - envelopeString: String, - callback: ((SentryMetricsEvents, SentryEnvelopeHeader) -> Boolean), - ): Boolean { - val envelope = deserialize(envelopeString) ?: return false - val metrics = envelope.metrics() ?: return false - return callback(metrics, envelope.header) - } - - fun doesContainLogWithBody(logs: SentryLogEvents, body: String): Boolean = - logs.hasLogWithBody(body) - - fun doesLogWithBodyHaveAttribute( - logs: SentryLogEvents, - body: String, - attributeKey: String, - attributeValue: Any?, - ): Boolean = logs.hasLogWithAttribute(body, attributeKey, attributeValue) - - fun doesMetricHaveAttribute( - metrics: SentryMetricsEvents, - metricName: String, - attributeKey: String, - attributeValue: Any?, - ): Boolean = metrics.hasMetricWithAttribute(metricName, attributeKey, attributeValue) - - private fun checkIfTransactionMatches( - envelopeString: String, - callback: ((SentryTransaction, SentryEnvelopeHeader) -> Boolean), - ): Boolean { - 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 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 error = deserialize(envelopeString)?.event() ?: return@ensureEnvelopeReceived false - val callbackResult = callback(error) - if (!callbackResult) { - println("found an error event but it did not match:") - logObject(error) - } - callbackResult - } - } - - fun ensureTransactionWithSpanReceived(callback: ((SentrySpan) -> Boolean)) { - ensureTransactionReceived { transaction, envelopeHeader -> - transaction.spans.forEach { span -> - val callbackResult = callback(span) - if (callbackResult) { - return@ensureTransactionReceived true - } - } - false - } - } - - fun reset() { - sentryClient.reset() - } - - fun logObject(obj: Any?) { - obj ?: return - // 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()) - } - - fun ensureErrorCount(response: ApolloResponse?, errorCount: Int) { - response ?: throw RuntimeException("no response") - assertEquals(errorCount, response.errors?.size) - } - - fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean = - transaction.hasSpanWithOp(op) - - fun doesTransactionContainSpanWithOpAndDescription( - transaction: SentryTransaction, - op: String, - description: String, - ): Boolean = transaction.hasSpanWithOpAndDescription(op, description) - - fun doesTransactionHaveTraceId(transaction: SentryTransaction, traceId: String): Boolean = - transaction.hasTraceId(traceId) - - fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean = - transaction.hasOp(op) - - fun doesTransactionHave( - transaction: SentryTransaction, - op: String, - featureFlag: FeatureFlag? = null, - ): Boolean = transaction.hasOp(op, featureFlag) - - fun doesTransactionHaveSpanWith( - transaction: SentryTransaction, - op: String, - featureFlag: FeatureFlag? = null, - noFeatureFlags: Boolean = false, - ): Boolean = transaction.hasSpanWithOp(op, featureFlag, noFeatureFlags) - - fun doesEventHaveExceptionMessage(event: SentryEvent, expectedMessage: String): Boolean = - event.hasExceptionMessage(expectedMessage) - - 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) - val jarFiles = - buildDir.listFiles { _, name -> name.startsWith(prefix) && name.endsWith(".jar") }?.toList() - ?: emptyList() - - if (jarFiles.isEmpty()) { - throw AssertionError("No JAR found in ${buildDir.absolutePath}") - } - - return jarFiles.maxOf { it } - } - - fun launch(jar: File, env: Map, enableOtelAutoConfig: Boolean = false): Process { - val processBuilderList = mutableListOf("java", "--add-opens", "java.base/java.lang=ALL-UNNAMED") - - if (enableOtelAutoConfig) { - processBuilderList.add("-Dotel.java.global-autoconfigure.enabled=true") - } - - processBuilderList.add("-jar") - processBuilderList.add(jar.absolutePath) - - val processBuilder = - ProcessBuilder(processBuilderList).inheritIO() // forward i/o to current process - - processBuilder.environment().putAll(env) - - return processBuilder.start() - } -} diff --git a/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt b/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt index 416fead4ddc..21eeda7239b 100644 --- a/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt +++ b/sentry-test-support/src/main/kotlin/io/sentry/test/ChildJvmApp.kt @@ -11,11 +11,12 @@ import kotlin.concurrent.thread /** * An app running in a child JVM, for apps that must not share the test's JVM, such as one with the - * OpenTelemetry agent attached. + * OpenTelemetry agent attached or one whose exit is under test. * - * The app writes the port it listens on to the file named by the `PORTFILE` environment variable, - * which Spring Boot's `WebServerPortFileWriter` reads too. The app's output goes to the test's - * standard output as it arrives, so Gradle's test report shows it with the test that was running. + * A server app writes the port it listens on to the file named by the `PORTFILE` environment + * variable, which Spring Boot's `WebServerPortFileWriter` reads too. The app's output goes to the + * test's standard output as it arrives, so Gradle's test report shows it with the test that was + * running. */ class ChildJvmApp private constructor(private val process: Process, private val workDir: File) : Closeable { @@ -37,6 +38,14 @@ class ChildJvmApp private constructor(private val process: Process, private val } } + /** Waits for the app to exit on its own and returns its exit code. */ + fun awaitExit(timeoutSeconds: Long = 30): Int { + check(process.waitFor(timeoutSeconds, TimeUnit.SECONDS)) { + "App did not exit within ${timeoutSeconds}s" + } + return process.exitValue() + } + override fun close() { process.destroyForcibly().waitFor() workDir.deleteRecursively() diff --git a/sentry/src/test/java/io/sentry/SentryEndToEndTest.kt b/sentry/src/test/java/io/sentry/SentryEndToEndTest.kt new file mode 100644 index 00000000000..742f4462b2b --- /dev/null +++ b/sentry/src/test/java/io/sentry/SentryEndToEndTest.kt @@ -0,0 +1,113 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.Message +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasFlag +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.initForTest +import io.sentry.test.metrics +import io.sentry.test.transaction +import kotlin.test.AfterTest +import kotlin.test.Test + +/** Reports through the APIs an app uses without integrations, over the real HTTP transport. */ +class SentryEndToEndTest { + private val server = MockSentryServer() + + @AfterTest + fun teardown() { + Sentry.close() + server.close() + } + + @Test + fun `events, a transaction and metrics reach Sentry`() { + initForTest { + it.dsn = server.dsn + it.tracesSampleRate = 1.0 + it.beforeBreadcrumb = SentryOptions.BeforeBreadcrumbCallback { breadcrumb, _ -> + if (breadcrumb.message == "bad breadcrumb") null else breadcrumb + } + } + Sentry.addBreadcrumb("bad breadcrumb") + Sentry.configureScope { + it.addEventProcessor( + object : EventProcessor { + override fun process(event: SentryEvent, hint: Hint): SentryEvent = event.apply { + if ((level?.ordinal ?: 0) > SentryLevel.INFO.ordinal) { + addBreadcrumb(Breadcrumb("Processed by the event processor")) + } + } + } + ) + } + + Sentry.withScope { + it.level = SentryLevel.FATAL + Sentry.captureMessage("Fatal message!") + } + Sentry.captureMessage("Some warning!", SentryLevel.WARNING) + Sentry.addFeatureFlag("my-feature-flag", true) + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + Sentry.metrics().gauge("gaugeMetric", 5.0) + Sentry.metrics().distribution("distributionMetric", 7.0) + Sentry.captureException(RuntimeException("Some error!")) + Sentry.captureEvent( + SentryEvent().apply { + message = Message().apply { message = "Detailed event" } + fingerprints = listOf("NewClientDebug") + level = SentryLevel.DEBUG + } + ) + repeat(10) { i -> + Sentry.captureEvent( + SentryEvent().apply { message = Message().apply { message = "event $i" } } + ) + } + val transaction = Sentry.startTransaction("transaction name", "op") + transaction.startChild("child").finish() + transaction.finish() + Sentry.flush(5000) + + val fatal = + server.awaitEnvelope { it.event()?.message?.formatted == "Fatal message!" }.event()!! + assertThat(fatal.level).isEqualTo(SentryLevel.FATAL) + assertThat(fatal.breadcrumbs!!.map { it.message }).contains("Processed by the event processor") + assertThat(fatal.breadcrumbs!!.map { it.message }).doesNotContain("bad breadcrumb") + + val warning = + server.awaitEnvelope { it.event()?.message?.formatted == "Some warning!" }.event()!! + assertThat(warning.level).isEqualTo(SentryLevel.WARNING) + + val error = + server + .awaitEnvelope { it.event()?.exceptions?.any { e -> e.value == "Some error!" } == true } + .event()!! + assertThat(error.exceptions!!.map { it.type to it.value }) + .contains("RuntimeException" to "Some error!") + assertThat(error.hasFlag("my-feature-flag", true)).isTrue() + + val detailed = + server.awaitEnvelope { it.event()?.message?.message == "Detailed event" }.event()!! + assertThat(detailed.fingerprints).containsExactly("NewClientDebug") + assertThat(detailed.level).isEqualTo(SentryLevel.DEBUG) + + repeat(10) { i -> server.awaitEnvelope { it.event()?.message?.message == "event $i" } } + + val sent = + server.awaitEnvelope { it.transaction()?.transaction == "transaction name" }.transaction()!! + assertThat(sent.spans.map { it.op }).contains("child") + + val metrics = server.awaitEnvelope { it.metrics() != null }.metrics()!! + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetric("gaugeMetric", "gauge", 5.0)).isTrue() + assertThat(metrics.hasMetric("distributionMetric", "distribution", 7.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } +} diff --git a/sentry/src/test/java/io/sentry/ShutdownHookEndToEndTest.kt b/sentry/src/test/java/io/sentry/ShutdownHookEndToEndTest.kt new file mode 100644 index 00000000000..c362ced5a21 --- /dev/null +++ b/sentry/src/test/java/io/sentry/ShutdownHookEndToEndTest.kt @@ -0,0 +1,56 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.ChildJvmApp +import io.sentry.test.MockSentryServer +import io.sentry.test.event +import io.sentry.test.hasLogWithBody +import io.sentry.test.hasMetric +import io.sentry.test.logs +import io.sentry.test.metrics +import kotlin.test.AfterTest +import kotlin.test.Test + +/** + * Runs an app in its own JVM that returns from `main` without flushing or closing Sentry, as most + * apps do, so only the shutdown hook can deliver what it reported. + */ +class ShutdownHookEndToEndTest { + private val server = MockSentryServer() + + @AfterTest + fun teardown() { + server.close() + } + + @Test + fun `the shutdown hook delivers what an app reported before it exited`() { + ChildJvmApp.start( + mainClass = ExitWithoutFlushApp::class.java.name, + classpath = System.getProperty("java.class.path"), + args = listOf(server.dsn), + ) + .use { app -> + // A timeout here means a thread the SDK started kept the JVM from exiting. + assertThat(app.awaitExit()).isEqualTo(0) + } + + server.awaitEnvelope { it.event()?.message?.formatted == "Reported before exit" } + server.awaitEnvelope { it.logs()?.hasLogWithBody("Logged before exit") == true } + server.awaitEnvelope { it.metrics()?.hasMetric("exitMetric", "counter", 1.0) == true } + } +} + +/** Reports an event, a log and a metric, then returns without flushing or closing Sentry. */ +object ExitWithoutFlushApp { + @JvmStatic + fun main(args: Array) { + Sentry.init { + it.dsn = args[0] + it.logs.isEnabled = true + } + Sentry.captureMessage("Reported before exit") + Sentry.logger().info("Logged before exit") + Sentry.metrics().count("exitMetric") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 413ab5fd59b..5947411dd56 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -59,7 +59,6 @@ include( "sentry-apollo-3", "sentry-apollo-4", "sentry-apollo-5", - "sentry-system-test-support", "sentry-test-support", "sentry-log4j2", "sentry-logback", diff --git a/test/system-test-runner.py b/test/system-test-runner.py deleted file mode 100644 index 843b32ed843..00000000000 --- a/test/system-test-runner.py +++ /dev/null @@ -1,1251 +0,0 @@ -#!/usr/bin/env python3 - -""" -System Test Runner for Sentry Java - -Usage examples: - # Run all tests - python3 test/system-test-runner.py test --all - - # Run specific module test - python3 test/system-test-runner.py test --module sentry-samples-console - - # Set up infrastructure for manual testing from IDE - python3 test/system-test-runner.py test --module sentry-samples-console --manual-test - - # Start Sentry mock server - python3 test/system-test-runner.py sentry start - - # Start Spring app served by Tomcat - python3 test/system-test-runner.py tomcat start sentry-samples-spring - - # Start Spring Boot app - python3 test/system-test-runner.py spring start sentry-samples-spring-boot - - # Start Spring Boot app with build - python3 test/system-test-runner.py spring start sentry-samples-spring-boot --build - - # Check status of all services - python3 test/system-test-runner.py status - - # Stop services - python3 test/system-test-runner.py sentry stop - python3 test/system-test-runner.py spring stop -""" - -import subprocess -import sys -import time -import signal -import os -from enum import Enum -import argparse -import requests -import threading -import socket -from pathlib import Path -from typing import Optional, List, Tuple -from dataclasses import dataclass - -TERMINAL_COLUMNS: int = 60 -try: - TERMINAL_COLUMNS: int = os.get_terminal_size().columns -except: - pass - -SENTRY_ENVIRONMENT_VARIABLES = { - "SENTRY_DSN": "http://502f25099c204a2fbf4cb16edc5975d1@localhost:8000/0", - "SENTRY_TRACES_SAMPLE_RATE": "1.0", - "SENTRY_PROFILE_SESSION_SAMPLE_RATE": "1.0", - "SENTRY_PROFILE_LIFECYCLE": "TRACE", - "SENTRY_PROFILING_TRACES_DIR_PATH": "tmp/sentry/profiling-traces", - "OTEL_TRACES_EXPORTER": "none", - "OTEL_METRICS_EXPORTER": "none", - "OTEL_LOGS_EXPORTER": "none", - "SENTRY_LOGS_ENABLED": "true", - "SENTRY_ENABLE_CACHE_TRACING": "true" -} - -KAFKA_CONTAINER_NAME = "sentry-java-system-test-kafka" -KAFKA_BOOTSTRAP_SERVERS = "localhost:9092" -KAFKA_BROKER_REQUIRED_MODULES = { - "sentry-samples-console", -} -KAFKA_PROFILE_REQUIRED_MODULES = set() - -class ServerType(Enum): - TOMCAT = 0 - SPRING = 1 - -def get_server_type_for_module(sample_module: str) -> Optional[ServerType]: - if "spring" in sample_module and "spring-boot" not in sample_module: - return ServerType.TOMCAT - elif "spring" in sample_module: - return ServerType.SPRING - - return None - -def str_to_bool(value: str) -> str: - """Convert true/false string to 1/0 string for internal compatibility.""" - if value.lower() in ('true', '1'): - return "1" - elif value.lower() in ('false', '0'): - return "0" - else: - raise ValueError(f"Invalid boolean value: {value}. Use 'true' or 'false'") - -@dataclass -class Server: - name: str - pid_filepath: str - process: Optional[subprocess.Popen] = None - pid: Optional[int] = None - -def store_pid(server: Server): - # Store PID in instance variable and write to file - server.pid = server.process.pid - with open(server.pid_filepath, "w") as pid_file: - pid_file.write(str(server.pid)) - -def cleanup_pid(server: Server): - # Clean up PID file and instance variable - if os.path.exists(server.pid_filepath): - os.remove(server.pid_filepath) - server.pid = None - -@dataclass -class ModuleConfig: - """Configuration for a test module.""" - name: str - java_agent: str - java_agent_auto_init: str - build_before_run: str - - def uses_agent(self) -> bool: - """Check if this module uses the Java agent.""" - return str_to_bool(self.java_agent) == "1" - - def needs_build(self) -> bool: - """Check if this module needs to be built before running.""" - return str_to_bool(self.build_before_run) == "1" - - def is_spring_module(self) -> bool: - """Check if this is a Spring Boot module.""" - return "spring" in self.name - -@dataclass -class InteractiveSelection: - """Result of interactive module selection.""" - modules: List[ModuleConfig] - manual_test_mode: bool - build_agent: bool - - def is_empty(self) -> bool: - """Check if no modules were selected.""" - return len(self.modules) == 0 - - def is_single_module(self) -> bool: - """Check if exactly one module was selected.""" - return len(self.modules) == 1 - - def get_first_module(self) -> ModuleConfig: - """Get the first selected module (for manual test mode).""" - if self.is_empty(): - raise ValueError("No modules selected") - return self.modules[0] - - def has_agent_modules(self) -> bool: - """Check if any selected modules use the Java agent.""" - return any(module_config.uses_agent() for module_config in self.modules) - -class SystemTestRunner: - def __init__(self): - self.mock_server = Server(name="Mock", pid_filepath="sentry-mock-server.pid") - self.tomcat_server = Server(name="Tomcat", pid_filepath="tomcat-server.pid") - self.spring_server = Server(name="Spring", pid_filepath="spring-server.pid") - self.kafka_started_by_runner = False - - # Load existing PIDs if available - for server in (self.mock_server, self.tomcat_server, self.spring_server): - server.pid = self.read_pid_file(server.pid_filepath) - if server.pid: - print(f"Found existing {server.name} server PID: {server.pid}") - - def read_pid_file(self, pid_file: str) -> Optional[int]: - """Read PID from file if it exists.""" - try: - if os.path.exists(pid_file): - with open(pid_file, 'r') as f: - return int(f.read().strip()) - except (ValueError, IOError) as e: - print(f"Error reading PID file {pid_file}: {e}") - return None - - def is_process_running(self, pid: int) -> bool: - """Check if a process with given PID is still running.""" - try: - # Send signal 0 to check if process exists - os.kill(pid, 0) - return True - except (OSError, ProcessLookupError): - return False - - def kill_process(self, pid: int, name: str) -> None: - """Kill a process by PID.""" - try: - print(f"Killing existing {name} process with PID {pid}") - os.kill(pid, signal.SIGTERM) - time.sleep(2) # Give it time to terminate gracefully - - # Check if it's still running and force kill if necessary - if self.is_process_running(pid): - print(f"Process {pid} didn't terminate gracefully, force killing...") - os.kill(pid, signal.SIGKILL) - time.sleep(1) - except (OSError, ProcessLookupError): - print(f"Process {pid} was already dead") - - def exclude_kafka(self) -> bool: - return os.environ.get("ORG_GRADLE_PROJECT_excludeKafka") == "true" - - def module_requires_kafka(self, sample_module: str) -> bool: - return not self.exclude_kafka() and sample_module in KAFKA_BROKER_REQUIRED_MODULES - - def module_requires_kafka_profile(self, sample_module: str) -> bool: - return not self.exclude_kafka() and sample_module in KAFKA_PROFILE_REQUIRED_MODULES - - def wait_for_port(self, host: str, port: int, max_attempts: int = 20) -> bool: - for _ in range(max_attempts): - try: - with socket.create_connection((host, port), timeout=1): - return True - except OSError: - time.sleep(1) - return False - - def remove_kafka_broker_container(self) -> None: - subprocess.run( - ["docker", "rm", "-f", KAFKA_CONTAINER_NAME], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - def start_kafka_broker(self) -> None: - if self.wait_for_port("localhost", 9092, max_attempts=1): - print("Kafka broker already running on localhost:9092, reusing it.") - self.kafka_started_by_runner = False - return - - self.remove_kafka_broker_container() - - print("Starting Kafka broker (Redpanda) for system tests...") - run_result = subprocess.run( - [ - "docker", - "run", - "-d", - "--name", - KAFKA_CONTAINER_NAME, - "-p", - "9092:9092", - "docker.redpanda.com/redpandadata/redpanda:v24.1.9", - "redpanda", - "start", - "--overprovisioned", - "--smp", - "1", - "--memory", - "1G", - "--reserve-memory", - "0M", - "--node-id", - "0", - "--check=false", - "--kafka-addr", - "PLAINTEXT://0.0.0.0:9092", - "--advertise-kafka-addr", - "PLAINTEXT://localhost:9092", - ], - check=False, - capture_output=True, - text=True, - ) - - if run_result.returncode != 0: - raise RuntimeError(f"Failed to start Kafka container: {run_result.stderr}") - - if not self.wait_for_port("localhost", 9092, max_attempts=30): - raise RuntimeError("Kafka broker did not become ready on localhost:9092") - - self.kafka_started_by_runner = True - - def stop_kafka_broker(self) -> None: - if not self.kafka_started_by_runner: - return - - self.remove_kafka_broker_container() - self.kafka_started_by_runner = False - - def start_sentry_mock_server(self) -> None: - """Start the Sentry mock server.""" - print("Starting Sentry mock server...") - try: - # Start the mock server in the background - with open("sentry-mock-server.txt", "w") as log_file: - self.mock_server.process = subprocess.Popen( - ["python3", "test/system-test-sentry-server.py"], - stdout=log_file, - stderr=subprocess.STDOUT - ) - - # Store PID in instance variable and write to file - store_pid(self.mock_server) - print(f"Started mock server with PID {self.mock_server.pid}") - - # Wait a moment for the server to start - time.sleep(2) - - except Exception as e: - print(f"Failed to start mock server: {e}") - raise - - def stop_sentry_mock_server(self) -> None: - """Stop the Sentry mock server.""" - try: - # Try graceful shutdown first - try: - response = requests.get("http://127.0.0.1:8000/STOP", timeout=5) - print("Sent stop signal to mock server") - except: - print("Could not send graceful stop signal") - - # Kill the process - try process object first, then PID from file - if self.mock_server.process and self.mock_server.process.poll() is None: - print(f"Killing mock server process object with PID {self.mock_server.process.pid}") - self.mock_server.process.kill() - self.mock_server.process.wait(timeout=5) - elif self.mock_server.pid and self.is_process_running(self.mock_server.pid): - print(f"Killing mock server from PID file with PID {self.mock_server.pid}") - self.kill_process(self.mock_server.pid, "mock server") - - except Exception as e: - print(f"Error stopping mock server: {e}") - finally: - # Clean up PID file and instance variable - cleanup_pid(self.mock_server) - - def read_version_from_gradle_properties(self) -> Optional[str]: - """Read the versionName from gradle.properties.""" - try: - with open("gradle.properties", "r") as f: - for line in f: - line = line.strip() - if line.startswith("versionName="): - return line.split("=", 1)[1] - except (IOError, IndexError) as e: - print(f"Error reading version from gradle.properties: {e}") - return None - - def find_agent_jar(self) -> Optional[str]: - """Find the OpenTelemetry agent JAR file with the specific version from gradle.properties.""" - agent_dir = Path("sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/") - if not agent_dir.exists(): - return None - - # Get the version from gradle.properties - version = self.read_version_from_gradle_properties() - if not version: - print("Error: Could not read version from gradle.properties") - return None - - # Look for the specific versioned JAR - versioned_jar_pattern = f"*agent*{version}*.jar" - for jar_file in agent_dir.glob(versioned_jar_pattern): - name = jar_file.name - if ("javadoc" not in name and - "sources" not in name and - "dontuse" not in name): - print(f"Found versioned agent JAR: {jar_file}") - return str(jar_file) - - # If versioned JAR not found, print helpful message - print(f"Error: Could not find agent JAR with version {version} in {agent_dir}") - print("Available JAR files:") - for jar_file in agent_dir.glob("*.jar"): - print(f" {jar_file.name}") - return None - - def build_agent_jar(self) -> int: - """Build the OpenTelemetry agent JAR file.""" - print("Building OpenTelemetry agent JAR...") - return self.run_gradle_task(":sentry-opentelemetry:sentry-opentelemetry-agent:assemble") - - def ensure_agent_jar(self, skip_build: bool = False) -> Optional[str]: - """Ensure the OpenTelemetry agent JAR exists, building it if necessary.""" - agent_jar = self.find_agent_jar() - if agent_jar: - return agent_jar - - if skip_build: - print("OpenTelemetry agent JAR not found and build was skipped") - return None - - # Agent JAR doesn't exist, try to build it - print("OpenTelemetry agent JAR not found, building it...") - build_result = self.build_agent_jar() - if build_result != 0: - print("Failed to build OpenTelemetry agent JAR") - return None - - # Try to find it again after building - agent_jar = self.find_agent_jar() - if not agent_jar: - print("OpenTelemetry agent JAR still not found after building") - return None - - return agent_jar - - def start_tomcat_server(self, sample_module: str) -> None: - # Build environment variables - env = os.environ.copy() - env.update(SENTRY_ENVIRONMENT_VARIABLES) - - try: - # Start the Tomcat server - with open("tomcat-server.txt", "w") as log_file: - self.tomcat_server.process = subprocess.Popen( - ["./gradlew", f"sentry-samples:{sample_module}:run", "--console=plain"], - env=env, - stdout=log_file, - stderr=subprocess.STDOUT - ) - - store_pid(self.tomcat_server) - print(f"Started Tomcat server with PID {self.tomcat_server.pid}") - except Exception as e: - print(f"Failed to start Tomcat server: {e}") - raise - - def start_spring_server(self, sample_module: str, java_agent: str, java_agent_auto_init: str) -> None: - """Start a Spring Boot server for testing.""" - print(f"Starting Spring server for {sample_module}...") - - # Build environment variables - env = os.environ.copy() - env.update(SENTRY_ENVIRONMENT_VARIABLES) - env["SENTRY_AUTO_INIT"] = java_agent_auto_init - - if self.module_requires_kafka_profile(sample_module): - env["SPRING_PROFILES_ACTIVE"] = "kafka" - env["SENTRY_ENABLE_QUEUE_TRACING"] = "true" - print("Enabling Spring profile: kafka") - else: - env.pop("SPRING_PROFILES_ACTIVE", None) - - # Build command - jar_path = f"sentry-samples/{sample_module}/build/libs/{sample_module}-0.0.1-SNAPSHOT.jar" - cmd = ["java"] - - if java_agent == "1": - agent_jar = self.ensure_agent_jar() - if agent_jar: - cmd.append(f"-javaagent:{agent_jar}") - print(f"Using Java Agent: {agent_jar}") - else: - print("Warning: Java agent was requested but could not be found or built") - - cmd.extend(["-jar", jar_path]) - - try: - # Start the Spring server - with open("spring-server.txt", "w") as log_file: - self.spring_server.process = subprocess.Popen( - cmd, - env=env, - stdout=log_file, - stderr=subprocess.STDOUT - ) - - # Store PID in instance variable and write to file - store_pid(self.spring_server) - print(f"Started Spring server with PID {self.spring_server.pid}") - - except Exception as e: - print(f"Failed to start Spring server: {e}") - raise - - def wait_for_tomcat(self, module_name: str, max_attempts: int = 20) -> bool: - """Wait for Tomcat to be ready.""" - print("Waiting for Tomcat to be ready...") - - for attempt in range(1, max_attempts + 1): - try: - response = requests.head( - f"http://localhost:8080/{module_name}-0.0.1-SNAPSHOT/person/1", - timeout=5 - ) - if response.status_code != 404: - print("Tomcat is ready!") - return True - except: - pass - - print(f"Waiting... (attempt {attempt}/{max_attempts})") - time.sleep(1) - - print("Tomcat failed to become ready") - return False - - def wait_for_spring(self, max_attempts: int = 20) -> bool: - """Wait for Spring Boot application to be ready.""" - print("Waiting for Spring application to be ready...") - - for attempt in range(1, max_attempts + 1): - # All current Spring Boot samples expose actuator/health. Waiting for the - # health endpoint avoids false positives from unrelated services on 8080. - try: - response = requests.head( - "http://localhost:8080/actuator/health", - auth=("user", "password"), - timeout=5 - ) - if response.status_code == 200: - print("Spring application is ready!") - return True - except: - pass - - print(f"Waiting... (attempt {attempt}/{max_attempts})") - time.sleep(1) - - print("Spring application failed to become ready") - return False - - def get_spring_status(self) -> dict: - """Get status of Spring Boot application.""" - status = { - "process_running": False, - "pid": self.spring_server.pid, - "http_ready": False - } - - if self.spring_server.pid and self.is_process_running(self.spring_server.pid): - status["process_running"] = True - - # Check HTTP endpoint - try: - response = requests.head( - "http://localhost:8080/actuator/health", - auth=("user", "password"), - timeout=2 - ) - if response.status_code == 200: - status["http_ready"] = True - except: - pass - - return status - - def get_sentry_status(self) -> dict: - """Get status of Sentry mock server.""" - status = { - "process_running": False, - "pid": self.mock_server.pid, - "http_ready": False - } - - if self.mock_server.pid and self.is_process_running(self.mock_server.pid): - status["process_running"] = True - - # Check HTTP endpoint - try: - response = requests.get("http://127.0.0.1:8000/envelope-count", timeout=2) - if response.status_code == 200: - status["http_ready"] = True - except: - pass - - return status - - def print_status_summary(self) -> None: - """Print status summary of all services.""" - print("=== Service Status ===") - - sentry_status = self.get_sentry_status() - print(f"Sentry Mock Server:") - print(f" PID: {sentry_status['pid'] or 'None'}") - print(f" Process Running: {'✅' if sentry_status['process_running'] else '❌'}") - print(f" HTTP Ready: {'✅' if sentry_status['http_ready'] else '❌'}") - - spring_status = self.get_spring_status() - print(f"Spring Boot App:") - print(f" PID: {spring_status['pid'] or 'None'}") - print(f" Process Running: {'✅' if spring_status['process_running'] else '❌'}") - print(f" HTTP Ready: {'✅' if spring_status['http_ready'] else '❌'}") - - def stop_tomcat_server(self) -> None: - """Stop the Tomcat server.""" - try: - # Kill the process - try process object first, then PID from file - if self.tomcat_server.process and self.tomcat_server.process.poll() is None: - print(f"Killing Tomcat server process object with PID {self.tomcat_server.process.pid}") - self.tomcat_server.process.kill() - try: - self.tomcat_server.process.wait(timeout=10) - except subprocess.TimeoutExpired: - print("Tomcat server did not terminate gracefully") - elif self.tomcat_server.pid and self.is_process_running(self.tomcat_server.pid): - print(f"Killing Tomcat server from PID file with PID {self.tomcat_server.pid}") - self.kill_process(self.tomcat_server.pid, "Tomcat server") - - except Exception as e: - print(f"Error stopping Tomcat server: {e}") - finally: - # Clean up PID file and instance variable - cleanup_pid(self.tomcat_server) - - def stop_spring_server(self) -> None: - """Stop the Spring Boot server.""" - try: - # Kill the process - try process object first, then PID from file - if self.spring_server.process and self.spring_server.process.poll() is None: - print(f"Killing Spring server process object with PID {self.spring_server.process.pid}") - self.spring_server.process.kill() - try: - self.spring_server.process.wait(timeout=10) - except subprocess.TimeoutExpired: - print("Spring server did not terminate gracefully") - elif self.spring_server.pid and self.is_process_running(self.spring_server.pid): - print(f"Killing Spring server from PID file with PID {self.spring_server.pid}") - self.kill_process(self.spring_server.pid, "Spring server") - - except Exception as e: - print(f"Error stopping Spring server: {e}") - finally: - # Clean up PID file and instance variable - cleanup_pid(self.spring_server) - - def get_build_task(self, sample_module: str, server_type: Optional[ServerType]) -> str: - """Get the appropriate build task for a module.""" - if server_type == ServerType.TOMCAT: - return "war" - elif server_type == ServerType.SPRING: - # Modules using Shadow plugin (e.g. Spring Boot 2 samples) use shadowJar, - # modules using Spring Boot plugin (SB3/SB4 samples) use bootJar - build_file = Path(f"sentry-samples/{sample_module}/build.gradle.kts") - if build_file.exists() and "shadow" in build_file.read_text(): - return "shadowJar" - return "bootJar" - - return "assemble" - - def build_module(self, sample_module: str, server_type: Optional[ServerType]) -> int: - """Build a sample module using the appropriate task.""" - build_task = self.get_build_task(sample_module, server_type) - print(f"Building {sample_module} using {build_task} task") - return self.run_gradle_task(f":sentry-samples:{sample_module}:{build_task}") - - def run_gradle_task(self, task: str) -> int: - """Run a Gradle task and return the exit code.""" - print(f"Running: ./gradlew {task}") - try: - result = subprocess.run(["./gradlew", task, "--console=plain"], check=False) - return result.returncode - except Exception as e: - print(f"Failed to run Gradle task: {e}") - return 1 - - def setup_test_infrastructure(self, sample_module: str, java_agent: str, - java_agent_auto_init: str, build_before_run: str, - server_type: Optional[ServerType]) -> int: - """Set up test infrastructure. Returns 0 on success, error code on failure.""" - if self.module_requires_kafka(sample_module): - self.start_kafka_broker() - os.environ["SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS"] = KAFKA_BOOTSTRAP_SERVERS - else: - os.environ.pop("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS", None) - - # Build if requested - if build_before_run == "1": - print("Building before test run") - build_result = self.build_module(sample_module, server_type) - if build_result != 0: - print("Build failed") - return build_result - - # Ensure agent JAR is available if needed - if java_agent == "1": - agent_jar = self.ensure_agent_jar() - if not agent_jar: - print("Error: Java agent was requested but could not be found or built") - return 1 - - # Start mock server - print("Starting Sentry mock server...") - self.start_sentry_mock_server() - - # Start Tomcat server given a Spring module - if server_type == ServerType.TOMCAT: - print(f"Starting Tomcat server for {sample_module}...") - self.start_tomcat_server(sample_module) - if not self.wait_for_tomcat(sample_module): - print("Tomcat application failed to start!") - return 1 - print("Tomcat application is ready!") - # Start Spring server if it's a Spring boot module - elif server_type == ServerType.SPRING: - print(f"Starting Spring server for {sample_module}...") - self.start_spring_server(sample_module, java_agent, java_agent_auto_init) - if not self.wait_for_spring(): - print("Spring application failed to start!") - return 1 - print("Spring application is ready!") - - return 0 - - def run_single_test(self, sample_module: str, java_agent: str, - java_agent_auto_init: str, build_before_run: str) -> int: - """Run a single system test.""" - print(f"Running system test for {sample_module}") - - server_type = get_server_type_for_module(sample_module) - - try: - # Set up infrastructure - setup_result = self.setup_test_infrastructure(sample_module, - java_agent, - java_agent_auto_init, - build_before_run, - server_type) - if setup_result != 0: - return setup_result - - # Run the system test - test_result = self.run_gradle_task(f":sentry-samples:{sample_module}:systemTest") - - return test_result - - finally: - # Cleanup - if server_type == ServerType.TOMCAT: - self.stop_tomcat_server() - elif server_type == ServerType.SPRING: - self.stop_spring_server() - self.stop_sentry_mock_server() - self.stop_kafka_broker() - os.environ.pop("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS", None) - - def run_all_tests(self) -> int: - """Run all system tests.""" - test_configs = self.get_available_modules() - - failed_tests = [] - - for i, module_config in enumerate(test_configs): - # Convert true/false to internal 1/0 format - agent = str_to_bool(module_config.java_agent) - auto_init = module_config.java_agent_auto_init # already in correct format - build = str_to_bool(module_config.build_before_run) - - print(f"\n{'='*TERMINAL_COLUMNS}") - print(f"Running test {i + 1}/{len(test_configs)}: {module_config.name} (agent={module_config.java_agent}, auto_init={module_config.java_agent_auto_init})") - print(f"{'='*TERMINAL_COLUMNS}") - - result = self.run_single_test(module_config.name, agent, auto_init, build) - - if result != 0: - # Find the module number in the full list for interactive reference - module_number = self._find_module_number(module_config.name, module_config.java_agent, module_config.java_agent_auto_init) - failed_tests.append((module_number, module_config.name, module_config.java_agent, module_config.java_agent_auto_init)) - print(f"❌ Test failed: {module_config.name}") - else: - print(f"✅ Test passed: {module_config.name}") - - # Summary - print(f"\n{'='*TERMINAL_COLUMNS}") - print("TEST SUMMARY") - print(f"{'='*TERMINAL_COLUMNS}") - print(f"Total tests: {len(test_configs)}") - print(f"Passed: {len(test_configs) - len(failed_tests)}") - print(f"Failed: {len(failed_tests)}") - - if failed_tests: - print("\nFailed tests (for interactive mode, use these numbers):") - for module_number, sample_module, java_agent, java_agent_auto_init in failed_tests: - print(f" {module_number}. {sample_module} (agent={java_agent}, auto_init={java_agent_auto_init})") - return 1 - else: - print("\n🎉 All tests passed!") - return 0 - - def run_manual_test_mode(self, sample_module: str, java_agent: str, - java_agent_auto_init: str, build_before_run: str) -> int: - """Set up infrastructure for manual testing from IDE.""" - print(f"Setting up manual test environment for {sample_module}") - - server_type = get_server_type_for_module(sample_module) - try: - # Set up infrastructure - setup_result = self.setup_test_infrastructure(sample_module, java_agent, java_agent_auto_init, build_before_run, server_type) - if setup_result != 0: - return setup_result - - # Show status and wait for user - print("\n" + "="*TERMINAL_COLUMNS) - print("🚀 Manual test environment ready 🚀") - print("="*TERMINAL_COLUMNS) - self.print_status_summary() - print(f"\nInfrastructure is ready for manual testing of: {sample_module}") - print("You can now run your system tests from your IDE.") - print("\nTest configuration:") - print(f" - Module: {sample_module}") - print(f" - Java Agent: {'Yes' if java_agent == '1' else 'No'}") - print(f" - Agent Auto-init: {java_agent_auto_init}") - print(f" - Mock DSN: http://502f25099c204a2fbf4cb16edc5975d1@localhost:8000/0") - - if server_type == ServerType.TOMCAT: - print(f"\nTomcat app is running on: http://localhost:8080/{sample_module}-0.0.1-SNAPSHOT") - elif server_type == ServerType.SPRING: - print("\nSpring Boot app is running on: http://localhost:8080") - - print("\nPress Enter to stop the infrastructure and exit...") - - # Wait for user input - try: - input() - except KeyboardInterrupt: - print("\nReceived interrupt signal") - - print("\nStopping infrastructure...") - return 0 - - finally: - # Cleanup will happen in the finally block of main() - pass - - def get_available_modules(self) -> List[ModuleConfig]: - """Get list of all available test modules.""" - return [ - ModuleConfig("sentry-samples-spring-boot-4-otlp", "false", "true", "false"), - ModuleConfig("sentry-samples-console", "false", "true", "false"), - ModuleConfig("sentry-samples-console-otlp", "false", "true", "false"), - ModuleConfig("sentry-samples-console-opentelemetry-noagent", "false", "true", "false"), - ] - - def _find_module_number(self, module_name: str, agent: str, auto_init: str) -> int: - """Find the module number in the interactive list (1-based).""" - modules = self.get_available_modules() - for i, module_config in enumerate(modules, 1): - if (module_config.name == module_name and - module_config.java_agent == agent and - module_config.java_agent_auto_init == auto_init): - return i - return 0 # Should not happen, but return 0 if not found - - def parse_selection(self, user_input: str, max_index: int) -> List[int]: - """Parse user selection string into list of indices.""" - if user_input.strip() == "*": - return list(range(max_index)) - - indices = [] - parts = user_input.split(",") - - for part in parts: - part = part.strip() - if "-" in part: - # Handle range like "1-4" - try: - start, end = map(int, part.split("-")) - # Convert from 1-based to 0-based indexing - indices.extend(range(start - 1, end)) - except ValueError: - raise ValueError(f"Invalid range format: {part}") - else: - # Handle single number - try: - # Convert from 1-based to 0-based indexing - indices.append(int(part) - 1) - except ValueError: - raise ValueError(f"Invalid number: {part}") - - # Remove duplicates and sort - indices = sorted(set(indices)) - - # Validate indices - for idx in indices: - if idx < 0 or idx >= max_index: - raise ValueError(f"Index {idx + 1} is out of range (1-{max_index})") - - return indices - - def interactive_module_selection(self) -> InteractiveSelection: - """Display modules and get user selection.""" - modules = self.get_available_modules() - - print("\nAvailable test modules:") - print("=" * 80) - for i, module_config in enumerate(modules, 1): - agent_text = "with agent" if module_config.uses_agent() else "no agent" - auto_init_text = f"auto-init: {module_config.java_agent_auto_init}" - print(f"{i:2d}. {module_config.name:<50} ({agent_text}, {auto_init_text})") - - print("\nSelection options:") - print(" * = all modules") - print(" Single: 1, 5, 8") - print(" Range: 1-4, 6-8") - print(" Combined: 1,2,4-5,8") - - selected_modules = [] - while True: - try: - user_input = input("\nEnter your selection: ").strip() - if not user_input: - print("Please enter a selection.") - continue - - selected_indices = self.parse_selection(user_input, len(modules)) - selected_modules = [modules[i] for i in selected_indices] - - # Show confirmation - print(f"\nSelected {len(selected_modules)} module(s):") - for i, module_config in enumerate(selected_modules, 1): - agent_text = "with agent" if module_config.uses_agent() else "no agent" - print(f" {i}. {module_config.name} ({agent_text}, auto-init: {module_config.java_agent_auto_init})") - - confirm = input("\nProceed with these selections? [Y/n]: ").strip().lower() - if confirm in ('', 'y', 'yes'): - break - else: - print("Please make a new selection.") - - except ValueError as e: - print(f"Error: {e}") - print("Please try again.") - except KeyboardInterrupt: - print("\nOperation cancelled.") - return InteractiveSelection(modules=[], manual_test_mode=False, build_agent=False) - - # Ask about test mode - manual_test_mode = False - while True: - try: - mode_input = input("\nRun tests automatically (n = only set up infrastucture for testing in IDE)? [Y/n]: ").strip().lower() - if not mode_input or mode_input in ('y', 'yes'): - manual_test_mode = False - break - elif mode_input in ('n', 'no'): - manual_test_mode = True - break - else: - print("Please enter 'y' or 'n'.") - except KeyboardInterrupt: - print("\nOperation cancelled.") - return InteractiveSelection(modules=[], manual_test_mode=False, build_agent=False) - - # Ask about building agent if any modules use it - build_agent = False - has_agent_modules = any(module_config.uses_agent() for module_config in selected_modules) - if has_agent_modules: - while True: - try: - agent_input = input("\nBuild OpenTelemetry agent JAR (recommended to ensure latest version)? [Y/n]: ").strip().lower() - if not agent_input or agent_input in ('y', 'yes'): - build_agent = True - break - elif agent_input in ('n', 'no'): - build_agent = False - break - else: - print("Please enter 'y' or 'n'.") - except KeyboardInterrupt: - print("\nOperation cancelled.") - return InteractiveSelection(modules=[], manual_test_mode=False, build_agent=False) - - return InteractiveSelection(modules=selected_modules, manual_test_mode=manual_test_mode, build_agent=build_agent) - - def run_interactive_tests(self, agent: str, auto_init: str, build: str) -> int: - """Run tests with interactive module selection.""" - selection = self.interactive_module_selection() - - if selection.is_empty(): - print("No modules selected. Exiting.") - return 0 - - # Build agent JAR if requested and modules use agent - if selection.build_agent and selection.has_agent_modules(): - print("\nBuilding OpenTelemetry agent JAR...") - build_result = self.build_agent_jar() - if build_result != 0: - print("Failed to build OpenTelemetry agent JAR") - return build_result - print("✅ OpenTelemetry agent JAR built successfully") - - # Handle manual test mode - if selection.manual_test_mode: - if not selection.is_single_module(): - print("Error: Manual test mode can only be used with a single module.") - print("Please select only one module for manual testing.") - return 1 - - module_config = selection.get_first_module() - # Convert true/false to internal 1/0 format - agent = str_to_bool(module_config.java_agent) - auto_init = module_config.java_agent_auto_init # already in correct format - build = str_to_bool(module_config.build_before_run) - - print(f"\nSetting up manual test environment for: {module_config.name}") - return self.run_manual_test_mode(module_config.name, agent, auto_init, build) - - # Handle automatic test running - failed_tests = [] - - for i, module_config in enumerate(selection.modules, 1): - # Convert true/false to internal 1/0 format - agent = str_to_bool(module_config.java_agent) - auto_init = module_config.java_agent_auto_init # already in correct format - build = str_to_bool(module_config.build_before_run) - - print(f"\n{'='*TERMINAL_COLUMNS}") - print(f"Running test {i}/{len(selection.modules)}: {module_config.name}") - print(f"Agent: {module_config.java_agent}, Auto-init: {module_config.java_agent_auto_init}") - print(f"{'='*TERMINAL_COLUMNS}") - - result = self.run_single_test(module_config.name, agent, auto_init, build) - - if result != 0: - # Find the module number in the full list for interactive reference - module_number = self._find_module_number(module_config.name, module_config.java_agent, module_config.java_agent_auto_init) - failed_tests.append((module_number, module_config.name, module_config.java_agent, module_config.java_agent_auto_init)) - print(f"❌ Test failed: {module_config.name}") - else: - print(f"✅ Test passed: {module_config.name}") - - # Summary - print(f"\n{'='*TERMINAL_COLUMNS}") - print("TEST SUMMARY") - print(f"{'='*TERMINAL_COLUMNS}") - print(f"Total tests: {len(selection.modules)}") - print(f"Passed: {len(selection.modules) - len(failed_tests)}") - print(f"Failed: {len(failed_tests)}") - - if failed_tests: - print("\nFailed tests (for interactive mode, use these numbers):") - for module_number, sample_module, test_agent, test_auto_init in failed_tests: - print(f" {module_number}. {sample_module} (agent={test_agent}, auto_init={test_auto_init})") - return 1 - else: - print("\n🎉 All tests passed!") - return 0 - - def cleanup_on_exit(self, signum, frame): - """Cleanup handler for signals.""" - print(f"\nReceived signal {signum}, cleaning up...") - self.stop_spring_server() - self.stop_sentry_mock_server() - self.stop_tomcat_server() - self.stop_kafka_broker() - os.environ.pop("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS", None) - sys.exit(1) - -def main(): - parser = argparse.ArgumentParser(description="System Test Runner for Sentry Java") - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # Test subcommand - test_parser = subparsers.add_parser("test", help="Run system tests") - test_group = test_parser.add_mutually_exclusive_group(required=True) - test_group.add_argument("--all", action="store_true", help="Run all system tests") - test_group.add_argument("--module", help="Sample module to test") - test_group.add_argument("--interactive", "-i", action="store_true", help="Interactive module selection") - test_parser.add_argument("--agent", default="false", help="Use Java agent (true or false)") - test_parser.add_argument("--auto-init", default="true", help="Auto-init agent (true or false)") - test_parser.add_argument("--build", default="false", help="Build before running (true or false)") - test_parser.add_argument("--manual-test", action="store_true", help="Set up infrastructure but pause for manual testing from IDE") - - # Tomcat subcommand - tomcat_parser = subparsers.add_parser("tomcat", help="Manage Servlet applications deployed on Tomcat") - tomcat_subparsers = tomcat_parser.add_subparsers(dest="tomcat_action", help="Tomcat actions") - - tomcat_start_parser = tomcat_subparsers.add_parser("start", help="Start tomcat application") - tomcat_start_parser.add_argument("module", help="Sample module to start") - tomcat_start_parser.add_argument("--build", default="false", help="Build before starting (true or false)") - - tomcat_subparsers.add_parser("stop", help="Stop Spring Boot application") - - # Spring subcommand - spring_parser = subparsers.add_parser("spring", help="Manage Spring Boot applications") - spring_subparsers = spring_parser.add_subparsers(dest="spring_action", help="Spring actions") - - spring_start_parser = spring_subparsers.add_parser("start", help="Start Spring Boot application") - spring_start_parser.add_argument("module", help="Sample module to start") - spring_start_parser.add_argument("--agent", default="false", help="Use Java agent (true or false)") - spring_start_parser.add_argument("--auto-init", default="true", help="Auto-init agent (true or false)") - spring_start_parser.add_argument("--build", default="false", help="Build before starting (true or false)") - - spring_stop_parser = spring_subparsers.add_parser("stop", help="Stop Spring Boot application") - - spring_wait_parser = spring_subparsers.add_parser("wait", help="Wait for Spring Boot application to be ready") - spring_wait_parser.add_argument("--timeout", type=int, default=20, help="Max attempts to wait (default: 20)") - - spring_status_parser = spring_subparsers.add_parser("status", help="Check Spring Boot application status") - - # Sentry subcommand - sentry_parser = subparsers.add_parser("sentry", help="Manage Sentry mock server") - sentry_subparsers = sentry_parser.add_subparsers(dest="sentry_action", help="Sentry actions") - - sentry_start_parser = sentry_subparsers.add_parser("start", help="Start Sentry mock server") - sentry_stop_parser = sentry_subparsers.add_parser("stop", help="Stop Sentry mock server") - sentry_status_parser = sentry_subparsers.add_parser("status", help="Check Sentry mock server status") - - # Status subcommand - status_parser = subparsers.add_parser("status", help="Show status of all services") - - args = parser.parse_args() - - if not args.command: - parser.print_help() - return 1 - - runner = SystemTestRunner() - - # Set up signal handlers for cleanup - signal.signal(signal.SIGINT, runner.cleanup_on_exit) - signal.signal(signal.SIGTERM, runner.cleanup_on_exit) - - try: - if args.command == "test": - # Convert true/false arguments to internal 1/0 format - agent = str_to_bool(args.agent) - auto_init = args.auto_init # already accepts true/false - build = str_to_bool(args.build) - - if args.manual_test and args.module: - return runner.run_manual_test_mode(args.module, agent, auto_init, build) - elif args.manual_test and args.all: - print("Error: --manual-test requires a specific --module, cannot be used with --all") - return 1 - elif args.manual_test and args.interactive: - print("Error: --manual-test cannot be used with --interactive") - return 1 - elif args.all: - return runner.run_all_tests() - elif args.module: - return runner.run_single_test(args.module, agent, auto_init, build) - elif args.interactive: - return runner.run_interactive_tests(agent, auto_init, build) - - elif args.command == "tomcat": - if args.tomcat_action == "start": - # Convert true/false arguments to internal format - build = str_to_bool(args.build) - - # Build if requested - if build == "1": - print("Building before starting Tomcat application") - build_result = runner.build_module(args.module, ServerType.TOMCAT) - if build_result != 0: - print("Build failed") - return build_result - - runner.start_tomcat_server(args.module) - - if runner.wait_for_tomcat(args.module): - print("Tomcat application started successfully!") - return 0 - else: - print("Tomcat application failed to start!") - return 1 - elif args.tomcat_action == "stop": - runner.stop_tomcat_server() - print("Tomcat application stopped.") - return 0 - - elif args.command == "spring": - if args.spring_action == "start": - # Convert true/false arguments to internal format - agent = str_to_bool(args.agent) - auto_init = args.auto_init # already accepts true/false - build = str_to_bool(args.build) - - # Build if requested - if build == "1": - print("Building before starting Spring application") - build_result = runner.build_module(args.module, ServerType.SPRING) - if build_result != 0: - print("Build failed") - return build_result - - runner.start_spring_server(args.module, agent, auto_init) - if runner.wait_for_spring(): - print("Spring application started successfully!") - return 0 - else: - print("Spring application failed to start!") - return 1 - elif args.spring_action == "stop": - runner.stop_spring_server() - print("Spring application stopped.") - return 0 - elif args.spring_action == "wait": - if runner.wait_for_spring(max_attempts=args.timeout): - print("Spring application is ready!") - return 0 - else: - print("Spring application is not ready!") - return 1 - elif args.spring_action == "status": - status = runner.get_spring_status() - print(f"Spring Boot Application Status:") - print(f" PID: {status['pid'] or 'None'}") - print(f" Process Running: {'✅' if status['process_running'] else '❌'}") - print(f" HTTP Ready: {'✅' if status['http_ready'] else '❌'}") - return 0 if (status['process_running'] and status['http_ready']) else 1 - else: - spring_parser.print_help() - return 1 - - elif args.command == "sentry": - if args.sentry_action == "start": - runner.start_sentry_mock_server() - print("Sentry mock server started successfully!") - return 0 - elif args.sentry_action == "stop": - runner.stop_sentry_mock_server() - print("Sentry mock server stopped.") - return 0 - elif args.sentry_action == "status": - status = runner.get_sentry_status() - print(f"Sentry Mock Server Status:") - print(f" PID: {status['pid'] or 'None'}") - print(f" Process Running: {'✅' if status['process_running'] else '❌'}") - print(f" HTTP Ready: {'✅' if status['http_ready'] else '❌'}") - return 0 if (status['process_running'] and status['http_ready']) else 1 - else: - sentry_parser.print_help() - return 1 - - elif args.command == "status": - runner.print_status_summary() - return 0 - else: - parser.print_help() - return 1 - - except KeyboardInterrupt: - print("\nInterrupted by user") - return 1 - except Exception as e: - print(f"Error: {e}") - return 1 - finally: - # Only cleanup if running tests or manual test mode, not for individual start/stop commands - if args.command == "test": - runner.stop_spring_server() - runner.stop_sentry_mock_server() - runner.stop_tomcat_server() - runner.stop_kafka_broker() - os.environ.pop("SENTRY_SAMPLE_KAFKA_BOOTSTRAP_SERVERS", None) - -if __name__ == "__main__": - sys.exit(main()) diff --git a/test/system-test-sentry-server.py b/test/system-test-sentry-server.py deleted file mode 100755 index 28232410622..00000000000 --- a/test/system-test-sentry-server.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python3 - -from http import HTTPStatus -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import urlparse -import sys -import threading -import binascii -import json -import gzip - -apiOrg = 'sentry-sdks' -apiProject = 'sentry-java' -uri = urlparse(sys.argv[1] if len(sys.argv) > 1 else 'http://127.0.0.1:8000') -version='1.1.0' -appIdentifier='com.sentry.fastlane.app' - -class EnvelopeStorage: - __envelopes_received = [] - - @classmethod - def add(cls, envelope): - cls.__envelopes_received.append(envelope) - - @classmethod - def get_envelopes_received(cls): - return cls.__envelopes_received - - @classmethod - def get_json(cls): - jsonObject = { - 'envelopes': cls.__envelopes_received - } - return json.dumps(jsonObject) - - @classmethod - def reset(cls): - cls.__envelopes_received.clear() - -class EnvelopeCount: - __envelopes_received = 0 - - @classmethod - def increment(cls): - cls.__envelopes_received += 1 - - @classmethod - def get_envelopes_received(cls): - return cls.__envelopes_received - - @classmethod - def get_json(cls): - jsonObject = { - 'envelopes': cls.__envelopes_received - } - return json.dumps(jsonObject) - - @classmethod - def reset(cls): - cls.__envelopes_received = 0 - - -class Handler(BaseHTTPRequestHandler): - body = None - - def do_GET(self): - self.start_response(HTTPStatus.OK) - - if self.path == "/STOP": - print("HTTP server stopping!") - threading.Thread(target=self.server.shutdown).start() - return - - if self.path == "/envelope-count": - print("Envelope count queried " + str(EnvelopeCount.get_envelopes_received())) - self.writeJSON(EnvelopeCount.get_json()) - return - - if self.path == "/envelopes-received": - print("Envelopes queried ") - self.writeJSON(EnvelopeStorage.get_json()) - return - - if self.path == "/reset": - print("Envelopes reset") - EnvelopeStorage.reset() - EnvelopeCount.reset() - self.writeJSON(json.dumps({})) - return - - self.flushLogs() - - def do_POST(self): - if self.isApi('/api/0/envelope/'): - EnvelopeCount.increment() - self.start_response(HTTPStatus.OK) - self.end_headers() - - self.flushLogs() - - def do_PUT(self): - self.start_response(HTTPStatus.OK) - self.end_headers() - - self.flushLogs() - - def start_response(self, code): - self.body = None - self.log_request(code) - self.send_response_only(code) - - def log_request(self, code=None, size=None): - if isinstance(code, HTTPStatus): - code = code.value - body = self.body = self.requestBody() - - if body: - EnvelopeStorage.add(str(body)) - body = self.body[0:min(1000, len(body))] - self.log_message('"%s" %s %s%s', - self.requestline, str(code), "({} bytes)".format(len(body)) if size else '', body) - - # Note: this may only be called once during a single request - can't `.read()` the same stream again. - def requestBody(self): - if self.command == "POST" and 'Content-Length' in self.headers: - content = self.getContent() - - try: - return content.decode("utf-8", errors='replace') - except Exception as e: - print(e) - return binascii.hexlify(bytearray(content)) - return None - - def isApi(self, api: str): - if self.path.strip('/') == api.strip('/'): - self.log_message("Matched API endpoint {}".format(api)) - return True - return False - - def writeJSONFile(self, file_name: str): - json_file = open(file_name, "r") - self.writeJSON(json_file.read()) - json_file.close() - - def writeJSON(self, string: str): - self.send_header("Content-type", "application/json") - self.end_headers() - self.wfile.write(str.encode(string)) - - def flushLogs(self): - sys.stdout.flush() - sys.stderr.flush() - - def getContent(self): - length = int(self.headers['Content-Length']) - content = self.rfile.read(length) - - if 'Content-Encoding' in self.headers and self.headers['Content-Encoding'] == 'gzip': - content = gzip.decompress(content) - - return content - - -print("HTTP server listening on {}".format(uri.geturl())) -print("To stop the server, execute a GET request to {}/STOP".format(uri.geturl())) -httpd = ThreadingHTTPServer((uri.hostname, uri.port), Handler) -target = httpd.serve_forever()