From fb8fd8390920b3546daa648b743b7c5878efd7e9 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 13 Feb 2026 15:00:51 +0100 Subject: [PATCH 1/4] Create sentry-opentelemetry-otlp module and OTLP sample for Spring Boot 4 --- build.gradle.kts | 1 + buildSrc/src/main/java/Config.kt | 1 + .../api/sentry-opentelemetry-core.api | 175 ++++++ .../build.gradle.kts | 81 +++ .../otlp/OpenTelemetryOtlpEventProcessor.java | 75 +++ .../otlp/OpenTelemetryOtlpPropagator.java | 119 ++++ .../OpenTelemetryAttributesExtractorTest.kt | 386 +++++++++++++ .../OtelInternalSpanDetectionUtilTest.kt | 188 +++++++ .../test/kotlin/OtelSentryPropagatorTest.kt | 355 ++++++++++++ .../test/kotlin/SentrySpanProcessorTest.kt | 525 ++++++++++++++++++ .../kotlin/SpanDescriptionExtractorTest.kt | 297 ++++++++++ .../README.md | 122 ++++ .../build.gradle.kts | 102 ++++ .../boot4/otlp/CustomEventProcessor.java | 35 ++ .../samples/spring/boot4/otlp/CustomJob.java | 25 + .../otlp/DistributedTracingController.java | 49 ++ .../spring/boot4/otlp/MetricController.java | 35 ++ .../samples/spring/boot4/otlp/Person.java | 24 + .../spring/boot4/otlp/PersonController.java | 51 ++ .../spring/boot4/otlp/PersonService.java | 41 ++ .../boot4/otlp/SecurityConfiguration.java | 41 ++ .../boot4/otlp/SentryDemoApplication.java | 81 +++ .../otlp/SentryOtlpPropagatorProvider.java | 18 + .../samples/spring/boot4/otlp/Todo.java | 25 + .../spring/boot4/otlp/TodoController.java | 57 ++ .../otlp/graphql/AssigneeController.java | 34 ++ .../otlp/graphql/GreetingController.java | 17 + .../boot4/otlp/graphql/ProjectController.java | 140 +++++ .../otlp/graphql/TaskCreatorController.java | 50 ++ .../spring/boot4/otlp/quartz/SampleJob.java | 19 + ...nfigure.spi.ConfigurablePropagatorProvider | 1 + .../src/main/resources/application.properties | 53 ++ .../main/resources/graphql/schema.graphqls | 68 +++ .../src/main/resources/quartz.properties | 1 + .../src/main/resources/schema.sql | 5 + .../src/test/kotlin/io/sentry/DummyTest.kt | 12 + .../DistributedTracingSystemTest.kt | 197 +++++++ .../systemtest/GraphqlGreetingSystemTest.kt | 46 ++ .../systemtest/GraphqlProjectSystemTest.kt | 66 +++ .../systemtest/GraphqlTaskSystemTest.kt | 50 ++ .../io/sentry/systemtest/MetricsSystemTest.kt | 49 ++ .../io/sentry/systemtest/PersonSystemTest.kt | 96 ++++ .../io/sentry/systemtest/TodoSystemTest.kt | 61 ++ .../src/test/resources/logback.xml | 17 + settings.gradle.kts | 2 + 45 files changed, 3893 insertions(+) create mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-core.api create mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts create mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java create mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java create mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt create mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt create mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt create mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SentrySpanProcessorTest.kt create mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SpanDescriptionExtractorTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/README.md create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/CustomEventProcessor.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/CustomJob.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/DistributedTracingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/MetricController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/Person.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/PersonController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/PersonService.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SecurityConfiguration.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SentryDemoApplication.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SentryOtlpPropagatorProvider.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/Todo.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/TodoController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/AssigneeController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/GreetingController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/ProjectController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/TaskCreatorController.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/quartz/SampleJob.java create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/application.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/graphql/schema.graphqls create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/quartz.properties create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/schema.sql create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/DummyTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt create mode 100644 sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/resources/logback.xml diff --git a/build.gradle.kts b/build.gradle.kts index b89b7deed10..5b02df71a0f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -77,6 +77,7 @@ apiValidation { "sentry-samples-spring-boot-4", "sentry-samples-spring-boot-4-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry-noagent", + "sentry-samples-spring-boot-4-otlp", "sentry-samples-spring-boot-4-webflux", "sentry-samples-ktor-client", "sentry-uitest-android", diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 3b6a08ad26b..76aebedebab 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -64,6 +64,7 @@ object Config { val SENTRY_SPRING_BOOT_4_STARTER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot-4-starter" val SENTRY_OPENTELEMETRY_BOOTSTRAP_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.bootstrap" val SENTRY_OPENTELEMETRY_CORE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.core" + val SENTRY_OPENTELEMETRY_OTLP_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.otlp" val SENTRY_OPENTELEMETRY_AGENT_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.agent" val SENTRY_OPENTELEMETRY_AGENTLESS_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.agentless" val SENTRY_OPENTELEMETRY_AGENTLESS_SPRING_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.agentless-spring" diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-core.api b/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-core.api new file mode 100644 index 00000000000..b51c8cc39bc --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-core.api @@ -0,0 +1,175 @@ +public final class io/sentry/opentelemetry/OpenTelemetryAttributesExtractor { + public fun ()V + public fun extract (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/IScope;Lio/sentry/SentryOptions;)V + public fun extractUrl (Lio/opentelemetry/api/common/Attributes;Lio/sentry/SentryOptions;)Ljava/lang/String; +} + +public final class io/sentry/opentelemetry/OpenTelemetryLinkErrorEventProcessor : io/sentry/EventProcessor { + public fun ()V + public fun getOrder ()Ljava/lang/Long; + public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; +} + +public final class io/sentry/opentelemetry/OtelInternalSpanDetectionUtil { + public fun ()V + public static fun isSentryRequest (Lio/sentry/IScopes;Lio/opentelemetry/api/trace/SpanKind;Lio/opentelemetry/api/common/Attributes;)Z +} + +public final class io/sentry/opentelemetry/OtelSamplingUtil { + public fun ()V + public static fun extractSamplingDecision (Lio/opentelemetry/api/common/Attributes;)Lio/sentry/TracesSamplingDecision; +} + +public final class io/sentry/opentelemetry/OtelSentryPropagator : io/opentelemetry/context/propagation/TextMapPropagator { + public fun ()V + public fun extract (Lio/opentelemetry/context/Context;Ljava/lang/Object;Lio/opentelemetry/context/propagation/TextMapGetter;)Lio/opentelemetry/context/Context; + public fun fields ()Ljava/util/Collection; + public fun inject (Lio/opentelemetry/context/Context;Ljava/lang/Object;Lio/opentelemetry/context/propagation/TextMapSetter;)V +} + +public final class io/sentry/opentelemetry/OtelSentrySpanProcessor : io/opentelemetry/sdk/trace/SpanProcessor { + public fun ()V + public fun isEndRequired ()Z + public fun isStartRequired ()Z + public fun onEnd (Lio/opentelemetry/sdk/trace/ReadableSpan;)V + public fun onStart (Lio/opentelemetry/context/Context;Lio/opentelemetry/sdk/trace/ReadWriteSpan;)V +} + +public final class io/sentry/opentelemetry/OtelSpanContext : io/sentry/SpanContext { + public fun (Lio/opentelemetry/sdk/trace/ReadWriteSpan;Lio/sentry/TracesSamplingDecision;Lio/sentry/opentelemetry/IOtelSpanWrapper;Lio/sentry/SpanId;Lio/sentry/Baggage;)V + public fun getOperation ()Ljava/lang/String; + public fun getStatus ()Lio/sentry/SpanStatus; + public fun setOperation (Ljava/lang/String;)V + public fun setStatus (Lio/sentry/SpanStatus;)V +} + +public final class io/sentry/opentelemetry/OtelSpanInfo { + public fun (Ljava/lang/String;Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;)V + public fun getDescription ()Ljava/lang/String; + public fun getOp ()Ljava/lang/String; + public fun getTransactionNameSource ()Lio/sentry/protocol/TransactionNameSource; +} + +public final class io/sentry/opentelemetry/OtelSpanUtils { + public fun ()V + public static fun maybeTransferOtelAttribute (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/ISpan;Lio/opentelemetry/api/common/AttributeKey;)V +} + +public final class io/sentry/opentelemetry/OtelSpanWrapper : io/sentry/opentelemetry/IOtelSpanWrapper { + public fun (Lio/opentelemetry/sdk/trace/ReadWriteSpan;Lio/sentry/IScopes;Lio/sentry/SentryDate;Lio/sentry/TracesSamplingDecision;Lio/sentry/opentelemetry/IOtelSpanWrapper;Lio/sentry/SpanId;Lio/sentry/Baggage;)V + public fun addFeatureFlag (Ljava/lang/String;Ljava/lang/Boolean;)V + public fun finish ()V + public fun finish (Lio/sentry/SpanStatus;)V + public fun finish (Lio/sentry/SpanStatus;Lio/sentry/SentryDate;)V + public fun getContexts ()Lio/sentry/protocol/Contexts; + public fun getData ()Ljava/util/Map; + public fun getData (Ljava/lang/String;)Ljava/lang/Object; + public fun getDescription ()Ljava/lang/String; + public fun getFinishDate ()Lio/sentry/SentryDate; + public fun getMeasurements ()Ljava/util/Map; + public fun getOpenTelemetrySpanAttributes ()Lio/opentelemetry/api/common/Attributes; + public fun getOperation ()Ljava/lang/String; + public fun getSamplingDecision ()Lio/sentry/TracesSamplingDecision; + public fun getScopes ()Lio/sentry/IScopes; + public fun getSpanContext ()Lio/sentry/SpanContext; + public fun getStartDate ()Lio/sentry/SentryDate; + public fun getStatus ()Lio/sentry/SpanStatus; + public fun getTag (Ljava/lang/String;)Ljava/lang/String; + public fun getTags ()Ljava/util/Map; + public fun getThrowable ()Ljava/lang/Throwable; + public fun getTraceId ()Lio/sentry/protocol/SentryId; + public fun getTransactionName ()Ljava/lang/String; + public fun getTransactionNameSource ()Lio/sentry/protocol/TransactionNameSource; + public fun isFinished ()Z + public fun isNoOp ()Z + public fun isProfileSampled ()Ljava/lang/Boolean; + public fun isSampled ()Ljava/lang/Boolean; + public fun makeCurrent ()Lio/sentry/ISentryLifecycleToken; + public fun setContext (Ljava/lang/String;Ljava/lang/Object;)V + public fun setData (Ljava/lang/String;Ljava/lang/Object;)V + public fun setDescription (Ljava/lang/String;)V + public fun setMeasurement (Ljava/lang/String;Ljava/lang/Number;)V + public fun setMeasurement (Ljava/lang/String;Ljava/lang/Number;Lio/sentry/MeasurementUnit;)V + public fun setOperation (Ljava/lang/String;)V + public fun setStatus (Lio/sentry/SpanStatus;)V + public fun setTag (Ljava/lang/String;Ljava/lang/String;)V + public fun setThrowable (Ljava/lang/Throwable;)V + public fun setTransactionName (Ljava/lang/String;)V + public fun setTransactionName (Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;)V + public fun startChild (Lio/sentry/SpanContext;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryDate;Lio/sentry/Instrumenter;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; + public fun startChild (Ljava/lang/String;Ljava/lang/String;Lio/sentry/SpanOptions;)Lio/sentry/ISpan; + public fun storeInContext (Lio/opentelemetry/context/Context;)Lio/opentelemetry/context/Context; + public fun toBaggageHeader (Ljava/util/List;)Lio/sentry/BaggageHeader; + public fun toSentryTrace ()Lio/sentry/SentryTraceHeader; + public fun traceContext ()Lio/sentry/TraceContext; + public fun updateEndDate (Lio/sentry/SentryDate;)Z +} + +public final class io/sentry/opentelemetry/SentryPropagator : io/opentelemetry/context/propagation/TextMapPropagator { + public fun ()V + public fun extract (Lio/opentelemetry/context/Context;Ljava/lang/Object;Lio/opentelemetry/context/propagation/TextMapGetter;)Lio/opentelemetry/context/Context; + public fun fields ()Ljava/util/Collection; + public fun inject (Lio/opentelemetry/context/Context;Ljava/lang/Object;Lio/opentelemetry/context/propagation/TextMapSetter;)V +} + +public final class io/sentry/opentelemetry/SentrySampler : io/opentelemetry/sdk/trace/samplers/Sampler { + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun getDescription ()Ljava/lang/String; + public fun shouldSample (Lio/opentelemetry/context/Context;Ljava/lang/String;Ljava/lang/String;Lio/opentelemetry/api/trace/SpanKind;Lio/opentelemetry/api/common/Attributes;Ljava/util/List;)Lio/opentelemetry/sdk/trace/samplers/SamplingResult; +} + +public final class io/sentry/opentelemetry/SentrySamplingResult : io/opentelemetry/sdk/trace/samplers/SamplingResult { + public fun (Lio/sentry/TracesSamplingDecision;)V + public fun getAttributes ()Lio/opentelemetry/api/common/Attributes; + public fun getDecision ()Lio/opentelemetry/sdk/trace/samplers/SamplingDecision; + public fun getSentryDecision ()Lio/sentry/TracesSamplingDecision; +} + +public final class io/sentry/opentelemetry/SentrySpanExporter : io/opentelemetry/sdk/trace/export/SpanExporter { + public static final field TRACE_ORIGIN Ljava/lang/String; + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun export (Ljava/util/Collection;)Lio/opentelemetry/sdk/common/CompletableResultCode; + public fun flush ()Lio/opentelemetry/sdk/common/CompletableResultCode; + public fun shutdown ()Lio/opentelemetry/sdk/common/CompletableResultCode; +} + +public final class io/sentry/opentelemetry/SentrySpanProcessor : io/opentelemetry/sdk/trace/SpanProcessor { + public fun ()V + public fun isEndRequired ()Z + public fun isStartRequired ()Z + public fun onEnd (Lio/opentelemetry/sdk/trace/ReadableSpan;)V + public fun onStart (Lio/opentelemetry/context/Context;Lio/opentelemetry/sdk/trace/ReadWriteSpan;)V +} + +public final class io/sentry/opentelemetry/SpanDescriptionExtractor { + public fun ()V + public fun extractSpanInfo (Lio/opentelemetry/sdk/trace/data/SpanData;Lio/sentry/opentelemetry/IOtelSpanWrapper;)Lio/sentry/opentelemetry/OtelSpanInfo; +} + +public final class io/sentry/opentelemetry/SpanNode { + public fun (Ljava/lang/String;)V + public fun addChild (Lio/sentry/opentelemetry/SpanNode;)V + public fun addChildren (Ljava/util/List;)V + public fun getChildren ()Ljava/util/List; + public fun getId ()Ljava/lang/String; + public fun getParentNode ()Lio/sentry/opentelemetry/SpanNode; + public fun getSpan ()Lio/opentelemetry/sdk/trace/data/SpanData; + public fun setParentNode (Lio/sentry/opentelemetry/SpanNode;)V + public fun setSpan (Lio/opentelemetry/sdk/trace/data/SpanData;)V +} + +public final class io/sentry/opentelemetry/TraceData { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/sentry/SentryTraceHeader;Lio/sentry/Baggage;)V + public fun getBaggage ()Lio/sentry/Baggage; + public fun getParentSpanId ()Ljava/lang/String; + public fun getSentryTraceHeader ()Lio/sentry/SentryTraceHeader; + public fun getSpanId ()Ljava/lang/String; + public fun getTraceId ()Ljava/lang/String; +} + diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts new file mode 100644 index 00000000000..5fd17c6613a --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -0,0 +1,81 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + jacoco + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 +} + +dependencies { + api(projects.sentry) + + compileOnly(libs.otel) +// compileOnly(libs.otel.semconv) +// compileOnly(libs.otel.semconv.incubating) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.awaitility.kotlin) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + + testImplementation(libs.otel) +// testImplementation(libs.otel.semconv) +// testImplementation(libs.otel.semconv.incubating) +} + +configure { test { java.srcDir("src/test/java") } } + +jacoco { toolVersion = libs.versions.jacoco.get() } + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_OPENTELEMETRY_OTLP_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-opentelemetry-otlp", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java new file mode 100644 index 00000000000..2aefe25191e --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java @@ -0,0 +1,75 @@ +package io.sentry.opentelemetry.otlp; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanId; +import io.opentelemetry.api.trace.TraceId; +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.ScopesAdapter; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SpanContext; +import io.sentry.protocol.SentryId; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; + +public final class OpenTelemetryOtlpEventProcessor implements EventProcessor { + + private final @NotNull IScopes scopes; + + public OpenTelemetryOtlpEventProcessor() { + this(ScopesAdapter.getInstance()); + } + + @TestOnly + OpenTelemetryOtlpEventProcessor(final @NotNull IScopes scopes) { + this.scopes = scopes; + } + + @Override + public @Nullable SentryEvent process(final @NotNull SentryEvent event, final @NotNull Hint hint) { + @NotNull final Span otelSpan = Span.current(); + @NotNull final String traceId = otelSpan.getSpanContext().getTraceId(); + @NotNull final String spanId = otelSpan.getSpanContext().getSpanId(); + + if (TraceId.isValid(traceId) && SpanId.isValid(spanId)) { + final @NotNull SpanContext spanContext = + new SpanContext( + new SentryId(traceId), + new io.sentry.SpanId(spanId), + "opentelemetry", // TODO probably no way to get span name + null, // TODO where to get parent id from? + null); + + event.getContexts().setTrace(spanContext); + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Linking Sentry event %s to span %s created via OpenTelemetry (trace %s).", + event.getEventId(), + spanId, + traceId); + } else { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not linking Sentry event %s to any transaction created via OpenTelemetry as traceId %s or spanId %s are invalid.", + event.getEventId(), + traceId, + spanId); + } + + return event; + } + + @Override + public @Nullable Long getOrder() { + return 6000L; + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java new file mode 100644 index 00000000000..bbbc9c42e0e --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java @@ -0,0 +1,119 @@ +package io.sentry.opentelemetry.otlp; + +import static io.sentry.SentryTraceHeader.SENTRY_TRACE_HEADER; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.ContextKey; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.context.propagation.TextMapSetter; +import io.sentry.Baggage; +import io.sentry.BaggageHeader; +import io.sentry.IScopes; +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.SentryTraceHeader; +import io.sentry.exception.InvalidSentryTraceHeaderException; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class OpenTelemetryOtlpPropagator implements TextMapPropagator { + + private static final @NotNull List FIELDS = + Arrays.asList(SENTRY_TRACE_HEADER, BaggageHeader.BAGGAGE_HEADER); + + public static final @NotNull ContextKey SENTRY_BAGGAGE_KEY = + ContextKey.named("sentry.baggage"); + private final @NotNull IScopes scopes; + + public OpenTelemetryOtlpPropagator() { + this(ScopesAdapter.getInstance()); + } + + OpenTelemetryOtlpPropagator(final @NotNull IScopes scopes) { + this.scopes = scopes; + } + + @Override + public Collection fields() { + return FIELDS; + } + + @Override + public void inject(final Context context, final C carrier, final TextMapSetter setter) { + final @NotNull Span otelSpan = Span.fromContext(context); + final @NotNull SpanContext otelSpanContext = otelSpan.getSpanContext(); + if (!otelSpanContext.isValid()) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not injecting Sentry tracing information for invalid OpenTelemetry span."); + return; + } + + setter.set(carrier, SENTRY_TRACE_HEADER, otelSpanContext.getTraceId() + "-" + otelSpanContext.getSpanId() + "-" + (otelSpanContext.isSampled() ? "1" : "0")); + + final @Nullable Baggage baggage = context.get(SENTRY_BAGGAGE_KEY); + if (baggage != null) { + setter.set(carrier, BaggageHeader.BAGGAGE_HEADER, baggage.toHeaderString(null)); + } + } + + @Override + public Context extract( + final Context context, final C carrier, final TextMapGetter getter) { + final @Nullable String sentryTraceString = + getter.get(carrier, SENTRY_TRACE_HEADER); + if (sentryTraceString == null) { + return context; + } + + try { + SentryTraceHeader sentryTraceHeader = new SentryTraceHeader(sentryTraceString); + + final @Nullable String baggageString = getter.get(carrier, BaggageHeader.BAGGAGE_HEADER); + final Baggage baggage = Baggage.fromHeader(baggageString); + final @NotNull TraceState traceState = TraceState.getDefault(); + + SpanContext otelSpanContext = + SpanContext.createFromRemoteParent( + sentryTraceHeader.getTraceId().toString(), + sentryTraceHeader.getSpanId().toString(), + TraceFlags.getSampled(), + traceState); + + Span wrappedSpan = Span.wrap(otelSpanContext); + + final @NotNull Context modifiedContext = + context + .with(wrappedSpan) + .with(SENTRY_BAGGAGE_KEY, baggage); + + scopes + .getOptions() + .getLogger() + .log(SentryLevel.DEBUG, "Continuing Sentry trace %s", sentryTraceHeader.getTraceId()); + + return modifiedContext; + } catch (InvalidSentryTraceHeaderException e) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.ERROR, + "Unable to extract Sentry tracing information from invalid header.", + e); + return context; + } + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt new file mode 100644 index 00000000000..5cc37d80f9c --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt @@ -0,0 +1,386 @@ +package io.sentry.opentelemetry + +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.sdk.internal.AttributesMap +import io.opentelemetry.sdk.trace.SpanLimits +import io.opentelemetry.sdk.trace.data.SpanData +import io.opentelemetry.semconv.HttpAttributes +import io.opentelemetry.semconv.ServerAttributes +import io.opentelemetry.semconv.UrlAttributes +import io.sentry.Scope +import io.sentry.SentryOptions +import io.sentry.protocol.Request +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class OpenTelemetryAttributesExtractorTest { + private class Fixture { + val spanData = mock() + val attributes = AttributesMap.create(100, SpanLimits.getDefault().maxAttributeValueLength) + val options = SentryOptions.empty() + val scope = Scope(options) + + init { + whenever(spanData.attributes).thenReturn(attributes) + } + } + + private val fixture = Fixture() + + @Test + fun `sets URL based on OTel attributes`() { + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_SCHEME to "https", + UrlAttributes.URL_PATH to "/path/to/123", + UrlAttributes.URL_QUERY to "q=123456&b=X", + ServerAttributes.SERVER_ADDRESS to "io.sentry", + ServerAttributes.SERVER_PORT to 8081L, + ) + ) + + whenExtractingAttributes() + + thenRequestIsSet() + thenUrlIsSetTo("https://io.sentry:8081/path/to/123") + thenQueryIsSetTo("q=123456&b=X") + } + + @Test + fun `when there is an existing request on scope it is filled with more details`() { + fixture.scope.request = Request().also { it.bodySize = 123L } + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_SCHEME to "https", + UrlAttributes.URL_PATH to "/path/to/123", + UrlAttributes.URL_QUERY to "q=123456&b=X", + ServerAttributes.SERVER_ADDRESS to "io.sentry", + ServerAttributes.SERVER_PORT to 8081L, + ) + ) + + whenExtractingAttributes() + + thenRequestIsSet() + thenUrlIsSetTo("https://io.sentry:8081/path/to/123") + thenQueryIsSetTo("q=123456&b=X") + assertEquals(123L, fixture.scope.request!!.bodySize) + } + + @Test + fun `when there is an existing request with url on scope it is kept`() { + fixture.scope.request = + Request().also { + it.url = "http://docs.sentry.io:3000/platform" + it.queryString = "s=abc" + } + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_SCHEME to "https", + UrlAttributes.URL_PATH to "/path/to/123", + UrlAttributes.URL_QUERY to "q=123456&b=X", + ServerAttributes.SERVER_ADDRESS to "io.sentry", + ServerAttributes.SERVER_PORT to 8081L, + ) + ) + + whenExtractingAttributes() + + thenRequestIsSet() + thenUrlIsSetTo("http://docs.sentry.io:3000/platform") + thenQueryIsSetTo("s=abc") + } + + @Test + fun `when there is an existing request with url on scope it is kept with URL_FULL`() { + fixture.scope.request = + Request().also { + it.url = "http://docs.sentry.io:3000/platform" + it.queryString = "s=abc" + } + givenAttributes( + mapOf(UrlAttributes.URL_FULL to "https://io.sentry:8081/path/to/123?q=123456&b=X") + ) + + whenExtractingAttributes() + + thenRequestIsSet() + thenUrlIsSetTo("http://docs.sentry.io:3000/platform") + thenQueryIsSetTo("s=abc") + } + + @Test + fun `sets URL based on OTel attributes without port`() { + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_SCHEME to "https", + UrlAttributes.URL_PATH to "/path/to/123", + ServerAttributes.SERVER_ADDRESS to "io.sentry", + ) + ) + + whenExtractingAttributes() + + thenRequestIsSet() + thenUrlIsSetTo("https://io.sentry/path/to/123") + } + + @Test + fun `sets URL based on OTel attributes without path`() { + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "io.sentry", + ) + ) + + whenExtractingAttributes() + + thenRequestIsSet() + thenUrlIsSetTo("https://io.sentry") + } + + @Test + fun `does not set URL if server address is missing`() { + givenAttributes( + mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET", UrlAttributes.URL_SCHEME to "https") + ) + + whenExtractingAttributes() + + thenRequestIsSet() + thenUrlIsNotSet() + } + + @Test + fun `does not set URL if scheme is missing`() { + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + ServerAttributes.SERVER_ADDRESS to "io.sentry", + ) + ) + + whenExtractingAttributes() + + thenRequestIsSet() + thenUrlIsNotSet() + } + + @Test + fun `returns null if no URL in attributes`() { + givenAttributes(mapOf()) + + val url = whenExtractingUrl() + + assertNull(url) + } + + @Test + fun `returns full URL if present`() { + givenAttributes(mapOf(UrlAttributes.URL_FULL to "https://sentry.io/some/path")) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io/some/path", url) + } + + @Test + fun `returns reconstructed URL if attributes present`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "sentry.io", + ServerAttributes.SERVER_PORT to 8082L, + UrlAttributes.URL_PATH to "/some/path", + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io:8082/some/path", url) + } + + @Test + fun `returns reconstructed URL if attributes present without port`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "sentry.io", + UrlAttributes.URL_PATH to "/some/path", + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io/some/path", url) + } + + @Test + fun `returns null URL if scheme missing`() { + givenAttributes( + mapOf( + ServerAttributes.SERVER_ADDRESS to "sentry.io", + ServerAttributes.SERVER_PORT to 8082L, + UrlAttributes.URL_PATH to "/some/path", + ) + ) + + val url = whenExtractingUrl() + + assertNull(url) + } + + @Test + fun `returns null URL if server address missing`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_PORT to 8082L, + UrlAttributes.URL_PATH to "/some/path", + ) + ) + + val url = whenExtractingUrl() + + assertNull(url) + } + + @Test + fun `returns reconstructed URL if attributes present without port and path`() { + givenAttributes( + mapOf(UrlAttributes.URL_SCHEME to "https", ServerAttributes.SERVER_ADDRESS to "sentry.io") + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io", url) + } + + @Test + fun `returns reconstructed URL if attributes present without path`() { + givenAttributes( + mapOf( + UrlAttributes.URL_SCHEME to "https", + ServerAttributes.SERVER_ADDRESS to "sentry.io", + ServerAttributes.SERVER_PORT to 8082L, + ) + ) + + val url = whenExtractingUrl() + + assertEquals("https://sentry.io:8082", url) + } + + @Test + fun `sets server request headers based on OTel attributes and merges list of values`() { + val elements = + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + val listOf = listOf(elements, "another-baggage=abc,more=def") + val pairs = AttributeKey.stringArrayKey("http.request.header.baggage") to listOf + val map = + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + pairs, + AttributeKey.stringArrayKey("http.request.header.sentry-trace") to + listOf("f9118105af4a2d42b4124532cd176588-4542d085bb0b4de5"), + AttributeKey.stringArrayKey("http.response.header.some-header") to + listOf( + "some-value" + + "__" + + UUID.randomUUID().toString() + + "__" + + UUID.randomUUID().toString() + + "__" + + UUID.randomUUID().toString() + + "__" + + UUID.randomUUID().toString() + ), + ) + givenAttributes(map) + + whenExtractingAttributes() + + thenRequestIsSet() + thenHeaderIsPresentOnRequest( + "baggage", + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d,another-baggage=abc,more=def", + ) + thenHeaderIsPresentOnRequest( + "sentry-trace", + "f9118105af4a2d42b4124532cd176588-4542d085bb0b4de5", + ) + thenHeaderIsNotPresentOnRequest("some-header") + } + + @Test + fun `if there are no header attributes does not set headers on request`() { + givenAttributes(mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET")) + + whenExtractingAttributes() + + thenRequestIsSet() + assertNull(fixture.scope.request!!.headers) + } + + @Test + fun `if there is no request method attribute does not set request on scope`() { + givenAttributes( + mapOf(UrlAttributes.URL_SCHEME to "https", ServerAttributes.SERVER_ADDRESS to "io.sentry") + ) + + whenExtractingAttributes() + + thenRequestIsNotSet() + } + + private fun givenAttributes(map: Map, Any>) { + map.forEach { k, v -> fixture.attributes.put(k, v) } + } + + private fun whenExtractingAttributes() { + OpenTelemetryAttributesExtractor().extract(fixture.spanData, fixture.scope, fixture.options) + } + + private fun whenExtractingUrl(): String? = + OpenTelemetryAttributesExtractor().extractUrl(fixture.attributes, fixture.options) + + private fun thenRequestIsSet() { + assertNotNull(fixture.scope.request) + } + + private fun thenRequestIsNotSet() { + assertNull(fixture.scope.request) + } + + private fun thenUrlIsSetTo(expected: String) { + assertEquals(expected, fixture.scope.request!!.url) + } + + private fun thenUrlIsNotSet() { + assertNull(fixture.scope.request!!.url) + } + + private fun thenQueryIsSetTo(expected: String) { + assertEquals(expected, fixture.scope.request!!.queryString) + } + + private fun thenHeaderIsPresentOnRequest(headerName: String, expectedValue: String) { + assertEquals(expectedValue, fixture.scope.request!!.headers!!.get(headerName)) + } + + private fun thenHeaderIsNotPresentOnRequest(headerName: String) { + assertFalse(fixture.scope.request!!.headers!!.containsKey(headerName)) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt new file mode 100644 index 00000000000..bc453be6c1a --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt @@ -0,0 +1,188 @@ +package io.sentry.opentelemetry + +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.trace.SpanKind +import io.opentelemetry.sdk.internal.AttributesMap +import io.opentelemetry.semconv.HttpAttributes +import io.opentelemetry.semconv.ServerAttributes +import io.opentelemetry.semconv.UrlAttributes +import io.sentry.IScopes +import io.sentry.SentryOptions +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class OtelInternalSpanDetectionUtilTest { + private class Fixture { + val scopes = mock() + val attributes = AttributesMap.create(100, 100) + val options = SentryOptions.empty() + var spanKind: SpanKind = SpanKind.INTERNAL + + init { + whenever(scopes.options).thenReturn(options) + } + } + + private val fixture = Fixture() + + @Test + fun `detects split url as internal (span kind client)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.CLIENT) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_SCHEME to "https", + UrlAttributes.URL_PATH to "/path/to/123", + UrlAttributes.URL_QUERY to "q=123456&b=X", + ServerAttributes.SERVER_ADDRESS to "io.sentry", + ServerAttributes.SERVER_PORT to 8081L, + ) + ) + + thenRequestIsConsideredInternal() + } + + @Test + fun `detects full url as internal (span kind client)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.CLIENT) + givenAttributes(mapOf(UrlAttributes.URL_FULL to "https://io.sentry:8081")) + + thenRequestIsConsideredInternal() + } + + @Test + fun `detects split url as internal (span kind internal)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.INTERNAL) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_SCHEME to "https", + UrlAttributes.URL_PATH to "/path/to/123", + UrlAttributes.URL_QUERY to "q=123456&b=X", + ServerAttributes.SERVER_ADDRESS to "io.sentry", + ServerAttributes.SERVER_PORT to 8081L, + ) + ) + + thenRequestIsConsideredInternal() + } + + @Test + fun `detects full url as internal (span kind internal)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.INTERNAL) + givenAttributes(mapOf(UrlAttributes.URL_FULL to "https://io.sentry:8081")) + + thenRequestIsConsideredInternal() + } + + @Test + fun `does not detect full url as internal (span kind server)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.SERVER) + givenAttributes(mapOf(UrlAttributes.URL_FULL to "https://io.sentry:8081")) + + thenRequestIsNotConsideredInternal() + } + + @Test + fun `does not detect full url as internal (span kind producer)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.PRODUCER) + givenAttributes(mapOf(UrlAttributes.URL_FULL to "https://io.sentry:8081")) + + thenRequestIsNotConsideredInternal() + } + + @Test + fun `does not detect full url as internal (span kind consumer)`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpanKind(SpanKind.CONSUMER) + givenAttributes(mapOf(UrlAttributes.URL_FULL to "https://io.sentry:8081")) + + thenRequestIsNotConsideredInternal() + } + + @Test + fun `detects full spotlight url as internal`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpotlightEnabled(true) + givenSpanKind(SpanKind.CLIENT) + givenAttributes(mapOf(UrlAttributes.URL_FULL to "http://localhost:8969/stream")) + + thenRequestIsConsideredInternal() + } + + @Test + fun `detects full spotlight url as internal with custom spotlight url`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpotlightEnabled(true) + givenSpotlightUrl("http://localhost:8090/stream") + givenSpanKind(SpanKind.CLIENT) + givenAttributes(mapOf(UrlAttributes.URL_FULL to "http://localhost:8090/stream")) + + thenRequestIsConsideredInternal() + } + + @Test + fun `does not detect mismatching full spotlight url as internal`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpotlightEnabled(true) + givenSpanKind(SpanKind.CLIENT) + givenAttributes(mapOf(UrlAttributes.URL_FULL to "http://localhost:8080/stream")) + + thenRequestIsNotConsideredInternal() + } + + @Test + fun `does not detect mismatching full customized spotlight url as internal`() { + givenDsn("https://publicKey:secretKey@io.sentry:8081/path/id?sample.rate=0.1") + givenSpotlightEnabled(true) + givenSpotlightUrl("http://localhost:8090/stream") + givenSpanKind(SpanKind.CLIENT) + givenAttributes(mapOf(UrlAttributes.URL_FULL to "http://localhost:8091/stream")) + + thenRequestIsNotConsideredInternal() + } + + private fun givenAttributes(map: Map, Any>) { + map.forEach { k, v -> fixture.attributes.put(k, v) } + } + + private fun givenDsn(dsn: String) { + fixture.options.dsn = dsn + } + + private fun givenSpotlightEnabled(enabled: Boolean) { + fixture.options.isEnableSpotlight = enabled + } + + private fun givenSpotlightUrl(url: String) { + fixture.options.spotlightConnectionUrl = url + } + + private fun givenSpanKind(spanKind: SpanKind) { + fixture.spanKind = spanKind + } + + private fun thenRequestIsConsideredInternal() { + assertTrue(checkIfInternal()) + } + + private fun thenRequestIsNotConsideredInternal() { + assertFalse(checkIfInternal()) + } + + private fun checkIfInternal(): Boolean = + OtelInternalSpanDetectionUtil.isSentryRequest( + fixture.scopes, + fixture.spanKind, + fixture.attributes, + ) +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt new file mode 100644 index 00000000000..2315412fd46 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt @@ -0,0 +1,355 @@ +package io.sentry.opentelemetry + +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.TraceFlags +import io.opentelemetry.api.trace.TraceState +import io.opentelemetry.context.Context +import io.opentelemetry.context.propagation.TextMapGetter +import io.opentelemetry.context.propagation.TextMapSetter +import io.opentelemetry.semconv.UrlAttributes +import io.sentry.BaggageHeader +import io.sentry.Sentry +import io.sentry.SentryTraceHeader +import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_BAGGAGE_KEY +import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_SCOPES_KEY +import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_TRACE_KEY +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class OtelSentryPropagatorTest { + val spanStorage: SentryWeakSpanStorage = SentryWeakSpanStorage.getInstance() + + @BeforeTest + fun setup() { + Sentry.init("https://key@sentry.io/proj") + } + + @AfterTest + fun cleanup() { + spanStorage.clear() + } + + @Test + fun `propagator registers for sentry-trace and baggage`() { + val propagator = OtelSentryPropagator() + assertEquals(listOf("sentry-trace", "baggage"), propagator.fields()) + } + + @Test + fun `invalid sentry trace header returns context without modification`() { + val propagator = OtelSentryPropagator() + val carrier: Map = + mapOf( + "sentry-trace" to "wrong", + "baggage" to + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + ) + val scopeInContext = Sentry.forkedRootScopes("test") + + val newContext = + propagator.extract( + Context.root().with(SENTRY_SCOPES_KEY, scopeInContext), + carrier, + MapGetter(), + ) + + val scopes = newContext.get(SENTRY_SCOPES_KEY) + assertNotNull(scopes) + assertSame(scopeInContext, scopes) + } + + @Test + fun `uses incoming headers`() { + val propagator = OtelSentryPropagator() + val carrier: Map = + mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + ) + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) + + val span = Span.fromContext(newContext) + assertEquals("f9118105af4a2d42b4124532cd1065ff", span.spanContext.traceId) + assertEquals("424cffc8f94feeee", span.spanContext.spanId) + assertTrue(span.spanContext.isSampled) + + assertEquals( + "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + newContext.get(SENTRY_TRACE_KEY)?.value, + ) + assertEquals( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + newContext.get(SENTRY_BAGGAGE_KEY)?.toHeaderString(null), + ) + } + + @Test + fun `injects headers if no URL`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()) + .thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())) + .thenReturn( + BaggageHeader( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + ) + val otelSpanContext = + SpanContext.create( + "f9118105af4a2d42b4124532cd1065ff", + "424cffc8f94feeee", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertEquals( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + carrier["baggage"], + ) + } + + @Test + fun `injects headers if URL in span attributes with default options`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelAttributes = Attributes.of(UrlAttributes.URL_FULL, "https://sentry.io/some/path") + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()) + .thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())) + .thenReturn( + BaggageHeader( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + ) + whenever(sentrySpan.openTelemetrySpanAttributes).thenReturn(otelAttributes) + val otelSpanContext = + SpanContext.create( + "f9118105af4a2d42b4124532cd1065ff", + "424cffc8f94feeee", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertEquals( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + carrier["baggage"], + ) + } + + @Test + fun `injects headers if URL in span attributes with tracePropagationTargets set to same url`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("sentry.io")) + } + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelAttributes = Attributes.of(UrlAttributes.URL_FULL, "https://sentry.io/some/path") + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()) + .thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())) + .thenReturn( + BaggageHeader( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + ) + whenever(sentrySpan.openTelemetrySpanAttributes).thenReturn(otelAttributes) + val otelSpanContext = + SpanContext.create( + "f9118105af4a2d42b4124532cd1065ff", + "424cffc8f94feeee", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) + assertEquals( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d", + carrier["baggage"], + ) + } + + @Test + fun `does not inject headers if URL in span attributes with tracePropagationTargets set to different url`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("github.com")) + } + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelAttributes = Attributes.of(UrlAttributes.URL_FULL, "https://sentry.io/some/path") + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()) + .thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())) + .thenReturn( + BaggageHeader( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + ) + whenever(sentrySpan.openTelemetrySpanAttributes).thenReturn(otelAttributes) + val otelSpanContext = + SpanContext.create( + "f9118105af4a2d42b4124532cd1065ff", + "424cffc8f94feeee", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if URL in span attributes with tracePropagationTargets set to same url but trace sampling disabled`() { + Sentry.init { options -> + options.dsn = "https://key@sentry.io/proj" + options.setTracePropagationTargets(listOf("sentry.io")) + options.isTraceSampling = false + } + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelAttributes = Attributes.of(UrlAttributes.URL_FULL, "https://sentry.io/some/path") + val sentrySpan = mock() + whenever(sentrySpan.toSentryTrace()) + .thenReturn(SentryTraceHeader("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1")) + whenever(sentrySpan.toBaggageHeader(anyOrNull())) + .thenReturn( + BaggageHeader( + "sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + ) + whenever(sentrySpan.openTelemetrySpanAttributes).thenReturn(otelAttributes) + val otelSpanContext = + SpanContext.create( + "f9118105af4a2d42b4124532cd1065ff", + "424cffc8f94feeee", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if sentry span missing`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val otelSpanContext = + SpanContext.create( + "f9118105af4a2d42b4124532cd1065ff", + "424cffc8f94feeee", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + val otelSpan = Span.wrap(otelSpanContext) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if sentry span noop`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + val sentrySpan = mock() + whenever(sentrySpan.isNoOp).thenReturn(true) + val otelSpanContext = + SpanContext.create( + "f9118105af4a2d42b4124532cd1065ff", + "424cffc8f94feeee", + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + val otelSpan = Span.wrap(otelSpanContext) + spanStorage.storeSentrySpan(otelSpanContext, sentrySpan) + + propagator.inject(Context.root().with(otelSpan), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if span is missing`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + propagator.inject(Context.root(), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } + + @Test + fun `does not inject headers if span is invalid`() { + val propagator = OtelSentryPropagator() + val carrier = mutableMapOf() + + propagator.inject(Context.root().with(Span.getInvalid()), carrier, MapSetter()) + + assertNull(carrier["sentry-trace"]) + assertNull(carrier["baggage"]) + } +} + +class MapGetter : TextMapGetter> { + override fun keys(carrier: Map): MutableIterable = + carrier.keys.toMutableList() + + override fun get(carrier: Map?, key: String): String? = carrier?.get(key) +} + +class MapSetter : TextMapSetter> { + override fun set(carrier: MutableMap?, key: String, value: String) { + carrier?.set(key, value) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SentrySpanProcessorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SentrySpanProcessorTest.kt new file mode 100644 index 00000000000..df2cf9596dd --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SentrySpanProcessorTest.kt @@ -0,0 +1,525 @@ +package io.sentry.opentelemetry + +import io.opentelemetry.api.OpenTelemetry +import io.opentelemetry.api.trace.Span +import io.opentelemetry.api.trace.SpanBuilder +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.SpanId +import io.opentelemetry.api.trace.SpanKind +import io.opentelemetry.api.trace.StatusCode +import io.opentelemetry.api.trace.TraceId +import io.opentelemetry.api.trace.Tracer +import io.opentelemetry.context.Context +import io.opentelemetry.context.propagation.ContextPropagators +import io.opentelemetry.context.propagation.TextMapGetter +import io.opentelemetry.context.propagation.TextMapSetter +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.trace.ReadWriteSpan +import io.opentelemetry.sdk.trace.ReadableSpan +import io.opentelemetry.sdk.trace.SdkTracerProvider +import io.opentelemetry.semconv.HttpAttributes +import io.opentelemetry.semconv.UrlAttributes +import io.sentry.Baggage +import io.sentry.BaggageHeader +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.ITransaction +import io.sentry.Instrumenter +import io.sentry.SentryDate +import io.sentry.SentryEvent +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SpanOptions +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.TransactionNameSource +import java.net.http.HttpHeaders +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +class SentrySpanProcessorTest { + + companion object { + val SENTRY_TRACE_HEADER_STRING = "2722d9f6ec019ade60c776169d9a8904-cedf5b7571cb4972-1" + val BAGGAGE_HEADER_STRING = + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=2722d9f6ec019ade60c776169d9a8904,sentry-transaction=HTTP%20GET" + } + + private class Fixture { + + val options = + SentryOptions().also { + it.dsn = "https://key@sentry.io/proj" + it.instrumenter = Instrumenter.OTEL + } + val scopes = mock() + val transaction = mock() + val span = mock() + val spanContext = mock() + lateinit var openTelemetry: OpenTelemetry + lateinit var tracer: Tracer + val sentryTrace = SentryTraceHeader(SENTRY_TRACE_HEADER_STRING) + val baggage = Baggage.fromHeader(BAGGAGE_HEADER_STRING) + + fun setup() { + whenever(scopes.isEnabled).thenReturn(true) + whenever(scopes.options).thenReturn(options) + whenever(scopes.startTransaction(any(), any())) + .thenReturn(transaction) + + whenever(spanContext.operation).thenReturn("spanContextOp") + whenever(spanContext.parentSpanId).thenReturn(io.sentry.SpanId("cedf5b7571cb4972")) + + whenever(transaction.spanContext).thenReturn(spanContext) + whenever(span.spanContext).thenReturn(spanContext) + whenever(span.toSentryTrace()).thenReturn(sentryTrace) + whenever(transaction.toSentryTrace()).thenReturn(sentryTrace) + + val baggageHeader = BaggageHeader.fromBaggageAndOutgoingHeader(baggage, null) + whenever(span.toBaggageHeader(any())).thenReturn(baggageHeader) + whenever(transaction.toBaggageHeader(any())).thenReturn(baggageHeader) + + whenever( + transaction.startChild( + any(), + anyOrNull(), + anyOrNull(), + eq(Instrumenter.OTEL), + any(), + ) + ) + .thenReturn(span) + + val sdkTracerProvider = + SdkTracerProvider.builder().addSpanProcessor(SentrySpanProcessor(scopes)).build() + + openTelemetry = + OpenTelemetrySdk.builder() + .setTracerProvider(sdkTracerProvider) + .setPropagators(ContextPropagators.create(SentryPropagator())) + .build() + + tracer = openTelemetry.getTracer("sentry-test") + } + } + + private val fixture = Fixture() + + @Test + fun `requires start`() { + val processor = SentrySpanProcessor() + assertTrue(processor.isStartRequired) + } + + @Test + fun `requires end`() { + val processor = SentrySpanProcessor() + assertTrue(processor.isEndRequired) + } + + @Test + fun `ignores sentry client request`() { + fixture.setup() + givenSpanBuilder(SpanKind.CLIENT) + .setAttribute(UrlAttributes.URL_FULL, "https://key@sentry.io/proj/some-api") + .startSpan() + + thenNoTransactionIsStarted() + } + + @Test + fun `ignores sentry internal request`() { + fixture.setup() + givenSpanBuilder(SpanKind.CLIENT) + .setAttribute(UrlAttributes.URL_FULL, "https://key@sentry.io/proj/some-api") + .startSpan() + + thenNoTransactionIsStarted() + } + + @Test + fun `does nothing on start if Sentry has not been initialized`() { + fixture.setup() + val context = mock() + val span = mock() + + whenever(fixture.scopes.isEnabled).thenReturn(false) + + SentrySpanProcessor(fixture.scopes).onStart(context, span) + + verify(fixture.scopes).isEnabled + verify(fixture.scopes).options + verifyNoMoreInteractions(fixture.scopes) + verifyNoInteractions(context, span) + } + + @Test + fun `does nothing on end if Sentry has not been initialized`() { + fixture.setup() + val span = mock() + + whenever(fixture.scopes.isEnabled).thenReturn(false) + + SentrySpanProcessor(fixture.scopes).onEnd(span) + + verify(fixture.scopes).isEnabled + verify(fixture.scopes).options + verifyNoMoreInteractions(fixture.scopes) + verifyNoInteractions(span) + } + + @Test + fun `does not start transaction for invalid SpanId`() { + fixture.setup() + val mockSpan = mock() + val mockSpanContext = mock() + whenever(mockSpanContext.spanId).thenReturn(SpanId.getInvalid()) + whenever(mockSpan.spanContext).thenReturn(mockSpanContext) + SentrySpanProcessor(fixture.scopes).onStart(Context.current(), mockSpan) + thenNoTransactionIsStarted() + } + + @Test + fun `does not start transaction for invalid TraceId`() { + fixture.setup() + val mockSpan = mock() + val mockSpanContext = mock() + whenever(mockSpanContext.spanId).thenReturn(SpanId.fromBytes("seed".toByteArray())) + whenever(mockSpanContext.traceId).thenReturn(TraceId.getInvalid()) + whenever(mockSpan.spanContext).thenReturn(mockSpanContext) + SentrySpanProcessor(fixture.scopes).onStart(Context.current(), mockSpan) + thenNoTransactionIsStarted() + } + + @Test + fun `creates transaction for first otel span and span for second`() { + fixture.setup() + val otelSpan = givenSpanBuilder().startSpan() + thenTransactionIsStarted(otelSpan, isContinued = false) + + val otelChildSpan = givenSpanBuilder(SpanKind.CLIENT, parentSpan = otelSpan).startSpan() + thenChildSpanIsStarted() + + otelChildSpan.end() + thenChildSpanIsFinished() + + otelSpan.end() + thenTransactionIsFinished() + } + + private fun whenExtractingHeaders(sentryTrace: Boolean = true, baggage: Boolean = true): Context { + val headers = givenHeaders(sentryTrace, baggage) + return fixture.openTelemetry.propagators.textMapPropagator.extract( + Context.current(), + headers, + HeaderGetter(), + ) + } + + @Test + fun `propagator can extract and result is used for transaction and attached on inject`() { + fixture.setup() + val extractedContext = whenExtractingHeaders() + + extractedContext.makeCurrent().use { _ -> + val otelSpan = givenSpanBuilder().startSpan() + thenTraceIdIsUsed(otelSpan) + thenTransactionIsStarted(otelSpan, isContinued = true) + + val otelChildSpan = givenSpanBuilder(SpanKind.CLIENT, parentSpan = otelSpan).startSpan() + thenChildSpanIsStarted() + + val map = mutableMapOf() + fixture.openTelemetry.propagators.textMapPropagator.inject( + Context.current().with(otelSpan), + map, + TestSetter(), + ) + + assertTrue(map.isNotEmpty()) + assertEquals(SENTRY_TRACE_HEADER_STRING, map["sentry-trace"]) + assertEquals(BAGGAGE_HEADER_STRING, map["baggage"]) + + otelChildSpan.end() + thenChildSpanIsFinished() + + otelSpan.end() + thenTransactionIsFinished() + } + } + + @Test + fun `incoming baggage without sentry-trace is ignored`() { + fixture.setup() + val extractedContext = whenExtractingHeaders(sentryTrace = false, baggage = true) + + extractedContext.makeCurrent().use { _ -> + val otelSpan = givenSpanBuilder().startSpan() + thenTraceIdIsNotUsed(otelSpan) + thenTransactionIsStarted(otelSpan, isContinued = false) + + val otelChildSpan = givenSpanBuilder(SpanKind.CLIENT, parentSpan = otelSpan).startSpan() + thenChildSpanIsStarted() + + otelChildSpan.end() + thenChildSpanIsFinished() + + otelSpan.end() + thenTransactionIsFinished() + } + } + + @Test + fun `sentry-trace without baggage continues trace`() { + fixture.setup() + val extractedContext = whenExtractingHeaders(sentryTrace = true, baggage = false) + + extractedContext.makeCurrent().use { _ -> + val otelSpan = givenSpanBuilder().startSpan() + + thenTraceIdIsUsed(otelSpan) + thenTransactionIsStarted(otelSpan, isContinued = true, continuesWithFilledBaggage = false) + + val otelChildSpan = givenSpanBuilder(SpanKind.CLIENT, parentSpan = otelSpan).startSpan() + thenChildSpanIsStarted() + + otelChildSpan.end() + thenChildSpanIsFinished() + + otelSpan.end() + thenTransactionIsFinished() + } + } + + @Test + fun `sets status for errored span`() { + fixture.setup() + val otelSpan = givenSpanBuilder().startSpan() + thenTransactionIsStarted(otelSpan, isContinued = false) + + val otelChildSpan = givenSpanBuilder(SpanKind.CLIENT, parentSpan = otelSpan).startSpan() + thenChildSpanIsStarted() + + otelChildSpan.setStatus(StatusCode.ERROR) + otelChildSpan.setAttribute(UrlAttributes.URL_FULL, "http://github.com/getsentry/sentry-java") + otelChildSpan.setAttribute(HttpAttributes.HTTP_RESPONSE_STATUS_CODE, 404L) + + otelChildSpan.end() + thenChildSpanIsFinished(SpanStatus.NOT_FOUND) + + otelSpan.end() + thenTransactionIsFinished() + } + + @Test + fun `sets status for errored span if not http`() { + fixture.setup() + val otelSpan = givenSpanBuilder().startSpan() + thenTransactionIsStarted(otelSpan, isContinued = false) + + val otelChildSpan = givenSpanBuilder(SpanKind.CLIENT, parentSpan = otelSpan).startSpan() + thenChildSpanIsStarted() + + otelChildSpan.setStatus(StatusCode.ERROR) + + otelChildSpan.end() + thenChildSpanIsFinished(SpanStatus.UNKNOWN_ERROR) + + otelSpan.end() + thenTransactionIsFinished() + } + + @Test + fun `links error to OTEL transaction`() { + fixture.setup() + val extractedContext = whenExtractingHeaders() + + extractedContext.makeCurrent().use { _ -> + val otelSpan = givenSpanBuilder().startSpan() + thenTransactionIsStarted(otelSpan, isContinued = true) + + otelSpan.makeCurrent().use { _ -> + val processedEvent = + OpenTelemetryLinkErrorEventProcessor(fixture.scopes).process(SentryEvent(), Hint()) + val traceContext = processedEvent!!.contexts.trace!! + + assertEquals("2722d9f6ec019ade60c776169d9a8904", traceContext.traceId.toString()) + assertEquals(otelSpan.spanContext.spanId, traceContext.spanId.toString()) + assertEquals("cedf5b7571cb4972", traceContext.parentSpanId.toString()) + assertEquals("spanContextOp", traceContext.operation) + } + + otelSpan.end() + thenTransactionIsFinished() + } + } + + @Test + fun `does not link error to OTEL transaction if instrumenter does not match`() { + fixture.options.instrumenter = Instrumenter.SENTRY + fixture.setup() + + val processedEvent = + OpenTelemetryLinkErrorEventProcessor(fixture.scopes).process(SentryEvent(), Hint()) + + thenNoTraceContextHasBeenAddedToEvent(processedEvent) + } + + private fun givenSpanBuilder( + spanKind: SpanKind = SpanKind.SERVER, + parentSpan: Span? = null, + ): SpanBuilder { + val spanName = if (parentSpan == null) "testspan" else "childspan" + val spanBuilder = + fixture.tracer + .spanBuilder(spanName) + .setAttribute("some-attribute", "some-value") + .setSpanKind(spanKind) + + parentSpan?.let { spanBuilder.setParent(Context.current().with(parentSpan)) } + + return spanBuilder + } + + private fun givenHeaders(sentryTrace: Boolean = true, baggage: Boolean = true): HttpHeaders? { + val headerMap = + mutableMapOf>().also { + if (sentryTrace) { + it.put("sentry-trace", listOf(SENTRY_TRACE_HEADER_STRING)) + } + if (baggage) { + it.put("baggage", listOf(BAGGAGE_HEADER_STRING)) + } + } + + return HttpHeaders.of(headerMap) { _, _ -> true } + } + + private fun thenTransactionIsStarted( + otelSpan: Span, + isContinued: Boolean = false, + continuesWithFilledBaggage: Boolean = true, + ) { + if (isContinued) { + verify(fixture.scopes) + .startTransaction( + check { + assertEquals("testspan", it.name) + assertEquals(TransactionNameSource.CUSTOM, it.transactionNameSource) + assertEquals("testspan", it.operation) + assertEquals(otelSpan.spanContext.spanId, it.spanId.toString()) + assertEquals("2722d9f6ec019ade60c776169d9a8904", it.traceId.toString()) + assertEquals("cedf5b7571cb4972", it.parentSpanId?.toString()) + assertTrue(it.parentSamplingDecision!!.sampled) + if (continuesWithFilledBaggage) { + assertEquals("2722d9f6ec019ade60c776169d9a8904", it.baggage?.traceId) + assertEquals(1.0, it.baggage?.sampleRate) + assertEquals("HTTP GET", it.baggage?.transaction) + assertEquals("502f25099c204a2fbf4cb16edc5975d1", it.baggage?.publicKey) + assertFalse(it.baggage!!.isMutable) + } else { + assertNotNull(it.baggage) + assertNull(it.baggage?.traceId) + assertNull(it.baggage?.sampleRate) + assertNull(it.baggage?.transaction) + assertNull(it.baggage?.publicKey) + assertTrue(it.baggage!!.isMutable) + } + }, + check { + assertNotNull(it.startTimestamp) + assertFalse(it.isBindToScope) + }, + ) + } else { + verify(fixture.scopes) + .startTransaction( + check { + assertEquals("testspan", it.name) + assertEquals(TransactionNameSource.CUSTOM, it.transactionNameSource) + assertEquals("testspan", it.operation) + assertEquals(otelSpan.spanContext.spanId, it.spanId.toString()) + assertEquals(otelSpan.spanContext.traceId, it.traceId.toString()) + assertNull(it.parentSpanId) + assertNull(it.parentSamplingDecision) + assertNotNull(it.baggage) + }, + check { + assertNotNull(it.startTimestamp) + assertFalse(it.isBindToScope) + }, + ) + } + } + + private fun thenTraceIdIsUsed(otelSpan: Span) { + assertEquals("2722d9f6ec019ade60c776169d9a8904", otelSpan.spanContext.traceId) + } + + private fun thenTraceIdIsNotUsed(otelSpan: Span) { + assertNotEquals("2722d9f6ec019ade60c776169d9a8904", otelSpan.spanContext.traceId) + } + + private fun thenNoTransactionIsStarted() { + verify(fixture.scopes, never()) + .startTransaction(any(), any()) + } + + private fun thenChildSpanIsStarted() { + verify(fixture.transaction) + .startChild( + eq("childspan"), + eq("childspan"), + any(), + eq(Instrumenter.OTEL), + any(), + ) + } + + private fun thenChildSpanIsFinished(status: SpanStatus = SpanStatus.OK) { + verify(fixture.span).finish(eq(status), any()) + } + + private fun thenTransactionIsFinished() { + verify(fixture.transaction).setContext(eq("otel"), any()) + verify(fixture.transaction).finish(eq(SpanStatus.OK), any()) + } + + private fun thenNoTraceContextHasBeenAddedToEvent(event: SentryEvent?) { + assertNotNull(event) + assertNull(event.contexts.trace) + } +} + +class HeaderGetter : TextMapGetter { + override fun keys(headers: HttpHeaders): MutableIterable { + return headers.map().map { it.key }.toMutableList() + } + + override fun get(headers: HttpHeaders?, key: String): String? { + return headers?.firstValue(key)?.orElse(null) + } +} + +class TestSetter : TextMapSetter> { + override fun set(values: MutableMap?, key: String, value: String) { + values?.put(key, value) + } +} diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SpanDescriptionExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SpanDescriptionExtractorTest.kt new file mode 100644 index 00000000000..af04914e278 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SpanDescriptionExtractorTest.kt @@ -0,0 +1,297 @@ +package io.sentry.opentelemetry + +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.trace.SpanContext +import io.opentelemetry.api.trace.SpanKind +import io.opentelemetry.api.trace.TraceFlags +import io.opentelemetry.api.trace.TraceState +import io.opentelemetry.sdk.internal.AttributesMap +import io.opentelemetry.sdk.trace.data.SpanData +import io.opentelemetry.semconv.HttpAttributes +import io.opentelemetry.semconv.UrlAttributes +import io.opentelemetry.semconv.incubating.DbIncubatingAttributes +import io.opentelemetry.semconv.incubating.HttpIncubatingAttributes +import io.sentry.protocol.TransactionNameSource +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class SpanDescriptionExtractorTest { + private class Fixture { + val sentrySpan = mock() + val otelSpan = mock() + val attributes = AttributesMap.create(100, 100) + var parentSpanContext = SpanContext.getInvalid() + var spanKind = SpanKind.INTERNAL + var spanName: String? = null + var spanDescription: String? = null + + fun setup() { + whenever(otelSpan.attributes).thenReturn(attributes) + whenever(otelSpan.parentSpanContext).thenReturn(parentSpanContext) + whenever(otelSpan.kind).thenReturn(spanKind) + spanName?.let { whenever(otelSpan.name).thenReturn(it) } + spanDescription?.let { whenever(sentrySpan.description).thenReturn(it) } + } + } + + private val fixture = Fixture() + + @Test + fun `sets op to http server for kind SERVER`() { + givenSpanKind(SpanKind.SERVER) + givenAttributes(mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET")) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `sets op to http client for kind CLIENT`() { + givenSpanKind(SpanKind.CLIENT) + givenAttributes(mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET")) + + val info = whenExtractingSpanInfo() + + assertEquals("http.client", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `sets op to http without server for root span with http GET`() { + givenAttributes(mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET")) + + val info = whenExtractingSpanInfo() + + assertEquals("http", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `sets op to http without server for non root span with remote parent with http GET`() { + givenParentContext(createSpanContext(true)) + givenAttributes(mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET")) + + val info = whenExtractingSpanInfo() + + assertEquals("http", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `sets op to http client for non root span with http GET`() { + givenParentContext(createSpanContext(false)) + givenAttributes(mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET")) + + val info = whenExtractingSpanInfo() + + assertEquals("http.client", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `uses URL_FULL for description`() { + givenSpanKind(SpanKind.SERVER) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + UrlAttributes.URL_FULL to "https://sentry.io/some/path?q=1#top", + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertEquals("GET https://sentry.io/some/path?q=1#top", info.description) + assertEquals(TransactionNameSource.URL, info.transactionNameSource) + } + + @Test + fun `uses URL_PATH for description`() { + givenSpanKind(SpanKind.SERVER) + givenAttributes( + mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET", UrlAttributes.URL_PATH to "/some/path") + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertEquals("GET /some/path", info.description) + assertEquals(TransactionNameSource.URL, info.transactionNameSource) + } + + @Test + fun `uses HTTP_TARGET for description`() { + givenSpanKind(SpanKind.SERVER) + givenAttributes( + mapOf( + HttpAttributes.HTTP_REQUEST_METHOD to "GET", + HttpAttributes.HTTP_ROUTE to "/some/{id}", + HttpIncubatingAttributes.HTTP_TARGET to "some/path?q=1#top", + UrlAttributes.URL_PATH to "/some/path", + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertEquals("GET /some/{id}", info.description) + assertEquals(TransactionNameSource.ROUTE, info.transactionNameSource) + } + + @Test + fun `uses span name as description fallback`() { + givenSpanKind(SpanKind.SERVER) + givenSpanName("span name") + givenAttributes(mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET")) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertEquals("span name", info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `no description if no span name as fallback`() { + givenSpanKind(SpanKind.SERVER) + givenAttributes(mapOf(HttpAttributes.HTTP_REQUEST_METHOD to "GET")) + + val info = whenExtractingSpanInfo() + + assertEquals("http.server", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `sets op to db for span with db system and query text`() { + givenAttributes( + mapOf( + DbIncubatingAttributes.DB_SYSTEM to "some", + DbIncubatingAttributes.DB_QUERY_TEXT to "SELECT * FROM tbl", + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("db", info.op) + assertEquals("SELECT * FROM tbl", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `sets op to db for span with db system and statement`() { + givenAttributes( + mapOf( + DbIncubatingAttributes.DB_SYSTEM to "some", + DbIncubatingAttributes.DB_STATEMENT to "SELECT * FROM tbl", + ) + ) + + val info = whenExtractingSpanInfo() + + assertEquals("db", info.op) + assertEquals("SELECT * FROM tbl", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `sets op to db for span with db system`() { + givenAttributes(mapOf(DbIncubatingAttributes.DB_SYSTEM to "some")) + + val info = whenExtractingSpanInfo() + + assertEquals("db", info.op) + assertNull(info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `sets op to db for span with db system fallback to span name as description`() { + givenSpanName("span name") + givenAttributes(mapOf(DbIncubatingAttributes.DB_SYSTEM to "some")) + + val info = whenExtractingSpanInfo() + + assertEquals("db", info.op) + assertEquals("span name", info.description) + assertEquals(TransactionNameSource.TASK, info.transactionNameSource) + } + + @Test + fun `uses span name as op and description if no relevant attributes`() { + givenSpanName("span name") + givenAttributes(emptyMap()) + + val info = whenExtractingSpanInfo() + + assertEquals("span name", info.op) + assertEquals("span name", info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + @Test + fun `uses existing sentry span description as description`() { + givenSpanName("span name") + givenSentrySpanDescription("span description") + givenAttributes(emptyMap()) + + val info = whenExtractingSpanInfo() + + assertEquals("span name", info.op) + assertEquals("span description", info.description) + assertEquals(TransactionNameSource.CUSTOM, info.transactionNameSource) + } + + private fun createSpanContext( + isRemote: Boolean, + traceId: String = "f9118105af4a2d42b4124532cd1065ff", + spanId: String = "424cffc8f94feeee", + ): SpanContext { + if (isRemote) { + return SpanContext.createFromRemoteParent( + traceId, + spanId, + TraceFlags.getSampled(), + TraceState.getDefault(), + ) + } else { + return SpanContext.create(traceId, spanId, TraceFlags.getSampled(), TraceState.getDefault()) + } + } + + private fun givenAttributes(map: Map, Any>) { + map.forEach { k, v -> fixture.attributes.put(k, v) } + } + + private fun whenExtractingSpanInfo(): OtelSpanInfo { + fixture.setup() + return SpanDescriptionExtractor().extractSpanInfo(fixture.otelSpan, fixture.sentrySpan) + } + + private fun givenParentContext(parentContext: SpanContext) { + fixture.parentSpanContext = parentContext + } + + private fun givenSpanName(name: String) { + fixture.spanName = name + } + + private fun givenSentrySpanDescription(description: String) { + fixture.spanDescription = description + } + + private fun givenSpanKind(spanKind: SpanKind) { + fixture.spanKind = spanKind + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/README.md b/sentry-samples/sentry-samples-spring-boot-4-otlp/README.md new file mode 100644 index 00000000000..58b94ba8997 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/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-4-otlp/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts new file mode 100644 index 00000000000..f13d3270844 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/build.gradle.kts @@ -0,0 +1,102 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.springboot4) + alias(libs.plugins.spring.dependency.management) + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) +} + +group = "io.sentry.sample.spring-boot-4-otlp" + +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 { + kotlin { + explicitApi() + // skip metadata version check, as Spring 7 / Spring Boot 4 is + // compiled against a newer version of Kotlin + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + } +} + +dependencies { + implementation(libs.springboot4.starter) + implementation(libs.springboot4.starter.actuator) + implementation(libs.springboot4.starter.aspectj) + implementation(libs.springboot4.starter.graphql) + implementation(libs.springboot4.starter.jdbc) + implementation(libs.springboot4.starter.quartz) + implementation(libs.springboot4.starter.security) + implementation(libs.springboot4.starter.web) + implementation(libs.springboot4.starter.webflux) + implementation(libs.springboot4.starter.websocket) + implementation(libs.springboot4.starter.restclient) + implementation(libs.springboot4.starter.webclient) + implementation(Config.Libs.aspectj) + implementation(Config.Libs.kotlinReflect) + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation(projects.sentrySpringBoot4Starter) + implementation(projects.sentryLogback) + implementation(projects.sentryGraphql22) + implementation(projects.sentryQuartz) + implementation(projects.sentryAsyncProfiler) + implementation(projects.sentryOpentelemetry.sentryOpentelemetryOtlp) + implementation(libs.springboot4.otel) + + // database query tracing + implementation(projects.sentryJdbc) + runtimeOnly(libs.hsqldb) + + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(projects.sentry) + testImplementation(projects.sentrySystemTestSupport) + testImplementation(libs.apollo3.kotlin) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.slf4j2.api) + testImplementation(libs.springboot4.starter.test) { + exclude(group = "org.junit.vintage", module = "junit-vintage-engine") + } + testImplementation("ch.qos.logback:logback-classic:1.5.16") + testImplementation("ch.qos.logback:logback-core:1.5.16") +} + +dependencyManagement { imports { mavenBom(libs.otel.instrumentation.bom.get().toString()) } } + +configure { test { java.srcDir("src/test/java") } } + +tasks.register("systemTest").configure { + group = "verification" + description = "Runs the System tests" + + outputs.upToDateWhen { false } + + 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.*") } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/CustomEventProcessor.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/CustomEventProcessor.java new file mode 100644 index 00000000000..14df61c652f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/CustomEventProcessor.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot4.otlp; + +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-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/CustomJob.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/CustomJob.java new file mode 100644 index 00000000000..6e6df9b2e2a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/CustomJob.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot4.otlp; + +import io.sentry.spring7.checkin.SentryCheckIn; +import io.sentry.spring7.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-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/DistributedTracingController.java new file mode 100644 index 00000000000..85e79ee1df5 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/DistributedTracingController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot4.otlp; + +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final RestClient restClient; + + public DistributedTracingController(RestClient restClient) { + this.restClient = restClient; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + return restClient + .get() + .uri("http://localhost:8080/person/{id}", id) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } + + @PostMapping + Person create(@RequestBody Person person) { + return restClient + .post() + .uri("http://localhost:8080/person/") + .body(person) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/MetricController.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/MetricController.java new file mode 100644 index 00000000000..e980d676d2b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/MetricController.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot4.otlp; + +import io.sentry.Sentry; +import io.sentry.metrics.MetricsUnit; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/metric/") +public class MetricController { + private static final Logger LOGGER = LoggerFactory.getLogger(MetricController.class); + + @GetMapping("count") + String count() { + Sentry.metrics().count("countMetric"); + return "count metric increased"; + } + + @GetMapping("gauge/{value}") + String gauge(@PathVariable("value") Long value) { + Sentry.metrics().gauge("memory.free", value.doubleValue(), MetricsUnit.Information.BYTE); + return "gauge metric tracked"; + } + + @GetMapping("distribution/{value}") + String distribution(@PathVariable("value") Long value) { + Sentry.metrics() + .distribution("distributionMetric", value.doubleValue(), MetricsUnit.Duration.MILLISECOND); + return "distribution metric tracked"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/Person.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/Person.java new file mode 100644 index 00000000000..8d8bc4bee75 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/Person.java @@ -0,0 +1,24 @@ +package io.sentry.samples.spring.boot4.otlp; + +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-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/PersonController.java new file mode 100644 index 00000000000..5603255334b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/PersonController.java @@ -0,0 +1,51 @@ +package io.sentry.samples.spring.boot4.otlp; + +import io.sentry.ISpan; +import io.sentry.Sentry; +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 static final Logger LOGGER = LoggerFactory.getLogger(PersonController.class); + + public PersonController(PersonService personService) { + this.personService = personService; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + Sentry.addFeatureFlag("transaction-feature-flag", true); + ISpan currentSpan = Sentry.getSpan(); + ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); + try { + Sentry.logger().warn("warn Sentry logging"); + Sentry.logger().error("error Sentry logging"); + Sentry.logger().info("hello %s %s", "there", "world!"); + Sentry.addFeatureFlag("my-feature-flag", true); + LOGGER.error("Trying person with id={}", id, new RuntimeException("error while loading")); + throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); + } finally { + sentrySpan.finish(); + } + } + + @PostMapping + Person create(@RequestBody Person person) { + ISpan currentSpan = Sentry.getSpan(); + ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); + try { + return personService.create(person); + } finally { + sentrySpan.finish(); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/PersonService.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/PersonService.java new file mode 100644 index 00000000000..947a82435d4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/PersonService.java @@ -0,0 +1,41 @@ +package io.sentry.samples.spring.boot4.otlp; + +import io.sentry.ISpan; +import io.sentry.Sentry; +import io.sentry.spring7.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-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SecurityConfiguration.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SecurityConfiguration.java new file mode 100644 index 00000000000..69d578f2d89 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SecurityConfiguration.java @@ -0,0 +1,41 @@ +package io.sentry.samples.spring.boot4.otlp; + +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 { + return http.csrf((csrf) -> csrf.disable()) + .authorizeHttpRequests((r) -> r.anyRequest().authenticated()) + .httpBasic((h) -> {}) + .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-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SentryDemoApplication.java new file mode 100644 index 00000000000..b4c58c4882e --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SentryDemoApplication.java @@ -0,0 +1,81 @@ +package io.sentry.samples.spring.boot4.otlp; + +import static io.sentry.quartz.SentryJobListener.SENTRY_SLUG_KEY; + +import io.sentry.Sentry; +import io.sentry.SentryOptions; +import io.sentry.opentelemetry.otlp.OpenTelemetryOtlpEventProcessor; +import io.sentry.samples.spring.boot4.otlp.quartz.SampleJob; +import java.util.Collections; +import org.quartz.JobDetail; +import org.quartz.SimpleTrigger; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.restclient.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.quartz.CronTriggerFactoryBean; +import org.springframework.scheduling.quartz.JobDetailFactoryBean; +import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean; +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 Sentry.OptionsConfiguration sentryOptionsCustomization() { + return options -> { + options.addEventProcessor(new OpenTelemetryOtlpEventProcessor()); + }; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SentryOtlpPropagatorProvider.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SentryOtlpPropagatorProvider.java new file mode 100644 index 00000000000..3774f60b068 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/SentryOtlpPropagatorProvider.java @@ -0,0 +1,18 @@ +package io.sentry.samples.spring.boot4.otlp; + +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider; +import io.sentry.opentelemetry.otlp.OpenTelemetryOtlpPropagator; + +public final class SentryOtlpPropagatorProvider implements ConfigurablePropagatorProvider { + @Override + public TextMapPropagator getPropagator(ConfigProperties config) { + return new OpenTelemetryOtlpPropagator(); + } + + @Override + public String getName() { + return "sentry"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/Todo.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/Todo.java new file mode 100644 index 00000000000..39be54cfc7f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/Todo.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot4.otlp; + +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-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/TodoController.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/TodoController.java new file mode 100644 index 00000000000..b083e6c1f88 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/TodoController.java @@ -0,0 +1,57 @@ +package io.sentry.samples.spring.boot4.otlp; + +import io.sentry.reactor.SentryReactorUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +@RestController +public class TodoController { + private final RestTemplate restTemplate; + private final WebClient webClient; + private final RestClient restClient; + + public TodoController(RestTemplate restTemplate, WebClient webClient, RestClient restClient) { + this.restTemplate = restTemplate; + this.webClient = webClient; + this.restClient = restClient; + } + + @GetMapping("/todo/{id}") + Todo todo(@PathVariable Long id) { + return restTemplate.getForObject( + "https://jsonplaceholder.typicode.com/todos/{id}", Todo.class, id); + } + + @GetMapping("/todo-webclient/{id}") + Todo todoWebClient(@PathVariable Long id) { + Hooks.enableAutomaticContextPropagation(); + return SentryReactorUtils.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) { + return restClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .body(Todo.class); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/AssigneeController.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/AssigneeController.java new file mode 100644 index 00000000000..ef4afb1a6f6 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/AssigneeController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot4.otlp.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-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/GreetingController.java new file mode 100644 index 00000000000..c1d2a9f9150 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/GreetingController.java @@ -0,0 +1,17 @@ +package io.sentry.samples.spring.boot4.otlp.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-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/ProjectController.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/ProjectController.java new file mode 100644 index 00000000000..7ec97bc12f2 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/ProjectController.java @@ -0,0 +1,140 @@ +package io.sentry.samples.spring.boot4.otlp.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-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/TaskCreatorController.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/TaskCreatorController.java new file mode 100644 index 00000000000..94314cbfcac --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/graphql/TaskCreatorController.java @@ -0,0 +1,50 @@ +package io.sentry.samples.spring.boot4.otlp.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) + .withOptions((builder) -> builder.setBatchingEnabled(true)) + .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-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/quartz/SampleJob.java b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/quartz/SampleJob.java new file mode 100644 index 00000000000..db143d90eb6 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/java/io/sentry/samples/spring/boot4/otlp/quartz/SampleJob.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot4.otlp.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-4-otlp/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider new file mode 100644 index 00000000000..26817d24ff3 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProvider @@ -0,0 +1 @@ +io.sentry.samples.spring.boot4.otlp.SentryOtlpPropagatorProvider diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/application.properties new file mode 100644 index 00000000000..43c0bd18c08 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/application.properties @@ -0,0 +1,53 @@ +# 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 +sentry.in-app-includes="io.sentry.samples" +sentry.logs.enabled=true +sentry.profile-session-sample-rate=1.0 +sentry.profiling-traces-dir-path=tmp/sentry/profiling-traces +sentry.profile-lifecycle=TRACE + +# 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 + +# OTEL configuration +# Use Sentry propagator to propagate sentry-trace and baggage headers +otel.propagators=tracecontext,baggage,sentry +otel.logs.exporter=none +otel.metrics.exporter=none +# OTLP traces exporter configuration +# Use both otlp and logging exporters - logging prints spans to console for debugging +otel.traces.exporter=otlp,logging +otel.exporter.otlp.traces.endpoint=https://o447951.ingest.us.sentry.io/api/5428563/integration/otlp/v1/traces +otel.exporter.otlp.traces.protocol=http/protobuf +otel.exporter.otlp.traces.headers=x-sentry-auth=sentry sentry_key=502f25099c204a2fbf4cb16edc5975d1 + +# Debug logging for OTel +logging.level.io.opentelemetry=DEBUG +logging.level.io.opentelemetry.exporter=DEBUG +logging.level.io.opentelemetry.sdk.trace.export=DEBUG diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..aeea62357bd --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/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-4-otlp/src/main/resources/quartz.properties b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/quartz.properties new file mode 100644 index 00000000000..6e302ce765a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/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-4-otlp/src/main/resources/schema.sql b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/main/resources/schema.sql new file mode 100644 index 00000000000..7ca8a5cbf42 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/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-4-otlp/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/DummyTest.kt new file mode 100644 index 00000000000..6f762b7e453 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/DummyTest.kt @@ -0,0 +1,12 @@ +package io.sentry + +import kotlin.test.Test +import kotlin.test.assertTrue + +class DummyTest { + @Test + fun `the only test`() { + // only needed to have more than 0 tests and not fail the build + assertTrue(true) + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..3cd16003024 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,197 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import org.junit.Before + +class DistributedTracingSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = + restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt new file mode 100644 index 00000000000..76a6024decc --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -0,0 +1,46 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import org.junit.Before + +class GraphqlGreetingSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `greeting works`() { + val response = testHelper.graphqlClient.greet("world") + + testHelper.ensureNoErrors(response) + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Query.greeting", + ) + } + } + + @Test + fun `greeting error`() { + val response = testHelper.graphqlClient.greet("crash") + + testHelper.ensureErrorCount(response, 1) + testHelper.ensureErrorReceived { error -> + error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Query.greeting", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt new file mode 100644 index 00000000000..fca3956717c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -0,0 +1,66 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.junit.Before + +class GraphqlProjectSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `project query works`() { + val response = testHelper.graphqlClient.project("proj-slug") + + testHelper.ensureNoErrors(response) + assertEquals("proj-slug", response?.data?.project?.slug) + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Query.project", + ) + } + } + + @Test + fun `project mutation works`() { + val response = testHelper.graphqlClient.addProject("proj-slug") + + testHelper.ensureNoErrors(response) + assertNotNull(response?.data?.addProject) + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Mutation.addProject", + ) + } + } + + @Test + fun `project mutation error`() { + val response = testHelper.graphqlClient.addProject("addprojectcrash") + + testHelper.ensureErrorCount(response, 1) + assertNull(response?.data?.addProject) + testHelper.ensureErrorReceived { error -> + error.message?.message?.startsWith("Unresolved RuntimeException for executionId ") ?: false + } + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Mutation.addProject", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt new file mode 100644 index 00000000000..940709c0778 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -0,0 +1,50 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class GraphqlTaskSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `tasks and assignees query works`() { + val response = testHelper.graphqlClient.tasksAndAssignees("project-slug") + + testHelper.ensureNoErrors(response) + + assertEquals(5, response?.data?.tasks?.size) + + val firstTask = response?.data?.tasks?.firstOrNull() ?: throw RuntimeException("no task") + assertEquals("T1", firstTask.id) + assertEquals("A3", firstTask.assigneeId) + assertEquals("A3", firstTask.assignee?.id) + assertEquals("C3", firstTask.creatorId) + assertEquals("C3", firstTask.creator?.id) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Query.tasks", + ) && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Task.assignee", + ) && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "graphql", + "Task.creator", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt new file mode 100644 index 00000000000..dc2ca2a10ae --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt @@ -0,0 +1,49 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class MetricsSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `count metric`() { + val restClient = testHelper.restClient + assertEquals("count metric increased", restClient.getCountMetric()) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) + } + } + + @Test + fun `gauge metric`() { + val restClient = testHelper.restClient + assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) + } + } + + @Test + fun `distribution metric`() { + val restClient = testHelper.restClient + assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureMetricsReceived { event, header -> + testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt new file mode 100644 index 00000000000..362a8577148 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -0,0 +1,96 @@ +package io.sentry.systemtest + +import io.sentry.protocol.FeatureFlag +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class PersonSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person fails`() { + val restClient = testHelper.restClient + restClient.getPerson(1L) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Trying person with id=1" && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + testHelper.ensureErrorReceived { event -> + testHelper.doesEventHaveExceptionMessage(event, "Something went wrong [id=1]") && + testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionHave( + transaction, + op = "http.server", + featureFlag = FeatureFlag("flag.evaluation.transaction-feature-flag", true), + ) && + testHelper.doesTransactionHaveSpanWith( + transaction, + op = "spanCreatedThroughSentryApi", + featureFlag = FeatureFlag("flag.evaluation.my-feature-flag", true), + ) + } + + Thread.sleep(10000) + + testHelper.ensureLogsReceived { logs, envelopeHeader -> + testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && + testHelper.doesContainLogWithBody(logs, "error Sentry logging") && + testHelper.doesContainLogWithBody(logs, "hello there world!") + } + } + + @Test + fun `create person works`() { + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPerson(person) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "db.query", + "insert into person (firstName, lastName) values (?, ?)", + ) + } + } + + @Test + fun `create person starts a profile linked to the transaction`() { + var profilerId: SentryId? = null + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPerson(person) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + profilerId = transaction.contexts.profile?.profilerId + transaction.transaction == "POST /person/" + } + testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> + profileChunk.profilerId == profilerId + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt new file mode 100644 index 00000000000..d34485e1388 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -0,0 +1,61 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class TodoSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get todo works`() { + val restClient = testHelper.restClient + restClient.getTodo(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } + + @Test + fun `get todo webclient works`() { + val restClient = testHelper.restClient + restClient.getTodoWebclient(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } + + @Test + fun `get todo restclient works`() { + val restClient = testHelper.restClient + restClient.getTodoRestClient(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/resources/logback.xml new file mode 100644 index 00000000000..a36b8f80f76 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-otlp/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + + + + %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index fcff35af112..21d0399e54c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,6 +64,7 @@ include( "sentry-opentelemetry:sentry-opentelemetry-agent", "sentry-opentelemetry:sentry-opentelemetry-agentless", "sentry-opentelemetry:sentry-opentelemetry-agentless-spring", + "sentry-opentelemetry:sentry-opentelemetry-otlp", "sentry-quartz", "sentry-okhttp", "sentry-openfeature", @@ -94,6 +95,7 @@ include( "sentry-samples:sentry-samples-spring-boot-4", "sentry-samples:sentry-samples-spring-boot-4-opentelemetry", "sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent", + "sentry-samples:sentry-samples-spring-boot-4-otlp", "sentry-samples:sentry-samples-spring-boot-4-webflux", "sentry-samples:sentry-samples-netflix-dgs", "sentry-android-integration-tests:sentry-uitest-android-critical", From aaab229161e819620048cc15491ea54bd7216e21 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Fri, 13 Feb 2026 14:07:10 +0000 Subject: [PATCH 2/4] Format code --- .../build.gradle.kts | 8 ++++---- .../otlp/OpenTelemetryOtlpPropagator.java | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts index 5fd17c6613a..28e0820db32 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/build.gradle.kts @@ -18,8 +18,8 @@ dependencies { api(projects.sentry) compileOnly(libs.otel) -// compileOnly(libs.otel.semconv) -// compileOnly(libs.otel.semconv.incubating) + // compileOnly(libs.otel.semconv) + // compileOnly(libs.otel.semconv.incubating) compileOnly(libs.jetbrains.annotations) compileOnly(libs.nopen.annotations) @@ -35,8 +35,8 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(libs.otel) -// testImplementation(libs.otel.semconv) -// testImplementation(libs.otel.semconv.incubating) + // testImplementation(libs.otel.semconv) + // testImplementation(libs.otel.semconv.incubating) } configure { test { java.srcDir("src/test/java") } } diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java index bbbc9c42e0e..1e058fd9bd3 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java @@ -18,7 +18,6 @@ import io.sentry.SentryLevel; import io.sentry.SentryTraceHeader; import io.sentry.exception.InvalidSentryTraceHeaderException; - import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -31,7 +30,7 @@ public final class OpenTelemetryOtlpPropagator implements TextMapPropagator { Arrays.asList(SENTRY_TRACE_HEADER, BaggageHeader.BAGGAGE_HEADER); public static final @NotNull ContextKey SENTRY_BAGGAGE_KEY = - ContextKey.named("sentry.baggage"); + ContextKey.named("sentry.baggage"); private final @NotNull IScopes scopes; public OpenTelemetryOtlpPropagator() { @@ -61,7 +60,14 @@ public void inject(final Context context, final C carrier, final TextMapSett return; } - setter.set(carrier, SENTRY_TRACE_HEADER, otelSpanContext.getTraceId() + "-" + otelSpanContext.getSpanId() + "-" + (otelSpanContext.isSampled() ? "1" : "0")); + setter.set( + carrier, + SENTRY_TRACE_HEADER, + otelSpanContext.getTraceId() + + "-" + + otelSpanContext.getSpanId() + + "-" + + (otelSpanContext.isSampled() ? "1" : "0")); final @Nullable Baggage baggage = context.get(SENTRY_BAGGAGE_KEY); if (baggage != null) { @@ -72,8 +78,7 @@ public void inject(final Context context, final C carrier, final TextMapSett @Override public Context extract( final Context context, final C carrier, final TextMapGetter getter) { - final @Nullable String sentryTraceString = - getter.get(carrier, SENTRY_TRACE_HEADER); + final @Nullable String sentryTraceString = getter.get(carrier, SENTRY_TRACE_HEADER); if (sentryTraceString == null) { return context; } @@ -95,9 +100,7 @@ public Context extract( Span wrappedSpan = Span.wrap(otelSpanContext); final @NotNull Context modifiedContext = - context - .with(wrappedSpan) - .with(SENTRY_BAGGAGE_KEY, baggage); + context.with(wrappedSpan).with(SENTRY_BAGGAGE_KEY, baggage); scopes .getOptions() From 476d7f139707966564571f0d6530ea157f2fe827 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 13 Feb 2026 15:26:56 +0100 Subject: [PATCH 3/4] also set trace id and span id for logs and metrics --- .../otlp/OpenTelemetryOtlpEventProcessor.java | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java index 2aefe25191e..e8b9f431c96 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java @@ -9,6 +9,8 @@ import io.sentry.ScopesAdapter; import io.sentry.SentryEvent; import io.sentry.SentryLevel; +import io.sentry.SentryLogEvent; +import io.sentry.SentryMetricsEvent; import io.sentry.SpanContext; import io.sentry.protocol.SentryId; import org.jetbrains.annotations.NotNull; @@ -39,8 +41,8 @@ public OpenTelemetryOtlpEventProcessor() { new SpanContext( new SentryId(traceId), new io.sentry.SpanId(spanId), - "opentelemetry", // TODO probably no way to get span name - null, // TODO where to get parent id from? + "opentelemetry", + null, null); event.getContexts().setTrace(spanContext); @@ -68,6 +70,52 @@ public OpenTelemetryOtlpEventProcessor() { return event; } + @Override + public @Nullable SentryLogEvent process(@NotNull SentryLogEvent event) { + @NotNull final Span otelSpan = Span.current(); + @NotNull final String traceId = otelSpan.getSpanContext().getTraceId(); + @NotNull final String spanId = otelSpan.getSpanContext().getSpanId(); + + if (TraceId.isValid(traceId) && SpanId.isValid(spanId)) { + event.setTraceId(new SentryId(traceId)); + event.setSpanId(new io.sentry.SpanId(spanId)); + } else { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not linking Sentry event to any transaction created via OpenTelemetry as traceId %s or spanId %s are invalid.", + traceId, + spanId); + } + + return event; + } + + @Override + public @Nullable SentryMetricsEvent process(@NotNull SentryMetricsEvent event, @NotNull Hint hint) { + @NotNull final Span otelSpan = Span.current(); + @NotNull final String traceId = otelSpan.getSpanContext().getTraceId(); + @NotNull final String spanId = otelSpan.getSpanContext().getSpanId(); + + if (TraceId.isValid(traceId) && SpanId.isValid(spanId)) { + event.setTraceId(new SentryId(traceId)); + event.setSpanId(new io.sentry.SpanId(spanId)); + } else { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not linking Sentry event to any transaction created via OpenTelemetry as traceId %s or spanId %s are invalid.", + traceId, + spanId); + } + + return event; + } + @Override public @Nullable Long getOrder() { return 6000L; From 4beac3001899585eabf53523d67c627edab94042 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Fri, 13 Feb 2026 14:33:34 +0000 Subject: [PATCH 4/4] Format code --- .../otlp/OpenTelemetryOtlpEventProcessor.java | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java index e8b9f431c96..ad8b672c7de 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor.java @@ -39,11 +39,7 @@ public OpenTelemetryOtlpEventProcessor() { if (TraceId.isValid(traceId) && SpanId.isValid(spanId)) { final @NotNull SpanContext spanContext = new SpanContext( - new SentryId(traceId), - new io.sentry.SpanId(spanId), - "opentelemetry", - null, - null); + new SentryId(traceId), new io.sentry.SpanId(spanId), "opentelemetry", null, null); event.getContexts().setTrace(spanContext); scopes @@ -81,20 +77,21 @@ public OpenTelemetryOtlpEventProcessor() { event.setSpanId(new io.sentry.SpanId(spanId)); } else { scopes - .getOptions() - .getLogger() - .log( - SentryLevel.DEBUG, - "Not linking Sentry event to any transaction created via OpenTelemetry as traceId %s or spanId %s are invalid.", - traceId, - spanId); + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not linking Sentry event to any transaction created via OpenTelemetry as traceId %s or spanId %s are invalid.", + traceId, + spanId); } return event; } @Override - public @Nullable SentryMetricsEvent process(@NotNull SentryMetricsEvent event, @NotNull Hint hint) { + public @Nullable SentryMetricsEvent process( + @NotNull SentryMetricsEvent event, @NotNull Hint hint) { @NotNull final Span otelSpan = Span.current(); @NotNull final String traceId = otelSpan.getSpanContext().getTraceId(); @NotNull final String spanId = otelSpan.getSpanContext().getSpanId(); @@ -104,13 +101,13 @@ public OpenTelemetryOtlpEventProcessor() { event.setSpanId(new io.sentry.SpanId(spanId)); } else { scopes - .getOptions() - .getLogger() - .log( - SentryLevel.DEBUG, - "Not linking Sentry event to any transaction created via OpenTelemetry as traceId %s or spanId %s are invalid.", - traceId, - spanId); + .getOptions() + .getLogger() + .log( + SentryLevel.DEBUG, + "Not linking Sentry event to any transaction created via OpenTelemetry as traceId %s or spanId %s are invalid.", + traceId, + spanId); } return event;