diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index d884196b7f..100f8ed075 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -20,10 +20,13 @@ jobs: fail-fast: false matrix: sample: [ "sentry-samples-spring-boot-jakarta" ] + agent: "" include: - sample: "sentry-samples-spring-boot" - sample: "sentry-samples-spring-boot-webflux-jakarta" - sample: "sentry-samples-spring-boot-webflux" + - sample: "sentry-samples-spring-boot-jakarta-opentelemetry" + agent: "-javaagent:./sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/sentry-opentelemetry-agent-8.0.0-beta.1.jar" steps: - uses: actions/checkout@v4 with: @@ -56,9 +59,13 @@ jobs: run: | ./gradlew :sentry-samples:${{ matrix.sample }}:bootJar + - name: Build agent jar + run: | + ./gradlew :sentry-opentelemetry:sentry-opentelemetry-agent:assemble + - name: Start server and run integration test for sentry-cli commands run: | - test/system-test-sentry-server-start.sh > sentry-mock-server.txt 2>&1 & test/system-test-spring-server-start.sh "${{ matrix.sample }}" > spring-server.txt 2>&1 & test/wait-for-spring.sh && ./gradlew :sentry-samples:${{ matrix.sample }}:systemTest + test/system-test-sentry-server-start.sh > sentry-mock-server.txt 2>&1 & test/system-test-spring-server-start.sh "${{ matrix.sample }}" "${{ matrix.agent }}" > spring-server.txt 2>&1 & test/wait-for-spring.sh && ./gradlew :sentry-samples:${{ matrix.sample }}:systemTest - name: Upload test results if: always() diff --git a/build.gradle.kts b/build.gradle.kts index 3dace3fc70..b80ec8ac14 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -60,6 +60,7 @@ apiValidation { "sentry-samples-spring-jakarta", "sentry-samples-spring-boot", "sentry-samples-spring-boot-jakarta", + "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-webflux", "sentry-samples-spring-boot-webflux-jakarta", "sentry-uitest-android", diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/README.md b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/README.md new file mode 100644 index 0000000000..58b94ba899 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/README.md @@ -0,0 +1,122 @@ +# Sentry Sample Spring Boot 3.0+ + +Sample application showing how to use Sentry with [Spring boot](http://spring.io/projects/spring-boot) from version `3.0` onwards. + +## How to run? + +To see events triggered in this sample application in your Sentry dashboard, go to `src/main/resources/application.properties` and replace the test DSN with your own DSN. + +Then, execute a command from the module directory: + +``` +../../gradlew bootRun +``` + +Make an HTTP request that will trigger events: + +``` +curl -XPOST --user user:password http://localhost:8080/person/ -H "Content-Type:application/json" -d '{"firstName":"John","lastName":"Smith"}' +``` + +## GraphQL + +The following queries can be used to test the GraphQL integration. + +### Greeting +``` +{ + greeting(name: "crash") +} +``` + +### Greeting with variables + +``` +query GreetingQuery($name: String) { + greeting(name: $name) +} +``` +variables: +``` +{ + "name": "crash" +} +``` + +### Project + +``` +query ProjectQuery($slug: ID!) { + project(slug: $slug) { + slug + name + repositoryUrl + status + } +} +``` +variables: +``` +{ + "slug": "statuscrash" +} +``` + +### Mutation + +``` +mutation AddProjectMutation($slug: ID!) { + addProject(slug: $slug) +} +``` +variables: +``` +{ + "slug": "nocrash", + "name": "nocrash" +} +``` + +### Subscription + +``` +subscription SubscriptionNotifyNewTask($slug: ID!) { + notifyNewTask(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` + +### Data loader + +``` +query TasksAndAssigneesQuery($slug: ID!) { + tasks(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts new file mode 100644 index 0000000000..15c385d357 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -0,0 +1,108 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id(Config.BuildPlugins.springBoot) version Config.springBoot3Version + id(Config.BuildPlugins.springDependencyManagement) version Config.BuildPlugins.springDependencyManagementVersion + kotlin("jvm") + kotlin("plugin.spring") version Config.kotlinVersion + id("com.apollographql.apollo3") version "3.8.2" +} + +group = "io.sentry.sample.spring-boot-jakarta" +version = "0.0.1-SNAPSHOT" +java.sourceCompatibility = JavaVersion.VERSION_17 +java.targetCompatibility = JavaVersion.VERSION_17 + +repositories { + mavenCentral() +} + +configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = JavaVersion.VERSION_17.toString() + } +} + +dependencies { + implementation(Config.Libs.springBoot3StarterSecurity) + implementation(Config.Libs.springBoot3StarterActuator) + implementation(Config.Libs.springBoot3StarterWeb) + implementation(Config.Libs.springBoot3StarterWebsocket) + implementation(Config.Libs.springBoot3StarterGraphql) + implementation(Config.Libs.springBoot3StarterQuartz) + implementation(Config.Libs.springBoot3StarterWebflux) + implementation(Config.Libs.springBoot3StarterAop) + implementation(Config.Libs.aspectj) + implementation(Config.Libs.springBoot3Starter) + implementation(Config.Libs.kotlinReflect) + implementation(Config.Libs.springBootStarterJdbc) + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation(projects.sentrySpringBootStarterJakarta) + implementation(projects.sentryLogback) + implementation(projects.sentryGraphql22) + implementation(projects.sentryQuartz) + implementation(Config.Libs.OpenTelemetry.otelSdk) + + // database query tracing + implementation(projects.sentryJdbc) + runtimeOnly(Config.TestLibs.hsqldb) + testImplementation(Config.Libs.springBoot3StarterTest) { + exclude(group = "org.junit.vintage", module = "junit-vintage-engine") + } + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation("ch.qos.logback:logback-classic:1.3.5") + testImplementation(Config.Libs.slf4jApi2) + testImplementation(Config.Libs.apolloKotlin) +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +tasks.register("systemTest").configure { + group = "verification" + description = "Runs the System tests" + +// maxParallelForks = Runtime.getRuntime().availableProcessors() / 2 + maxParallelForks = 1 + + // Cap JVM args per test + minHeapSize = "128m" + maxHeapSize = "1g" + + filter { + includeTestsMatching("io.sentry.systemtest*") + } +} + +tasks.named("test").configure { + require(this is Test) + + filter { + excludeTestsMatching("io.sentry.systemtest.*") + } +} + +apollo { + service("service") { + srcDir("src/test/graphql") + packageName.set("io.sentry.samples.graphql") + outputDirConnection { + connectToKotlinSourceSet("test") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomEventProcessor.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomEventProcessor.java new file mode 100644 index 0000000000..51451a5d77 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomEventProcessor.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.SentryEvent; +import io.sentry.protocol.SentryRuntime; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.SpringBootVersion; +import org.springframework.stereotype.Component; + +/** + * Custom {@link EventProcessor} implementation lets modifying {@link SentryEvent}s before they are + * sent to Sentry. + */ +@Component +public class CustomEventProcessor implements EventProcessor { + private final String springBootVersion; + + public CustomEventProcessor(String springBootVersion) { + this.springBootVersion = springBootVersion; + } + + public CustomEventProcessor() { + this(SpringBootVersion.getVersion()); + } + + @Override + public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { + final SentryRuntime runtime = new SentryRuntime(); + runtime.setVersion(springBootVersion); + runtime.setName("Spring Boot"); + event.getContexts().setRuntime(runtime); + return event; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java new file mode 100644 index 0000000000..4cd609d67c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.sentry.spring.jakarta.checkin.SentryCheckIn; +import io.sentry.spring.jakarta.tracing.SentryTransaction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * {@link SentryTransaction} added on the class level, creates transaction around each method + * execution of every method of the annotated class. + */ +@Component +@SentryTransaction(operation = "scheduled") +public class CustomJob { + + private static final Logger LOGGER = LoggerFactory.getLogger(CustomJob.class); + + @SentryCheckIn("monitor_slug_1") + // @Scheduled(fixedRate = 3 * 60 * 1000L) + void execute() throws InterruptedException { + LOGGER.info("Executing scheduled job"); + Thread.sleep(2000L); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/Person.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/Person.java new file mode 100644 index 0000000000..2c8bdc33c6 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/Person.java @@ -0,0 +1,24 @@ +package io.sentry.samples.spring.boot.jakarta; + +public class Person { + private final String firstName; + private final String lastName; + + public Person(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + @Override + public String toString() { + return "Person{" + "firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + '}'; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java new file mode 100644 index 0000000000..341555b1e0 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonController.java @@ -0,0 +1,60 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import io.sentry.ISpan; +import io.sentry.Sentry; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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("/person/") +public class PersonController { + private final PersonService personService; + private final Tracer tracer; + private static final Logger LOGGER = LoggerFactory.getLogger(PersonController.class); + + public PersonController(PersonService personService, Tracer tracer) { + this.personService = personService; + this.tracer = tracer; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + Span span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan(); + try (final @NotNull Scope spanScope = span.makeCurrent()) { + ISpan sentrySpan = Sentry.getSpan().startChild("spanCreatedThroughSentryApi"); + try { + LOGGER.error("Trying person with id={}", id, new RuntimeException("error while loading")); + throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); + } finally { + sentrySpan.finish(); + } + } finally { + span.end(); + } + } + + @PostMapping + Person create(@RequestBody Person person) { + Span span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan(); + try (final @NotNull Scope spanScope = span.makeCurrent()) { + ISpan sentrySpan = Sentry.getSpan().startChild("spanCreatedThroughSentryApi"); + try { + return personService.create(person); + } finally { + sentrySpan.finish(); + } + } finally { + span.end(); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonService.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonService.java new file mode 100644 index 0000000000..50cdc9dd4e --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/PersonService.java @@ -0,0 +1,41 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.sentry.ISpan; +import io.sentry.Sentry; +import io.sentry.spring.jakarta.tracing.SentrySpan; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * {@link SentrySpan} can be added either on the class or the method to create spans around method + * executions. + */ +@Service +@SentrySpan +public class PersonService { + private static final Logger LOGGER = LoggerFactory.getLogger(PersonService.class); + + private final JdbcTemplate jdbcTemplate; + private int createCount = 0; + + public PersonService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + Person create(Person person) { + createCount++; + final ISpan span = Sentry.getSpan(); + if (span != null) { + span.setMeasurement("create_count", createCount); + } + + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.getFirstName(), + person.getLastName()); + + return person; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/SecurityConfiguration.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/SecurityConfiguration.java new file mode 100644 index 0000000000..e5987c8f4a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/SecurityConfiguration.java @@ -0,0 +1,40 @@ +package io.sentry.samples.spring.boot.jakarta; + +import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfiguration { + + // this API is meant to be consumed by non-browser clients thus the CSRF protection is not needed. + @SuppressWarnings({"lgtm[java/spring-disabled-csrf-protection]", "removal"}) + @Bean + public SecurityFilterChain filterChain(final @NotNull HttpSecurity http) throws Exception { + http.csrf().disable().authorizeHttpRequests().anyRequest().authenticated().and().httpBasic(); + + return http.build(); + } + + @Bean + public @NotNull InMemoryUserDetailsManager userDetailsService() { + final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + final UserDetails user = + User.builder() + .passwordEncoder(encoder::encode) + .username("user") + .password("password") + .roles("USER") + .build(); + + return new InMemoryUserDetailsManager(user); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java new file mode 100644 index 0000000000..96c3a7764d --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java @@ -0,0 +1,69 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Tracer; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +@SpringBootApplication +@EnableScheduling +public class SentryDemoApplication { + public static void main(String[] args) { + SpringApplication.run(SentryDemoApplication.class, args); + } + + @Bean + RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder.build(); + } + + @Bean + WebClient webClient(WebClient.Builder builder) { + return builder.build(); + } + + @Bean + RestClient restClient(RestClient.Builder builder) { + return builder.build(); + } + + // @Bean + // public JobDetailFactoryBean jobDetail() { + // JobDetailFactoryBean jobDetailFactory = new JobDetailFactoryBean(); + // jobDetailFactory.setJobClass(SampleJob.class); + // jobDetailFactory.setDurability(true); + // jobDetailFactory.setJobDataAsMap( + // Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_job_detail")); + // return jobDetailFactory; + // } + // + // @Bean + // public SimpleTriggerFactoryBean trigger(JobDetail job) { + // SimpleTriggerFactoryBean trigger = new SimpleTriggerFactoryBean(); + // trigger.setJobDetail(job); + // trigger.setRepeatInterval(2 * 60 * 1000); // every two minutes + // trigger.setRepeatCount(SimpleTrigger.REPEAT_INDEFINITELY); + // trigger.setJobDataAsMap( + // Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_simple_trigger")); + // return trigger; + // } + // + // @Bean + // public CronTriggerFactoryBean cronTrigger(JobDetail job) { + // CronTriggerFactoryBean trigger = new CronTriggerFactoryBean(); + // trigger.setJobDetail(job); + // trigger.setCronExpression("0 0/5 * ? * *"); // every five minutes + // return trigger; + // } + + @Bean + public Tracer getTracer() { + return GlobalOpenTelemetry.getTracer("tracerForSpringBootDemo"); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/Todo.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/Todo.java new file mode 100644 index 0000000000..5fc4164d1b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/Todo.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot.jakarta; + +public class Todo { + private final Long id; + private final String title; + private final boolean completed; + + public Todo(Long id, String title, boolean completed) { + this.id = id; + this.title = title; + this.completed = completed; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public boolean isCompleted() { + return completed; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoController.java new file mode 100644 index 0000000000..8d86ddcb86 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/TodoController.java @@ -0,0 +1,86 @@ +package io.sentry.samples.spring.boot.jakarta; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import io.sentry.ISpan; +import io.sentry.Sentry; +import io.sentry.spring.jakarta.webflux.ReactorUtils; +import org.jetbrains.annotations.NotNull; +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; + +@RestController +public class TodoController { + private final RestTemplate restTemplate; + private final WebClient webClient; + private final RestClient restClient; + private final Tracer tracer; + + public TodoController( + RestTemplate restTemplate, WebClient webClient, RestClient restClient, Tracer tracer) { + this.restTemplate = restTemplate; + this.webClient = webClient; + this.restClient = restClient; + this.tracer = tracer; + } + + @GetMapping("/todo/{id}") + Todo todo(@PathVariable Long id) { + Span otelSpan = tracer.spanBuilder("todoSpanOtelApi").startSpan(); + try (final @NotNull Scope spanScope = otelSpan.makeCurrent()) { + ISpan sentrySpan = Sentry.getSpan().startChild("todoSpanSentryApi"); + try { + return restTemplate.getForObject( + "https://jsonplaceholder.typicode.com/todos/{id}", Todo.class, id); + } finally { + sentrySpan.finish(); + } + } finally { + otelSpan.end(); + } + } + + @GetMapping("/todo-webclient/{id}") + Todo todoWebClient(@PathVariable Long id) { + Hooks.enableAutomaticContextPropagation(); + return ReactorUtils.withSentry( + Mono.just(true) + .publishOn(Schedulers.boundedElastic()) + .flatMap( + x -> + webClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .bodyToMono(Todo.class) + .map(response -> response))) + .block(); + } + + @GetMapping("/todo-restclient/{id}") + Todo todoRestClient(@PathVariable Long id) { + Span span = tracer.spanBuilder("todoRestClientSpanOtelApi").startSpan(); + try (final @NotNull Scope spanScope = span.makeCurrent()) { + ISpan sentrySpan = Sentry.getSpan().startChild("todoRestClientSpanSentryApi"); + try { + return restClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .body(Todo.class); + } finally { + sentrySpan.finish(); + } + } finally { + span.end(); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/AssigneeController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/AssigneeController.java new file mode 100644 index 0000000000..6fdf96506c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/AssigneeController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot.jakarta.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.BatchMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +public class AssigneeController { + + @BatchMapping(typeName = "Task", field = "assignee") + public Mono> assignee( + final @NotNull Set tasks) { + return Mono.fromCallable( + () -> { + final @NotNull Map map = + new HashMap<>(); + for (final @NotNull ProjectController.Task task : tasks) { + if ("Acrash".equalsIgnoreCase(task.assigneeId)) { + throw new RuntimeException("Causing an error while loading assignee"); + } + if (task.assigneeId != null) { + map.put( + task, new ProjectController.Assignee(task.assigneeId, "Name" + task.assigneeId)); + } + } + + return map; + }); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java new file mode 100644 index 0000000000..bfc383c912 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/GreetingController.java @@ -0,0 +1,17 @@ +package io.sentry.samples.spring.boot.jakarta.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +@Controller +public class GreetingController { + + @QueryMapping + public String greeting(final @Argument String name) { + if ("crash".equalsIgnoreCase(name)) { + throw new RuntimeException("causing an error for " + name); + } + return "Hello " + name + "!"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java new file mode 100644 index 0000000000..63790bca62 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/ProjectController.java @@ -0,0 +1,140 @@ +package io.sentry.samples.spring.boot.jakarta.graphql; + +import java.nio.file.NoSuchFileException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.Argument; +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.data.method.annotation.SubscriptionMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Flux; + +@Controller +public class ProjectController { + + @QueryMapping + public Project project(final @Argument String slug) throws Exception { + if ("crash".equalsIgnoreCase(slug) || "projectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project error for " + slug); + } + if ("notfound".equalsIgnoreCase(slug)) { + throw new IllegalStateException("not found"); + } + if ("nofile".equals(slug)) { + throw new NoSuchFileException("no such file"); + } + Project project = new Project(); + project.slug = slug; + return project; + } + + @SchemaMapping(typeName = "Project", field = "status") + public ProjectStatus projectStatus(final Project project) { + if ("crash".equalsIgnoreCase(project.slug) || "statuscrash".equalsIgnoreCase(project.slug)) { + throw new RuntimeException("causing a project status error for " + project.slug); + } + return ProjectStatus.COMMUNITY; + } + + @MutationMapping + public String addProject(@Argument String slug) { + if ("crash".equalsIgnoreCase(slug) || "addprojectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project add error for " + slug); + } + return UUID.randomUUID().toString(); + } + + @QueryMapping + public List tasks(final @Argument String projectSlug) { + List tasks = new ArrayList<>(); + tasks.add(new Task("T1", "Create a new API", "A3", "C3")); + tasks.add(new Task("T2", "Update dependencies", "A1", "C1")); + tasks.add(new Task("T3", "Document API", "A1", "C1")); + tasks.add(new Task("T4", "Merge community PRs", "A2", "C2")); + tasks.add(new Task("T5", "Plan more work", null, null)); + if ("crash".equalsIgnoreCase(projectSlug)) { + tasks.add(new Task("T6", "Fix crash", "Acrash", "Ccrash")); + } + return tasks; + } + + @SubscriptionMapping + public Flux notifyNewTask(@Argument String projectSlug) { + if ("crash".equalsIgnoreCase(projectSlug)) { + throw new RuntimeException("causing error for subscription"); + } + if ("fluxerror".equalsIgnoreCase(projectSlug)) { + return Flux.error(new RuntimeException("causing flux error for subscription")); + } + final String assigneeId = "assigneecrash".equalsIgnoreCase(projectSlug) ? "Acrash" : "A1"; + final String creatorId = "creatorcrash".equalsIgnoreCase(projectSlug) ? "Ccrash" : "C1"; + final @NotNull AtomicInteger counter = new AtomicInteger(1000); + return Flux.interval(Duration.ofSeconds(1)) + .map( + num -> { + int i = counter.incrementAndGet(); + if ("produceerror".equalsIgnoreCase(projectSlug) && i % 2 == 0) { + throw new RuntimeException("causing produce error for subscription"); + } + return new Task("T" + i, "A new task arrived ", assigneeId, creatorId); + }); + } + + public static class Task { + public String id; + public String name; + public String assigneeId; + public String creatorId; + + public Task( + final String id, final String name, final String assigneeId, final String creatorId) { + this.id = id; + this.name = name; + this.assigneeId = assigneeId; + this.creatorId = creatorId; + } + + @Override + public String toString() { + return "Task{id=" + id + "}"; + } + } + + public static class Assignee { + public String id; + public String name; + + public Assignee(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Creator { + public String id; + public String name; + + public Creator(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Project { + public String slug; + } + + public enum ProjectStatus { + ACTIVE, + COMMUNITY, + INCUBATING, + ATTIC, + EOL; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/TaskCreatorController.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/TaskCreatorController.java new file mode 100644 index 0000000000..cb6677c0c3 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/graphql/TaskCreatorController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot.jakarta.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import org.dataloader.BatchLoaderEnvironment; +import org.dataloader.DataLoader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +class TaskCreatorController { + + public TaskCreatorController(final BatchLoaderRegistry batchLoaderRegistry) { + // using mapped BatchLoader to not have to deal with correct ordering of items + batchLoaderRegistry + .forTypePair(String.class, ProjectController.Creator.class) + .registerMappedBatchLoader( + (Set keys, BatchLoaderEnvironment env) -> { + return Mono.fromCallable( + () -> { + final @NotNull Map map = new HashMap<>(); + for (String key : keys) { + if ("Ccrash".equalsIgnoreCase(key)) { + throw new RuntimeException("Causing an error while loading creator"); + } + map.put(key, new ProjectController.Creator(key, "Name" + key)); + } + + return map; + }); + }); + } + + @SchemaMapping(typeName = "Task") + public @Nullable CompletableFuture creator( + final ProjectController.Task task, + final DataLoader dataLoader) { + if (task.creatorId == null) { + return null; + } + return dataLoader.load(task.creatorId); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/quartz/SampleJob.java b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/quartz/SampleJob.java new file mode 100644 index 0000000000..d0f0973c86 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/java/io/sentry/samples/spring/boot/jakarta/quartz/SampleJob.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.jakarta.quartz; + +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.stereotype.Component; + +@Component +public class SampleJob implements Job { + + public void execute(JobExecutionContext context) throws JobExecutionException { + System.out.println("running job"); + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application.properties new file mode 100644 index 0000000000..e00d4c855e --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/application.properties @@ -0,0 +1,33 @@ +# NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in your Sentry project/dashboard +sentry.dsn=https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563 +sentry.send-default-pii=true +sentry.max-request-body-size=medium +# Sentry Spring Boot integration allows more fine-grained SentryOptions configuration +sentry.max-breadcrumbs=150 +# Logback integration configuration options +sentry.logging.minimum-event-level=info +sentry.logging.minimum-breadcrumb-level=debug +# Performance configuration +sentry.traces-sample-rate=1.0 +sentry.ignored-checkins=ignored_monitor_slug_1,ignored_monitor_slug_2 +sentry.debug=true +sentry.graphql.ignored-error-types=SOME_ERROR,ANOTHER_ERROR +sentry.enable-backpressure-handling=true +sentry.enable-spotlight=true +sentry.enablePrettySerializationOutput=false +in-app-includes="io.sentry.samples" + +# Uncomment and set to true to enable aot compatibility +# This flag disables all AOP related features (i.e. @SentryTransaction, @SentrySpan) +# to successfully compile to GraalVM +# sentry.enable-aot-compatibility=false + +# Database configuration +spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb +spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver +spring.datasource.username=sa +spring.datasource.password= +spring.graphql.graphiql.enabled=true +spring.graphql.websocket.path=/graphql +spring.quartz.job-store-type=memory + diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/graphql/schema.graphqls new file mode 100644 index 0000000000..aeea62357b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,68 @@ +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 + """ 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-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/quartz.properties b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/quartz.properties new file mode 100644 index 0000000000..6e302ce765 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/quartz.properties @@ -0,0 +1 @@ +org.quartz.jobStore.class=org.quartz.simpl.RAMJobStore diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/schema.sql b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/resources/schema.sql new file mode 100644 index 0000000000..7ca8a5cbf4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/main/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-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/greeting.graphql b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/greeting.graphql new file mode 100644 index 0000000000..06c866a65f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/greeting.graphql @@ -0,0 +1,3 @@ +query GreetingQuery($name: String!) { + greeting(name: $name) +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/project.graphql b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/project.graphql new file mode 100644 index 0000000000..bff62ed2c2 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/project.graphql @@ -0,0 +1,11 @@ +query ProjectQuery($slug: ID!) { + project(slug: $slug) { + slug + name + status + } +} + +mutation AddProjectMutation($slug: ID!) { + addProject(slug: $slug) +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/schema.graphqls new file mode 100644 index 0000000000..d76aca4756 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/schema.graphqls @@ -0,0 +1,70 @@ +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-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/task.graphql b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/task.graphql new file mode 100644 index 0000000000..11ae18574d --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/graphql/task.graphql @@ -0,0 +1,16 @@ +query TasksAndAssigneesQuery($slug: ID!) { + tasks(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + creatorId + creator { + id + name + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/DummyTest.kt new file mode 100644 index 0000000000..a8f7fe6905 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/DummyTest.kt @@ -0,0 +1,13 @@ +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-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt new file mode 100644 index 0000000000..b60f2b113a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -0,0 +1,39 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import kotlin.test.Test + +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 -> + testHelper.doesTransactionContainSpanWithOp(transaction, "query GreetingQuery") + } + } + + @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 -> + testHelper.doesTransactionContainSpanWithOp(transaction, "query GreetingQuery") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt new file mode 100644 index 0000000000..8946284be4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -0,0 +1,55 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +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 -> + testHelper.doesTransactionContainSpanWithOp(transaction, "query ProjectQuery") + } + } + + @Test + fun `project mutation works`() { + val response = testHelper.graphqlClient.addProject("proj-slug") + + testHelper.ensureNoErrors(response) + assertNotNull(response?.data?.addProject) + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "mutation AddProjectMutation") + } + } + + @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 -> + testHelper.doesTransactionContainSpanWithOp(transaction, "mutation AddProjectMutation") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt new file mode 100644 index 0000000000..2fba967c82 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -0,0 +1,37 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals + +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 -> + testHelper.doesTransactionContainSpanWithOp(transaction, "query TasksAndAssigneesQuery") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt new file mode 100644 index 0000000000..8d83bde630 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -0,0 +1,47 @@ +package io.sentry.systemtest + +import io.sentry.samples.spring.boot.jakarta.Person +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import org.springframework.http.HttpStatus +import kotlin.test.Test +import kotlin.test.assertEquals + +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(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") + } + } + + @Test + fun `create person works`() { + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPerson(person) + assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt new file mode 100644 index 0000000000..4c8ee45ea6 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -0,0 +1,55 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import org.junit.Before +import org.springframework.http.HttpStatus +import kotlin.test.Test +import kotlin.test.assertEquals + +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(HttpStatus.OK, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanSentryApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") + } + } + + @Test + fun `get todo webclient works`() { + val restClient = testHelper.restClient + restClient.getTodoWebclient(1L) + assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") + } + } + + @Test + fun `get todo restclient works`() { + val restClient = testHelper.restClient + restClient.getTodoRestClient(1L) + assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanSentryApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt new file mode 100644 index 0000000000..0c11906292 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/graphql/GraphqlTestClient.kt @@ -0,0 +1,43 @@ +package io.sentry.systemtest.graphql + +import com.apollographql.apollo3.ApolloClient +import com.apollographql.apollo3.api.ApolloResponse +import com.apollographql.apollo3.api.Mutation +import com.apollographql.apollo3.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? { + return executeQuery(GreetingQuery(name)) + } + + fun project(slug: String): ApolloResponse? { + return executeQuery(ProjectQuery(slug)) + } + + fun tasksAndAssignees(slug: String): ApolloResponse? { + return executeQuery(TasksAndAssigneesQuery(slug)) + } + + fun addProject(slug: String): ApolloResponse? { + return 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-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt new file mode 100644 index 0000000000..17eea1a008 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/LoggingInsecureRestClient.kt @@ -0,0 +1,13 @@ +package io.sentry.systemtest.util + +import org.springframework.http.client.BufferingClientHttpRequestFactory +import org.springframework.web.client.RestTemplate + +open class LoggingInsecureRestClient { + + protected fun restTemplate(): RestTemplate { + return RestTemplate().also { + it.requestFactory = BufferingClientHttpRequestFactory(it.requestFactory) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt new file mode 100644 index 0000000000..1b1d16c841 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt @@ -0,0 +1,76 @@ +package io.sentry.systemtest.util + +import io.sentry.samples.spring.boot.jakarta.Person +import io.sentry.samples.spring.boot.jakarta.Todo +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatusCode +import org.springframework.web.client.HttpStatusCodeException + +class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestClient() { + var lastKnownStatusCode: HttpStatusCode? = null + + fun getPerson(id: Long): Person? { + return try { + val response = restTemplate().exchange("$backendBaseUrl/person/{id}", HttpMethod.GET, entityWithAuth(), Person::class.java, mapOf("id" to id)) + lastKnownStatusCode = response.statusCode + response.body + } catch (e: HttpStatusCodeException) { + lastKnownStatusCode = e.statusCode + null + } + } + + fun createPerson(person: Person): Person? { + return try { + val response = restTemplate().exchange("$backendBaseUrl/person/", HttpMethod.POST, entityWithAuth(person), Person::class.java, person) + lastKnownStatusCode = response.statusCode + response.body + } catch (e: HttpStatusCodeException) { + lastKnownStatusCode = e.statusCode + null + } + } + + fun getTodo(id: Long): Todo? { + return try { + val response = restTemplate().exchange("$backendBaseUrl/todo/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) + lastKnownStatusCode = response.statusCode + response.body + } catch (e: HttpStatusCodeException) { + lastKnownStatusCode = e.statusCode + null + } + } + + fun getTodoWebclient(id: Long): Todo? { + return try { + val response = restTemplate().exchange("$backendBaseUrl/todo-webclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) + lastKnownStatusCode = response.statusCode + response.body + } catch (e: HttpStatusCodeException) { + lastKnownStatusCode = e.statusCode + null + } + } + + fun getTodoRestClient(id: Long): Todo? { + return try { + val response = restTemplate().exchange("$backendBaseUrl/todo-restclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) + lastKnownStatusCode = response.statusCode + response.body + } catch (e: HttpStatusCodeException) { + lastKnownStatusCode = e.statusCode + null + } + } + + private fun entityWithAuth(request: Any? = null): HttpEntity { + val headers = HttpHeaders().also { + it.setBasicAuth("user", "password") + } + + return HttpEntity(request, headers) + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt new file mode 100644 index 0000000000..7ef1699f12 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt @@ -0,0 +1,43 @@ +package io.sentry.systemtest.util + +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod + +class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestClient() { + + fun getEnvelopeCount(): EnvelopeCounts { + val response = restTemplate().exchange("$baseUrl/envelope-count", HttpMethod.GET, entityWithAuth(), EnvelopeCounts::class.java) + return response.body!! + } + + fun reset() { + restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) + } + + fun getEnvelopes(): EnvelopesReceived { + val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) + return response.body!! + } + + private fun entityWithAuth(request: Any? = null): HttpEntity { + val headers = HttpHeaders() + return HttpEntity(request, headers) + } +} + +class EnvelopeCounts { + val envelopes: Long? = null + + override fun toString(): String { + return "EnvelopeCounts{envelopes=$envelopes}" + } +} + +class EnvelopesReceived { + val envelopes: List? = null + + override fun toString(): String { + return "EnvelopesReceived{envelopes=$envelopes}" + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt new file mode 100644 index 0000000000..c175346d01 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -0,0 +1,151 @@ +package io.sentry.systemtest.util + +import com.apollographql.apollo3.api.ApolloResponse +import com.apollographql.apollo3.api.Operation +import io.sentry.JsonSerializer +import io.sentry.SentryEvent +import io.sentry.SentryItemType +import io.sentry.SentryOptions +import io.sentry.protocol.SentrySpan +import io.sentry.protocol.SentryTransaction +import io.sentry.systemtest.graphql.GraphqlTestClient +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 + + 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(callback: ((String) -> Boolean)) { + Thread.sleep(10000) + val envelopes = sentryClient.getEnvelopes() + assertNotNull(envelopes.envelopes) + envelopes.envelopes.forEach { envelopeString -> + val didMatch = callback(envelopeString) + if (didMatch) { + return + } + } + throw RuntimeException("Unable to find matching envelope received by relay") + } + + fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { + ensureEnvelopeReceived { envelopeString -> + val deserializeEnvelope = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + if (deserializeEnvelope == null) { + return@ensureEnvelopeReceived false + } + + val transactionItem = + deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } + if (transactionItem == null) { + return@ensureEnvelopeReceived false + } + + val transaction = transactionItem.getTransaction(jsonSerializer) + if (transaction == null) { + return@ensureEnvelopeReceived false + } + + callback(transaction) + } + } + + fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { + ensureEnvelopeReceived { envelopeString -> + val deserializeEnvelope = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + if (deserializeEnvelope == null) { + return@ensureEnvelopeReceived false + } + + val errorItem = + deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } + if (errorItem == null) { + return@ensureEnvelopeReceived false + } + + val error = errorItem.getEvent(jsonSerializer) + if (error == null) { + return@ensureEnvelopeReceived false + } + + val 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 -> + transaction.spans.forEach { span -> + val callbackResult = callback(span) + if (callbackResult) { + return@ensureTransactionReceived true + } + } + false + } + } + + fun reset() { + sentryClient.reset() + } + + fun logObject(obj: Any?) { + obj ?: return + PrintWriter(System.out).use { + jsonSerializer.serialize(obj, it) + } + } + + 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 { + val span = transaction.spans.firstOrNull { span -> span.op == op } + if (span == null) { + println("Unable to find span with op $op in transaction:") + logObject(transaction) + return false + } + + return true + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/resources/logback.xml new file mode 100644 index 0000000000..a36b8f80f7 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + + + + %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n + + + + + + + diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index 34a26d1f04..b7f7357ce7 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -64,6 +64,7 @@ dependencies { testImplementation("ch.qos.logback:logback-classic:1.3.5") testImplementation(Config.Libs.slf4jApi2) testImplementation(Config.Libs.apolloKotlin) + testImplementation(projects.sentry) } configure { @@ -76,7 +77,7 @@ tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" -// maxParallelForks = Runtime.getRuntime().availableProcessors() / 2 + maxParallelForks = 1 // Cap JVM args per test minHeapSize = "128m" diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt index a92e73d931..769ae399bf 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -11,25 +11,29 @@ class GraphqlGreetingSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `greeting works`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.greet("world") testHelper.ensureNoErrors(response) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") + } } @Test fun `greeting error`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.greet("crash") testHelper.ensureErrorCount(response, 1) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureErrorReceived { error -> + error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false + } + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt index e78ad04489..74b196e33b 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -14,38 +14,42 @@ class GraphqlProjectSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `project query works`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.project("proj-slug") testHelper.ensureNoErrors(response) assertEquals("proj-slug", response?.data?.project?.slug) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.project") + } } @Test fun `project mutation works`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.addProject("proj-slug") testHelper.ensureNoErrors(response) assertNotNull(response?.data?.addProject) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Mutation.addProject") + } } @Test fun `project mutation error`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.addProject("addprojectcrash") testHelper.ensureErrorCount(response, 1) assertNull(response?.data?.addProject) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureErrorReceived { error -> + error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false + } + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Mutation.addProject") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt index 630621c16a..cf2aebfd09 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -12,12 +12,11 @@ class GraphqlTaskSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `tasks and assignees query works`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.tasksAndAssignees("project-slug") testHelper.ensureNoErrors(response) @@ -31,6 +30,10 @@ class GraphqlTaskSystemTest { assertEquals("C3", firstTask.creatorId) assertEquals("C3", firstTask.creator?.id) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.tasks") && + testHelper.doesTransactionContainSpanWithDescription(transaction, "Task.assignee") && + testHelper.doesTransactionContainSpanWithDescription(transaction, "Task.creator") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 2bace56899..e4a388ef52 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -14,17 +14,18 @@ class PersonSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `get person fails`() { - testHelper.snapshotEnvelopeCount() - val restClient = testHelper.restClient restClient.getPerson(1L) assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionHaveOp(transaction, "http.server") + } } @Test @@ -36,5 +37,10 @@ class PersonSystemTest { assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && + testHelper.doesTransactionContainSpanWithOp(transaction, "db.query") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index 3e2b462856..9893bcb396 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -13,27 +13,39 @@ class TodoSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `get todo works`() { - testHelper.snapshotEnvelopeCount() - val restClient = testHelper.restClient restClient.getTodo(1L) assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") + } } @Test fun `get todo webclient works`() { - testHelper.snapshotEnvelopeCount() - val restClient = testHelper.restClient restClient.getTodoWebclient(1L) assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") + } + } + + @Test + fun `get todo restclient works`() { + val restClient = testHelper.restClient + restClient.getTodoRestClient(1L) + assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt index 9f77b96294..1b1d16c841 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/RestTestClient.kt @@ -55,6 +55,17 @@ class RestTestClient(private val backendBaseUrl: String) : LoggingInsecureRestCl } } + fun getTodoRestClient(id: Long): Todo? { + return try { + val response = restTemplate().exchange("$backendBaseUrl/todo-restclient/{id}", HttpMethod.GET, entityWithAuth(), Todo::class.java, mapOf("id" to id)) + lastKnownStatusCode = response.statusCode + response.body + } catch (e: HttpStatusCodeException) { + lastKnownStatusCode = e.statusCode + null + } + } + private fun entityWithAuth(request: Any? = null): HttpEntity { val headers = HttpHeaders().also { it.setBasicAuth("user", "password") diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt index b545260e48..7ef1699f12 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt @@ -11,6 +11,15 @@ class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestC return response.body!! } + fun reset() { + restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) + } + + fun getEnvelopes(): EnvelopesReceived { + val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) + return response.body!! + } + private fun entityWithAuth(request: Any? = null): HttpEntity { val headers = HttpHeaders() return HttpEntity(request, headers) @@ -24,3 +33,11 @@ class EnvelopeCounts { return "EnvelopeCounts{envelopes=$envelopes}" } } + +class EnvelopesReceived { + val envelopes: List? = null + + override fun toString(): String { + return "EnvelopesReceived{envelopes=$envelopes}" + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt index 948b1c55e8..1017d84734 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -2,9 +2,17 @@ package io.sentry.systemtest.util import com.apollographql.apollo3.api.ApolloResponse import com.apollographql.apollo3.api.Operation +import io.sentry.JsonSerializer +import io.sentry.SentryEvent +import io.sentry.SentryItemType +import io.sentry.SentryOptions +import io.sentry.protocol.SentrySpan +import io.sentry.protocol.SentryTransaction import io.sentry.systemtest.graphql.GraphqlTestClient +import java.io.PrintWriter import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue class TestHelper(backendUrl: String) { @@ -12,6 +20,7 @@ class TestHelper(backendUrl: String) { val restClient: RestTestClient val graphqlClient: GraphqlTestClient val sentryClient: SentryMockServerClient + val jsonSerializer: JsonSerializer var envelopeCounts: EnvelopeCounts? = null @@ -19,6 +28,7 @@ class TestHelper(backendUrl: String) { restClient = RestTestClient(backendUrl) sentryClient = SentryMockServerClient("http://localhost:8000") graphqlClient = GraphqlTestClient(backendUrl) + jsonSerializer = JsonSerializer(SentryOptions.empty()) } fun snapshotEnvelopeCount() { @@ -31,6 +41,93 @@ class TestHelper(backendUrl: String) { assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) } + fun ensureEnvelopeReceived(callback: ((String) -> Boolean)) { + Thread.sleep(10000) + val envelopes = sentryClient.getEnvelopes() + assertNotNull(envelopes.envelopes) + envelopes.envelopes.forEach { envelopeString -> + val didMatch = callback(envelopeString) + if (didMatch) { + return + } + } + throw RuntimeException("Unable to find matching envelope received by relay") + } + + fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { + ensureEnvelopeReceived { envelopeString -> + val deserializeEnvelope = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + if (deserializeEnvelope == null) { + return@ensureEnvelopeReceived false + } + + val transactionItem = + deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } + if (transactionItem == null) { + return@ensureEnvelopeReceived false + } + + val transaction = transactionItem.getTransaction(jsonSerializer) + if (transaction == null) { + return@ensureEnvelopeReceived false + } + + callback(transaction) + } + } + + fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { + ensureEnvelopeReceived { envelopeString -> + val deserializeEnvelope = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + if (deserializeEnvelope == null) { + return@ensureEnvelopeReceived false + } + + val errorItem = + deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } + if (errorItem == null) { + return@ensureEnvelopeReceived false + } + + val error = errorItem.getEvent(jsonSerializer) + if (error == null) { + return@ensureEnvelopeReceived false + } + + val 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 -> + transaction.spans.forEach { span -> + val callbackResult = callback(span) + if (callbackResult) { + return@ensureTransactionReceived true + } + } + false + } + } + + fun reset() { + sentryClient.reset() + } + + fun logObject(obj: Any?) { + obj ?: return + PrintWriter(System.out).use { + jsonSerializer.serialize(obj, it) + } + } + fun ensureNoErrors(response: ApolloResponse?) { response ?: throw RuntimeException("no response") assertFalse(response.hasErrors()) @@ -40,4 +137,37 @@ class TestHelper(backendUrl: String) { response ?: throw RuntimeException("no response") assertEquals(errorCount, response.errors?.size) } + + fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { + val span = transaction.spans.firstOrNull { span -> span.op == op } + if (span == null) { + println("Unable to find span with op $op in transaction:") + logObject(transaction) + return false + } + + return true + } + + fun doesTransactionContainSpanWithDescription(transaction: SentryTransaction, description: String): Boolean { + val span = transaction.spans.firstOrNull { span -> span.description == description } + if (span == null) { + println("Unable to find span with description $description in transaction:") + logObject(transaction) + return false + } + + return true + } + + fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { + val matches = transaction.contexts.trace?.operation == op + if (!matches) { + println("Unable to find transaction with op $op:") + logObject(transaction) + return false + } + + return true + } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index 473366130d..a32e5ef58d 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -27,7 +27,8 @@ dependencies { implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(projects.sentrySpringBootStarterJakarta) implementation(projects.sentryLogback) - implementation(projects.sentryGraphql) + implementation(projects.sentryJdbc) + implementation(projects.sentryGraphql22) testImplementation(Config.Libs.springBoot3StarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") @@ -56,7 +57,7 @@ tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" -// maxParallelForks = Runtime.getRuntime().availableProcessors() / 2 + maxParallelForks = 1 // Cap JVM args per test minHeapSize = "128m" diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt index a92e73d931..769ae399bf 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -11,25 +11,29 @@ class GraphqlGreetingSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `greeting works`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.greet("world") testHelper.ensureNoErrors(response) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") + } } @Test fun `greeting error`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.greet("crash") testHelper.ensureErrorCount(response, 1) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureErrorReceived { error -> + error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false + } + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index 2bace56899..4708d5609d 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -14,17 +14,18 @@ class PersonSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `get person fails`() { - testHelper.snapshotEnvelopeCount() - val restClient = testHelper.restClient restClient.getPerson(1L) assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionHaveOp(transaction, "http.server") + } } @Test @@ -36,5 +37,9 @@ class PersonSystemTest { assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionHaveOp(transaction, "http.server") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index 5692a924cc..fe5c8252ed 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -13,16 +13,17 @@ class TodoSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `get todo webclient works`() { - testHelper.snapshotEnvelopeCount() - val restClient = testHelper.restClient restClient.getTodoWebclient(1L) assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt index b545260e48..7ef1699f12 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt @@ -11,6 +11,15 @@ class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestC return response.body!! } + fun reset() { + restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) + } + + fun getEnvelopes(): EnvelopesReceived { + val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) + return response.body!! + } + private fun entityWithAuth(request: Any? = null): HttpEntity { val headers = HttpHeaders() return HttpEntity(request, headers) @@ -24,3 +33,11 @@ class EnvelopeCounts { return "EnvelopeCounts{envelopes=$envelopes}" } } + +class EnvelopesReceived { + val envelopes: List? = null + + override fun toString(): String { + return "EnvelopesReceived{envelopes=$envelopes}" + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt index 948b1c55e8..1017d84734 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -2,9 +2,17 @@ package io.sentry.systemtest.util import com.apollographql.apollo3.api.ApolloResponse import com.apollographql.apollo3.api.Operation +import io.sentry.JsonSerializer +import io.sentry.SentryEvent +import io.sentry.SentryItemType +import io.sentry.SentryOptions +import io.sentry.protocol.SentrySpan +import io.sentry.protocol.SentryTransaction import io.sentry.systemtest.graphql.GraphqlTestClient +import java.io.PrintWriter import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue class TestHelper(backendUrl: String) { @@ -12,6 +20,7 @@ class TestHelper(backendUrl: String) { val restClient: RestTestClient val graphqlClient: GraphqlTestClient val sentryClient: SentryMockServerClient + val jsonSerializer: JsonSerializer var envelopeCounts: EnvelopeCounts? = null @@ -19,6 +28,7 @@ class TestHelper(backendUrl: String) { restClient = RestTestClient(backendUrl) sentryClient = SentryMockServerClient("http://localhost:8000") graphqlClient = GraphqlTestClient(backendUrl) + jsonSerializer = JsonSerializer(SentryOptions.empty()) } fun snapshotEnvelopeCount() { @@ -31,6 +41,93 @@ class TestHelper(backendUrl: String) { assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) } + fun ensureEnvelopeReceived(callback: ((String) -> Boolean)) { + Thread.sleep(10000) + val envelopes = sentryClient.getEnvelopes() + assertNotNull(envelopes.envelopes) + envelopes.envelopes.forEach { envelopeString -> + val didMatch = callback(envelopeString) + if (didMatch) { + return + } + } + throw RuntimeException("Unable to find matching envelope received by relay") + } + + fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { + ensureEnvelopeReceived { envelopeString -> + val deserializeEnvelope = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + if (deserializeEnvelope == null) { + return@ensureEnvelopeReceived false + } + + val transactionItem = + deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } + if (transactionItem == null) { + return@ensureEnvelopeReceived false + } + + val transaction = transactionItem.getTransaction(jsonSerializer) + if (transaction == null) { + return@ensureEnvelopeReceived false + } + + callback(transaction) + } + } + + fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { + ensureEnvelopeReceived { envelopeString -> + val deserializeEnvelope = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + if (deserializeEnvelope == null) { + return@ensureEnvelopeReceived false + } + + val errorItem = + deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } + if (errorItem == null) { + return@ensureEnvelopeReceived false + } + + val error = errorItem.getEvent(jsonSerializer) + if (error == null) { + return@ensureEnvelopeReceived false + } + + val 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 -> + transaction.spans.forEach { span -> + val callbackResult = callback(span) + if (callbackResult) { + return@ensureTransactionReceived true + } + } + false + } + } + + fun reset() { + sentryClient.reset() + } + + fun logObject(obj: Any?) { + obj ?: return + PrintWriter(System.out).use { + jsonSerializer.serialize(obj, it) + } + } + fun ensureNoErrors(response: ApolloResponse?) { response ?: throw RuntimeException("no response") assertFalse(response.hasErrors()) @@ -40,4 +137,37 @@ class TestHelper(backendUrl: String) { response ?: throw RuntimeException("no response") assertEquals(errorCount, response.errors?.size) } + + fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { + val span = transaction.spans.firstOrNull { span -> span.op == op } + if (span == null) { + println("Unable to find span with op $op in transaction:") + logObject(transaction) + return false + } + + return true + } + + fun doesTransactionContainSpanWithDescription(transaction: SentryTransaction, description: String): Boolean { + val span = transaction.spans.firstOrNull { span -> span.description == description } + if (span == null) { + println("Unable to find span with description $description in transaction:") + logObject(transaction) + return false + } + + return true + } + + fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { + val matches = transaction.contexts.trace?.operation == op + if (!matches) { + println("Unable to find transaction with op $op:") + logObject(transaction) + return false + } + + return true + } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index 9e98823483..04a03250a9 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -55,7 +55,7 @@ tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" -// maxParallelForks = Runtime.getRuntime().availableProcessors() / 2 + maxParallelForks = 1 // Cap JVM args per test minHeapSize = "128m" diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt index a92e73d931..769ae399bf 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -11,25 +11,29 @@ class GraphqlGreetingSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `greeting works`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.greet("world") testHelper.ensureNoErrors(response) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") + } } @Test fun `greeting error`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.greet("crash") testHelper.ensureErrorCount(response, 1) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureErrorReceived { error -> + error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false + } + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index dfa3a599fd..e047431ee5 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -14,17 +14,18 @@ class PersonSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `get person fails`() { - testHelper.snapshotEnvelopeCount() - val restClient = testHelper.restClient restClient.getPerson(1L) assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionHaveOp(transaction, "http.server") + } } @Test @@ -36,5 +37,9 @@ class PersonSystemTest { assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionHaveOp(transaction, "http.server") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index 5692a924cc..fe5c8252ed 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -13,16 +13,17 @@ class TodoSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `get todo webclient works`() { - testHelper.snapshotEnvelopeCount() - val restClient = testHelper.restClient restClient.getTodoWebclient(1L) assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt index b545260e48..7ef1699f12 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt @@ -11,6 +11,15 @@ class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestC return response.body!! } + fun reset() { + restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) + } + + fun getEnvelopes(): EnvelopesReceived { + val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) + return response.body!! + } + private fun entityWithAuth(request: Any? = null): HttpEntity { val headers = HttpHeaders() return HttpEntity(request, headers) @@ -24,3 +33,11 @@ class EnvelopeCounts { return "EnvelopeCounts{envelopes=$envelopes}" } } + +class EnvelopesReceived { + val envelopes: List? = null + + override fun toString(): String { + return "EnvelopesReceived{envelopes=$envelopes}" + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt index 948b1c55e8..1017d84734 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-samples/sentry-samples-spring-boot-webflux/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -2,9 +2,17 @@ package io.sentry.systemtest.util import com.apollographql.apollo3.api.ApolloResponse import com.apollographql.apollo3.api.Operation +import io.sentry.JsonSerializer +import io.sentry.SentryEvent +import io.sentry.SentryItemType +import io.sentry.SentryOptions +import io.sentry.protocol.SentrySpan +import io.sentry.protocol.SentryTransaction import io.sentry.systemtest.graphql.GraphqlTestClient +import java.io.PrintWriter import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue class TestHelper(backendUrl: String) { @@ -12,6 +20,7 @@ class TestHelper(backendUrl: String) { val restClient: RestTestClient val graphqlClient: GraphqlTestClient val sentryClient: SentryMockServerClient + val jsonSerializer: JsonSerializer var envelopeCounts: EnvelopeCounts? = null @@ -19,6 +28,7 @@ class TestHelper(backendUrl: String) { restClient = RestTestClient(backendUrl) sentryClient = SentryMockServerClient("http://localhost:8000") graphqlClient = GraphqlTestClient(backendUrl) + jsonSerializer = JsonSerializer(SentryOptions.empty()) } fun snapshotEnvelopeCount() { @@ -31,6 +41,93 @@ class TestHelper(backendUrl: String) { assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) } + fun ensureEnvelopeReceived(callback: ((String) -> Boolean)) { + Thread.sleep(10000) + val envelopes = sentryClient.getEnvelopes() + assertNotNull(envelopes.envelopes) + envelopes.envelopes.forEach { envelopeString -> + val didMatch = callback(envelopeString) + if (didMatch) { + return + } + } + throw RuntimeException("Unable to find matching envelope received by relay") + } + + fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { + ensureEnvelopeReceived { envelopeString -> + val deserializeEnvelope = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + if (deserializeEnvelope == null) { + return@ensureEnvelopeReceived false + } + + val transactionItem = + deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } + if (transactionItem == null) { + return@ensureEnvelopeReceived false + } + + val transaction = transactionItem.getTransaction(jsonSerializer) + if (transaction == null) { + return@ensureEnvelopeReceived false + } + + callback(transaction) + } + } + + fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { + ensureEnvelopeReceived { envelopeString -> + val deserializeEnvelope = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + if (deserializeEnvelope == null) { + return@ensureEnvelopeReceived false + } + + val errorItem = + deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } + if (errorItem == null) { + return@ensureEnvelopeReceived false + } + + val error = errorItem.getEvent(jsonSerializer) + if (error == null) { + return@ensureEnvelopeReceived false + } + + val 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 -> + transaction.spans.forEach { span -> + val callbackResult = callback(span) + if (callbackResult) { + return@ensureTransactionReceived true + } + } + false + } + } + + fun reset() { + sentryClient.reset() + } + + fun logObject(obj: Any?) { + obj ?: return + PrintWriter(System.out).use { + jsonSerializer.serialize(obj, it) + } + } + fun ensureNoErrors(response: ApolloResponse?) { response ?: throw RuntimeException("no response") assertFalse(response.hasErrors()) @@ -40,4 +137,37 @@ class TestHelper(backendUrl: String) { response ?: throw RuntimeException("no response") assertEquals(errorCount, response.errors?.size) } + + fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { + val span = transaction.spans.firstOrNull { span -> span.op == op } + if (span == null) { + println("Unable to find span with op $op in transaction:") + logObject(transaction) + return false + } + + return true + } + + fun doesTransactionContainSpanWithDescription(transaction: SentryTransaction, description: String): Boolean { + val span = transaction.spans.firstOrNull { span -> span.description == description } + if (span == null) { + println("Unable to find span with description $description in transaction:") + logObject(transaction) + return false + } + + return true + } + + fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { + val matches = transaction.contexts.trace?.operation == op + if (!matches) { + println("Unable to find transaction with op $op:") + logObject(transaction) + return false + } + + return true + } } diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 1814bc3694..b3fb7c1ba1 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -77,7 +77,7 @@ tasks.register("systemTest").configure { group = "verification" description = "Runs the System tests" -// maxParallelForks = Runtime.getRuntime().availableProcessors() / 2 + maxParallelForks = 1 // Cap JVM args per test minHeapSize = "128m" diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt index a92e73d931..769ae399bf 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -11,25 +11,29 @@ class GraphqlGreetingSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `greeting works`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.greet("world") testHelper.ensureNoErrors(response) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") + } } @Test fun `greeting error`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.greet("crash") testHelper.ensureErrorCount(response, 1) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureErrorReceived { error -> + error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false + } + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.greeting") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt index e78ad04489..74b196e33b 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -14,38 +14,42 @@ class GraphqlProjectSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `project query works`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.project("proj-slug") testHelper.ensureNoErrors(response) assertEquals("proj-slug", response?.data?.project?.slug) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.project") + } } @Test fun `project mutation works`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.addProject("proj-slug") testHelper.ensureNoErrors(response) assertNotNull(response?.data?.addProject) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Mutation.addProject") + } } @Test fun `project mutation error`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.addProject("addprojectcrash") testHelper.ensureErrorCount(response, 1) assertNull(response?.data?.addProject) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureErrorReceived { error -> + error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false + } + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Mutation.addProject") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt index 630621c16a..cf2aebfd09 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -12,12 +12,11 @@ class GraphqlTaskSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `tasks and assignees query works`() { - testHelper.snapshotEnvelopeCount() - val response = testHelper.graphqlClient.tasksAndAssignees("project-slug") testHelper.ensureNoErrors(response) @@ -31,6 +30,10 @@ class GraphqlTaskSystemTest { assertEquals("C3", firstTask.creatorId) assertEquals("C3", firstTask.creator?.id) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithDescription(transaction, "Query.tasks") && + testHelper.doesTransactionContainSpanWithDescription(transaction, "Task.assignee") && + testHelper.doesTransactionContainSpanWithDescription(transaction, "Task.creator") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt index dfa3a599fd..96b0860856 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -14,17 +14,18 @@ class PersonSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `get person fails`() { - testHelper.snapshotEnvelopeCount() - val restClient = testHelper.restClient restClient.getPerson(1L) assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, restClient.lastKnownStatusCode) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionHaveOp(transaction, "http.server") + } } @Test @@ -36,5 +37,10 @@ class PersonSystemTest { assertEquals(person.firstName, returnedPerson!!.firstName) assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && + testHelper.doesTransactionContainSpanWithOp(transaction, "db.query") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt index 3e2b462856..7a756d98da 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -13,27 +13,28 @@ class TodoSystemTest { @Before fun setup() { testHelper = TestHelper("http://localhost:8080") + testHelper.reset() } @Test fun `get todo works`() { - testHelper.snapshotEnvelopeCount() - val restClient = testHelper.restClient restClient.getTodo(1L) assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") + } } @Test fun `get todo webclient works`() { - testHelper.snapshotEnvelopeCount() - val restClient = testHelper.restClient restClient.getTodoWebclient(1L) assertEquals(HttpStatus.OK, restClient.lastKnownStatusCode) - testHelper.ensureEnvelopeCountIncreased() + testHelper.ensureTransactionReceived { transaction -> + testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") + } } } diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt index b545260e48..7ef1699f12 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/SentryMockServerClient.kt @@ -11,6 +11,15 @@ class SentryMockServerClient(private val baseUrl: String) : LoggingInsecureRestC return response.body!! } + fun reset() { + restTemplate().exchange("$baseUrl/reset", HttpMethod.GET, entityWithAuth(), Any::class.java) + } + + fun getEnvelopes(): EnvelopesReceived { + val response = restTemplate().exchange("$baseUrl/envelopes-received", HttpMethod.GET, entityWithAuth(), EnvelopesReceived::class.java) + return response.body!! + } + private fun entityWithAuth(request: Any? = null): HttpEntity { val headers = HttpHeaders() return HttpEntity(request, headers) @@ -24,3 +33,11 @@ class EnvelopeCounts { return "EnvelopeCounts{envelopes=$envelopes}" } } + +class EnvelopesReceived { + val envelopes: List? = null + + override fun toString(): String { + return "EnvelopesReceived{envelopes=$envelopes}" + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt index 948b1c55e8..1017d84734 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt +++ b/sentry-samples/sentry-samples-spring-boot/src/test/kotlin/io/sentry/systemtest/util/TestHelper.kt @@ -2,9 +2,17 @@ package io.sentry.systemtest.util import com.apollographql.apollo3.api.ApolloResponse import com.apollographql.apollo3.api.Operation +import io.sentry.JsonSerializer +import io.sentry.SentryEvent +import io.sentry.SentryItemType +import io.sentry.SentryOptions +import io.sentry.protocol.SentrySpan +import io.sentry.protocol.SentryTransaction import io.sentry.systemtest.graphql.GraphqlTestClient +import java.io.PrintWriter import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue class TestHelper(backendUrl: String) { @@ -12,6 +20,7 @@ class TestHelper(backendUrl: String) { val restClient: RestTestClient val graphqlClient: GraphqlTestClient val sentryClient: SentryMockServerClient + val jsonSerializer: JsonSerializer var envelopeCounts: EnvelopeCounts? = null @@ -19,6 +28,7 @@ class TestHelper(backendUrl: String) { restClient = RestTestClient(backendUrl) sentryClient = SentryMockServerClient("http://localhost:8000") graphqlClient = GraphqlTestClient(backendUrl) + jsonSerializer = JsonSerializer(SentryOptions.empty()) } fun snapshotEnvelopeCount() { @@ -31,6 +41,93 @@ class TestHelper(backendUrl: String) { assertTrue(envelopeCountsAfter!!.envelopes!! > envelopeCounts!!.envelopes!!) } + fun ensureEnvelopeReceived(callback: ((String) -> Boolean)) { + Thread.sleep(10000) + val envelopes = sentryClient.getEnvelopes() + assertNotNull(envelopes.envelopes) + envelopes.envelopes.forEach { envelopeString -> + val didMatch = callback(envelopeString) + if (didMatch) { + return + } + } + throw RuntimeException("Unable to find matching envelope received by relay") + } + + fun ensureTransactionReceived(callback: ((SentryTransaction) -> Boolean)) { + ensureEnvelopeReceived { envelopeString -> + val deserializeEnvelope = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + if (deserializeEnvelope == null) { + return@ensureEnvelopeReceived false + } + + val transactionItem = + deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Transaction } + if (transactionItem == null) { + return@ensureEnvelopeReceived false + } + + val transaction = transactionItem.getTransaction(jsonSerializer) + if (transaction == null) { + return@ensureEnvelopeReceived false + } + + callback(transaction) + } + } + + fun ensureErrorReceived(callback: ((SentryEvent) -> Boolean)) { + ensureEnvelopeReceived { envelopeString -> + val deserializeEnvelope = + jsonSerializer.deserializeEnvelope(envelopeString.byteInputStream()) + if (deserializeEnvelope == null) { + return@ensureEnvelopeReceived false + } + + val errorItem = + deserializeEnvelope.items.firstOrNull { it.header.type == SentryItemType.Event } + if (errorItem == null) { + return@ensureEnvelopeReceived false + } + + val error = errorItem.getEvent(jsonSerializer) + if (error == null) { + return@ensureEnvelopeReceived false + } + + val 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 -> + transaction.spans.forEach { span -> + val callbackResult = callback(span) + if (callbackResult) { + return@ensureTransactionReceived true + } + } + false + } + } + + fun reset() { + sentryClient.reset() + } + + fun logObject(obj: Any?) { + obj ?: return + PrintWriter(System.out).use { + jsonSerializer.serialize(obj, it) + } + } + fun ensureNoErrors(response: ApolloResponse?) { response ?: throw RuntimeException("no response") assertFalse(response.hasErrors()) @@ -40,4 +137,37 @@ class TestHelper(backendUrl: String) { response ?: throw RuntimeException("no response") assertEquals(errorCount, response.errors?.size) } + + fun doesTransactionContainSpanWithOp(transaction: SentryTransaction, op: String): Boolean { + val span = transaction.spans.firstOrNull { span -> span.op == op } + if (span == null) { + println("Unable to find span with op $op in transaction:") + logObject(transaction) + return false + } + + return true + } + + fun doesTransactionContainSpanWithDescription(transaction: SentryTransaction, description: String): Boolean { + val span = transaction.spans.firstOrNull { span -> span.description == description } + if (span == null) { + println("Unable to find span with description $description in transaction:") + logObject(transaction) + return false + } + + return true + } + + fun doesTransactionHaveOp(transaction: SentryTransaction, op: String): Boolean { + val matches = transaction.contexts.trace?.operation == op + if (!matches) { + println("Unable to find transaction with op $op:") + logObject(transaction) + return false + } + + return true + } } diff --git a/sentry/src/main/java/io/sentry/util/SpanUtils.java b/sentry/src/main/java/io/sentry/util/SpanUtils.java index 6c6a83182f..b939e09db4 100644 --- a/sentry/src/main/java/io/sentry/util/SpanUtils.java +++ b/sentry/src/main/java/io/sentry/util/SpanUtils.java @@ -28,6 +28,7 @@ public final class SpanUtils { origins.add("auto.http.spring.resttemplate"); origins.add("auto.http.openfeign"); origins.add("auto.graphql.graphql"); + origins.add("auto.graphql.graphql22"); origins.add("auto.db.jdbc"); return origins; diff --git a/settings.gradle.kts b/settings.gradle.kts index 54f5b2f3ec..34584d1def 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -61,6 +61,7 @@ include( "sentry-samples:sentry-samples-spring-jakarta", "sentry-samples:sentry-samples-spring-boot", "sentry-samples:sentry-samples-spring-boot-jakarta", + "sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples:sentry-samples-spring-boot-webflux", "sentry-samples:sentry-samples-spring-boot-webflux-jakarta", "sentry-samples:sentry-samples-netflix-dgs", diff --git a/test/system-test-sentry-server.py b/test/system-test-sentry-server.py index c98c06fe3b..2823241062 100755 --- a/test/system-test-sentry-server.py +++ b/test/system-test-sentry-server.py @@ -15,6 +15,27 @@ 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 @@ -34,6 +55,10 @@ def get_json(cls): } return json.dumps(jsonObject) + @classmethod + def reset(cls): + cls.__envelopes_received = 0 + class Handler(BaseHTTPRequestHandler): body = None @@ -51,6 +76,18 @@ def do_GET(self): 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): @@ -76,8 +113,9 @@ 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) @@ -117,10 +155,10 @@ def flushLogs(self): 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 diff --git a/test/system-test-spring-server-start.sh b/test/system-test-spring-server-start.sh index 533c1a9f7f..a79c701311 100755 --- a/test/system-test-spring-server-start.sh +++ b/test/system-test-spring-server-start.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash readonly SAMPLE_MODULE=$1 -SENTRY_DSN="http://502f25099c204a2fbf4cb16edc5975d1@localhost:8000/0" java -jar sentry-samples/${SAMPLE_MODULE}/build/libs/${SAMPLE_MODULE}-0.0.1-SNAPSHOT.jar +readonly JAVA_AGENT=$2 +SENTRY_DSN="http://502f25099c204a2fbf4cb16edc5975d1@localhost:8000/0" SENTRY_TRACES_SAMPLE_RATE=1.0 OTEL_TRACES_EXPORTER=none OTEL_METRICS_EXPORTER=none OTEL_LOGS_EXPORTER=none java ${JAVA_AGENT} -jar sentry-samples/${SAMPLE_MODULE}/build/libs/${SAMPLE_MODULE}-0.0.1-SNAPSHOT.jar