From fb8fd8390920b3546daa648b743b7c5878efd7e9 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 13 Feb 2026 15:00:51 +0100 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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; From 2283547a5806536e7221f23d98265cfc38ba849d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 16 Feb 2026 16:18:22 +0100 Subject: [PATCH 5/5] add console otlp sample; some missing things; cleanup --- .craft.yml | 1 + .cursor/rules/opentelemetry.mdc | 7 + .github/workflows/system-tests-backend.yml | 6 + README.md | 1 + build.gradle.kts | 3 +- gradle/libs.versions.toml | 2 + .../sentry-opentelemetry-otlp/README.md | 7 + .../api/sentry-opentelemetry-core.api | 175 ------ .../api/sentry-opentelemetry-otlp.api | 16 + .../OpenTelemetryAttributesExtractorTest.kt | 386 ------------- .../OtelInternalSpanDetectionUtilTest.kt | 188 ------- .../test/kotlin/OtelSentryPropagatorTest.kt | 273 +-------- .../test/kotlin/SentrySpanProcessorTest.kt | 525 ------------------ .../kotlin/SpanDescriptionExtractorTest.kt | 297 ---------- .../sentry-samples-console-otlp/README.md | 13 + .../api/sentry-samples-console-otlp.api | 5 + .../build.gradle.kts | 90 +++ .../java/io/sentry/samples/console/Main.java | 248 +++++++++ .../src/test/kotlin/io/sentry/DummyTest.kt | 12 + .../ConsoleApplicationSystemTest.kt | 112 ++++ settings.gradle.kts | 1 + test/system-test-runner.py | 2 + 22 files changed, 547 insertions(+), 1823 deletions(-) create mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/README.md delete mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-core.api create mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-otlp.api delete mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt delete mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt delete mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SentrySpanProcessorTest.kt delete mode 100644 sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SpanDescriptionExtractorTest.kt create mode 100644 sentry-samples/sentry-samples-console-otlp/README.md create mode 100644 sentry-samples/sentry-samples-console-otlp/api/sentry-samples-console-otlp.api create mode 100644 sentry-samples/sentry-samples-console-otlp/build.gradle.kts create mode 100644 sentry-samples/sentry-samples-console-otlp/src/main/java/io/sentry/samples/console/Main.java create mode 100644 sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/DummyTest.kt create mode 100644 sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt diff --git a/.craft.yml b/.craft.yml index 6f52255dd63..d693a25628e 100644 --- a/.craft.yml +++ b/.craft.yml @@ -48,6 +48,7 @@ targets: maven:io.sentry:sentry-opentelemetry-agentless-spring: maven:io.sentry:sentry-opentelemetry-bootstrap: maven:io.sentry:sentry-opentelemetry-core: +# maven:io.sentry:sentry-opentelemetry-otlp: maven:io.sentry:sentry-apollo: maven:io.sentry:sentry-jdbc: maven:io.sentry:sentry-graphql: diff --git a/.cursor/rules/opentelemetry.mdc b/.cursor/rules/opentelemetry.mdc index 7a94dcf58f4..7d9ea462b12 100644 --- a/.cursor/rules/opentelemetry.mdc +++ b/.cursor/rules/opentelemetry.mdc @@ -14,6 +14,7 @@ The Sentry Java SDK provides comprehensive OpenTelemetry integration through mul - `sentry-opentelemetry-agentless-spring`: Spring-specific agentless integration - `sentry-opentelemetry-bootstrap`: Classes that go into the bootstrap classloader when the agent is used. For agentless they are simply used in the applications classloader. - `sentry-opentelemetry-agentcustomization`: Classes that help wire up Sentry in OpenTelemetry. These land in the agent classloader when the agent is used. For agentless they are simply used in the application classloader. +- `sentry-opentelemetry-otlp`: Classes for using OpenTelemetry to send spans to Sentry using the OTLP endpoint and have Sentry use OpenTelemetry trace and span id. ## Advantages over using Sentry without OpenTelemetry @@ -86,3 +87,9 @@ After creating the transaction with child spans `SentrySpanExporter` uses Sentry ## Troubleshooting To debug forking of `Scopes`, we added a reference to `parent` `Scopes` and a `creator` String to store the reason why `Scopes` were created or forked. + +# OTLP +When using `sentry-opentelemetry-otlp`, Sentry only loads trace ID and span ID from OpenTelemetry `Context` (via `OpenTelemetryOtlpEventProcessor`). Sentry does not rely on OpenTelemetry `Context` for scope storage and propagation, instead relying on its `DefaultScopesStorage`. +It is common to keep Performance in Sentry SDK disabled since that part is taken over by OpenTelemetry. +The `sentry-opentelemetry-otlp` module is not connected to the other `sentry-opentelemetry-*` modules but instead intended only when the goal is to run OpenTelemetry for creating spans and Sentry for other products like errors, logs, metrics etc. +The OTLP module does not easily work with the OpenTelemetry agent as it would require customizing the agent.JAR in order to get the propagator loaded. diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 1e668577c93..641b49f6c85 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -54,6 +54,9 @@ jobs: - sample: "sentry-samples-console" agent: "false" agent-auto-init: "true" + - sample: "sentry-samples-console-otlp" + agent: "false" + agent-auto-init: "true" - sample: "sentry-samples-logback" agent: "false" agent-auto-init: "true" @@ -78,6 +81,9 @@ jobs: - sample: "sentry-samples-spring-boot-4-opentelemetry" agent: "true" agent-auto-init: "false" + - sample: "sentry-samples-spring-boot-4-otlp" + agent: "false" + agent-auto-init: "true" - sample: "sentry-samples-spring-7" agent: "false" agent-auto-init: "true" diff --git a/README.md b/README.md index ee32d485f42..5a013c5a144 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Sentry SDK for Java and Android | sentry-opentelemetry-agent | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-agent?style=for-the-badge&logo=sentry&color=green) | | sentry-opentelemetry-agentcustomization | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-agentcustomization?style=for-the-badge&logo=sentry&color=green) | | sentry-opentelemetry-core | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-core?style=for-the-badge&logo=sentry&color=green) | +| sentry-opentelemetry-otlp | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-opentelemetry-otlp?style=for-the-badge&logo=sentry&color=green) | | sentry-okhttp | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-okhttp?style=for-the-badge&logo=sentry&color=green) | | sentry-reactor | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-reactor?style=for-the-badge&logo=sentry&color=green) | | sentry-spotlight | ![Maven Central Version](https://img.shields.io/maven-central/v/io.sentry/sentry-spotlight?style=for-the-badge&logo=sentry&color=green) | diff --git a/build.gradle.kts b/build.gradle.kts index 5b02df71a0f..376d0652832 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -86,7 +86,8 @@ apiValidation { "test-app-plain", "test-app-sentry", "test-app-size", - "sentry-samples-netflix-dgs" + "sentry-samples-netflix-dgs", + "sentry-samples-console-otlp" ) ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e8e498c23b..34c88543068 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -134,6 +134,8 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttp" } openfeature = { module = "dev.openfeature:sdk", version.ref = "openfeature" } otel = { module = "io.opentelemetry:opentelemetry-sdk", version.ref = "otel" } +otel-exporter-otlp = { module = "io.opentelemetry:opentelemetry-exporter-otlp", version.ref = "otel" } +otel-exporter-logging = { module = "io.opentelemetry:opentelemetry-exporter-logging", version.ref = "otel" } otel-extension-autoconfigure = { module = "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure", version.ref = "otel" } otel-extension-autoconfigure-spi = { module = "io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi", version.ref = "otel" } otel-instrumentation-bom = { module = "io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom", version.ref = "otelInstrumentation" } diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/README.md b/sentry-opentelemetry/sentry-opentelemetry-otlp/README.md new file mode 100644 index 00000000000..c729ce27629 --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/README.md @@ -0,0 +1,7 @@ +# sentry-opentelemetry-otlp + +This module provides a lightweight integration for using OpenTelemetry alongside the Sentry SDK. It reads trace and span IDs from the OpenTelemetry `Context` so that Sentry events (errors, logs, metrics) are correlated with OpenTelemetry traces. + +Unlike the other `sentry-opentelemetry-*` modules, this module does not rely on OpenTelemetry for scope storage or span creation. It is intended for setups where OpenTelemetry handles performance/tracing and Sentry handles errors, logs, metrics, and other products. + +Please consult the documentation on how to install and use this integration in the [Sentry Docs for Java](https://docs.sentry.io/platforms/java/). diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-core.api b/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-core.api deleted file mode 100644 index b51c8cc39bc..00000000000 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-core.api +++ /dev/null @@ -1,175 +0,0 @@ -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/api/sentry-opentelemetry-otlp.api b/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-otlp.api new file mode 100644 index 00000000000..06ed41ee88f --- /dev/null +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/api/sentry-opentelemetry-otlp.api @@ -0,0 +1,16 @@ +public final class io/sentry/opentelemetry/otlp/OpenTelemetryOtlpEventProcessor : 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 fun process (Lio/sentry/SentryLogEvent;)Lio/sentry/SentryLogEvent; + public fun process (Lio/sentry/SentryMetricsEvent;Lio/sentry/Hint;)Lio/sentry/SentryMetricsEvent; +} + +public final class io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator : io/opentelemetry/context/propagation/TextMapPropagator { + public static final field SENTRY_BAGGAGE_KEY Lio/opentelemetry/context/ContextKey; + 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 +} + diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt deleted file mode 100644 index 5cc37d80f9c..00000000000 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OpenTelemetryAttributesExtractorTest.kt +++ /dev/null @@ -1,386 +0,0 @@ -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 deleted file mode 100644 index bc453be6c1a..00000000000 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelInternalSpanDetectionUtilTest.kt +++ /dev/null @@ -1,188 +0,0 @@ -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 index 2315412fd46..3dc386fd20c 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt +++ b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/OtelSentryPropagatorTest.kt @@ -1,6 +1,5 @@ -package io.sentry.opentelemetry +package io.sentry.opentelemetry.otlp -import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.trace.Span import io.opentelemetry.api.trace.SpanContext import io.opentelemetry.api.trace.TraceFlags @@ -8,47 +7,30 @@ 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.Baggage 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() +class OpenTelemetryOtlpPropagatorTest { @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() + val propagator = OpenTelemetryOtlpPropagator() assertEquals(listOf("sentry-trace", "baggage"), propagator.fields()) } @Test fun `invalid sentry trace header returns context without modification`() { - val propagator = OtelSentryPropagator() + val propagator = OpenTelemetryOtlpPropagator() val carrier: Map = mapOf( "sentry-trace" to "wrong", @@ -57,21 +39,15 @@ class OtelSentryPropagatorTest { ) val scopeInContext = Sentry.forkedRootScopes("test") - val newContext = - propagator.extract( - Context.root().with(SENTRY_SCOPES_KEY, scopeInContext), - carrier, - MapGetter(), - ) + val newContext = propagator.extract(Context.root(), carrier, MapGetter()) - val scopes = newContext.get(SENTRY_SCOPES_KEY) - assertNotNull(scopes) - assertSame(scopeInContext, scopes) + val baggage = newContext.get(OpenTelemetryOtlpPropagator.SENTRY_BAGGAGE_KEY) + assertNull(baggage) } @Test fun `uses incoming headers`() { - val propagator = OtelSentryPropagator() + val propagator = OpenTelemetryOtlpPropagator() val carrier: Map = mapOf( "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", @@ -85,30 +61,17 @@ class OtelSentryPropagatorTest { 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), + newContext.get(OpenTelemetryOtlpPropagator.SENTRY_BAGGAGE_KEY)?.toHeaderString(null), ) } @Test - fun `injects headers if no URL`() { - val propagator = OtelSentryPropagator() + fun `injects headers`() { + val propagator = OpenTelemetryOtlpPropagator() 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", @@ -117,44 +80,18 @@ class OtelSentryPropagatorTest { 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" + val context = + Context.root() + .with(otelSpan) + .with( + OpenTelemetryOtlpPropagator.SENTRY_BAGGAGE_KEY, + Baggage.fromHeader( + "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()) + propagator.inject(context, carrier, MapSetter()) assertEquals("f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", carrier["sentry-trace"]) assertEquals( @@ -163,175 +100,9 @@ class OtelSentryPropagatorTest { ) } - @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 propagator = OpenTelemetryOtlpPropagator() val carrier = mutableMapOf() propagator.inject(Context.root().with(Span.getInvalid()), carrier, MapSetter()) diff --git a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SentrySpanProcessorTest.kt b/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SentrySpanProcessorTest.kt deleted file mode 100644 index df2cf9596dd..00000000000 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SentrySpanProcessorTest.kt +++ /dev/null @@ -1,525 +0,0 @@ -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 deleted file mode 100644 index af04914e278..00000000000 --- a/sentry-opentelemetry/sentry-opentelemetry-otlp/src/test/kotlin/SpanDescriptionExtractorTest.kt +++ /dev/null @@ -1,297 +0,0 @@ -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-console-otlp/README.md b/sentry-samples/sentry-samples-console-otlp/README.md new file mode 100644 index 00000000000..a1e7314fdac --- /dev/null +++ b/sentry-samples/sentry-samples-console-otlp/README.md @@ -0,0 +1,13 @@ +# Sentry Sample Console OTLP + +Sample application showing how to use Sentry with OTLP without any framework integration. + +## How to run? + +To see events triggered in this sample application in your Sentry dashboard, go to `src/main/java/io/sentry/samples/console/Main.java` and replace the test DSN with your own DSN. + +Then, execute a command from the module directory: + +``` +../../gradlew run +``` diff --git a/sentry-samples/sentry-samples-console-otlp/api/sentry-samples-console-otlp.api b/sentry-samples/sentry-samples-console-otlp/api/sentry-samples-console-otlp.api new file mode 100644 index 00000000000..867869223ea --- /dev/null +++ b/sentry-samples/sentry-samples-console-otlp/api/sentry-samples-console-otlp.api @@ -0,0 +1,5 @@ +public class io/sentry/samples/console/Main { + public fun ()V + public static fun main ([Ljava/lang/String;)V +} + diff --git a/sentry-samples/sentry-samples-console-otlp/build.gradle.kts b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts new file mode 100644 index 00000000000..f944d24bb99 --- /dev/null +++ b/sentry-samples/sentry-samples-console-otlp/build.gradle.kts @@ -0,0 +1,90 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + java + application + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.gradle.versions) + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +application { mainClass.set("io.sentry.samples.console.Main") } + +java.sourceCompatibility = JavaVersion.VERSION_17 + +java.targetCompatibility = JavaVersion.VERSION_17 + +repositories { mavenCentral() } + +configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 +} + +tasks.withType().configureEach { + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + implementation(projects.sentryOpentelemetry.sentryOpentelemetryOtlp) + implementation(projects.sentryAsyncProfiler) + implementation(libs.otel) + implementation(libs.otel.semconv) + implementation(libs.otel.semconv.incubating) + implementation(libs.otel.extension.autoconfigure) + implementation(libs.otel.exporter.otlp) + implementation(libs.otel.exporter.logging) + + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(projects.sentry) + testImplementation(projects.sentrySystemTestSupport) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.slf4j.api) + testImplementation(libs.slf4j.jdk14) +} + +// Configure the Shadow JAR (executable JAR with all dependencies) +tasks.shadowJar { + manifest { attributes["Main-Class"] = "io.sentry.samples.console.Main" } + archiveClassifier.set("") // Remove the classifier so it replaces the regular JAR + mergeServiceFiles() +} + +// Make the regular jar task depend on shadowJar +tasks.jar { + enabled = false + dependsOn(tasks.shadowJar) +} + +// Fix the startScripts task dependency +tasks.startScripts { dependsOn(tasks.shadowJar) } + +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-console-otlp/src/main/java/io/sentry/samples/console/Main.java b/sentry-samples/sentry-samples-console-otlp/src/main/java/io/sentry/samples/console/Main.java new file mode 100644 index 00000000000..49f1dd30a0c --- /dev/null +++ b/sentry-samples/sentry-samples-console-otlp/src/main/java/io/sentry/samples/console/Main.java @@ -0,0 +1,248 @@ +package io.sentry.samples.console; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; +import io.opentelemetry.context.propagation.TextMapPropagator; +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk; +import io.sentry.*; +import io.sentry.clientreport.DiscardReason; +import io.sentry.opentelemetry.otlp.OpenTelemetryOtlpEventProcessor; +import io.sentry.opentelemetry.otlp.OpenTelemetryOtlpPropagator; +import io.sentry.protocol.Message; +import io.sentry.protocol.User; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class Main { + + private static long numberOfDiscardedSpansDueToOverflow = 0; + + public static void main(String[] args) throws InterruptedException { + // Configure OpenTelemetry SDK with Sentry OTLP propagator + AutoConfiguredOpenTelemetrySdk.builder() + .setResultAsGlobal() + .addPropertiesSupplier( + () -> { + final Map properties = new HashMap<>(); + properties.put("otel.logs.exporter", "none"); + properties.put("otel.metrics.exporter", "none"); + // OTLP traces exporter configuration + properties.put("otel.traces.exporter", "otlp,logging"); + properties.put( + "otel.exporter.otlp.traces.endpoint", + "https://o447951.ingest.us.sentry.io/api/5428563/integration/otlp/v1/traces"); + properties.put("otel.exporter.otlp.traces.protocol", "http/protobuf"); + properties.put( + "otel.exporter.otlp.traces.headers", + "x-sentry-auth=sentry sentry_key=502f25099c204a2fbf4cb16edc5975d1"); + return properties; + }) + .addPropagatorCustomizer( + (propagator, config) -> + TextMapPropagator.composite(propagator, new OpenTelemetryOtlpPropagator())) + .build(); + + Sentry.init( + options -> { + // NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in + // your Sentry project/dashboard + options.setEnableExternalConfiguration(true); + options.setDsn( + "https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563"); + + // All events get assigned to the release. See more at + // https://docs.sentry.io/workflow/releases/ + options.setRelease("io.sentry.samples.console@3.0.0+1"); + + // Link Sentry events to OpenTelemetry spans + options.addEventProcessor(new OpenTelemetryOtlpEventProcessor()); + + // Modifications to event before it goes out. Could replace the event altogether + options.setBeforeSend( + (event, hint) -> { + // Drop an event altogether: + if (event.getTag("SomeTag") != null) { + return null; + } + return event; + }); + + options.setBeforeSendTransaction( + (transaction, hint) -> { + // Drop a transaction: + if (transaction.getTag("SomeTransactionTag") != null) { + return null; + } + + return transaction; + }); + + // Allows inspecting and modifying, returning a new or simply rejecting (returning null) + options.setBeforeBreadcrumb( + (breadcrumb, hint) -> { + // Don't add breadcrumbs with message containing: + if (breadcrumb.getMessage() != null + && breadcrumb.getMessage().contains("bad breadcrumb")) { + return null; + } + return breadcrumb; + }); + + // Record data being discarded, including the reason, type of data, and the number of + // items dropped + options.setOnDiscard( + (reason, category, number) -> { + // Only record the number of lost spans due to overflow conditions + if ((reason.equals(DiscardReason.CACHE_OVERFLOW) + || reason.equals(DiscardReason.QUEUE_OVERFLOW)) + && category.equals(DataCategory.Span)) { + numberOfDiscardedSpansDueToOverflow += number; + } + }); + + // Configure the background worker which sends events to sentry: + // Wait up to 5 seconds before shutdown while there are events to send. + options.setShutdownTimeoutMillis(5000); + + // Enable SDK logging with Debug level + options.setDebug(true); + // To change the verbosity, use: + // By default it's DEBUG. + // options.setDiagnosticLevel(SentryLevel.ERROR); + // A good option to have SDK debug log in prod is to use only level ERROR here. + + // Exclude frames from some packages from being "inApp" so are hidden by default in Sentry + // UI: + options.addInAppExclude("org.jboss"); + + // Include frames from our package + options.addInAppInclude("io.sentry.samples"); + + // Performance configuration options + // Set what percentage of traces should be collected + // options.setTracesSampleRate(1.0); // set 0.5 to send 50% of traces + + // Determine traces sample rate based on the sampling context + // options.setTracesSampler( + // context -> { + // // only 10% of transactions with "/product" prefix will be collected + // if (!context.getTransactionContext().getName().startsWith("/products")) + // { + // return 0.1; + // } else { + // return 0.5; + // } + // }); + + options.getLogs().setEnabled(true); + }); + + Sentry.addBreadcrumb( + "A 'bad breadcrumb' that will be rejected because of 'BeforeBreadcrumb callback above.'"); + + // Data added to the root scope (no PushScope called up to this point) + // The modifications done here will affect all events sent and will propagate to child scopes. + Sentry.configureScope( + scope -> { + scope.addEventProcessor(new SomeEventProcessor()); + + scope.setExtra("SomeExtraInfo", "Some value for extra info"); + }); + + // Configures a scope which is only valid within the callback + Sentry.withScope( + scope -> { + scope.setLevel(SentryLevel.FATAL); + scope.setTransaction("main"); + + // This message includes the data set to the scope in this block: + Sentry.captureMessage("Fatal message!"); + }); + + // Only data added to the scope on `configureScope` above is included. + Sentry.captureMessage("Some warning!", SentryLevel.WARNING); + + Sentry.addFeatureFlag("my-feature-flag", true); + + captureMetrics(); + + // Sending exception: + Exception exception = new RuntimeException("Some error!"); + Sentry.captureException(exception); + + // An event with breadcrumb and user data + SentryEvent evt = new SentryEvent(); + Message msg = new Message(); + msg.setMessage("Detailed event"); + evt.setMessage(msg); + evt.addBreadcrumb("Breadcrumb directly to the event"); + User user = new User(); + user.setUsername("some@user"); + evt.setUser(user); + // Group all events with the following fingerprint: + evt.setFingerprints(Collections.singletonList("NewClientDebug")); + evt.setLevel(SentryLevel.DEBUG); + Sentry.captureEvent(evt); + + int count = 10; + for (int i = 0; i < count; i++) { + String messageContent = "%d of %d items we'll wait to flush to Sentry!"; + Message message = new Message(); + message.setMessage(messageContent); + message.setFormatted(String.format(messageContent, i, count)); + SentryEvent event = new SentryEvent(); + event.setMessage(message); + + final Hint hint = new Hint(); + hint.set("level", SentryLevel.DEBUG); + Sentry.captureEvent(event, hint); + } + + // Create an OpenTelemetry span that will be linked to the Sentry trace + Span otelSpan = + GlobalOpenTelemetry.get() + .getTracer("demoTracer", "1.0.0") + .spanBuilder("otelSpan") + .startSpan(); + try (Scope innerScope = otelSpan.makeCurrent()) { + otelSpan.setAttribute("otel-attribute", "attribute-value"); + + // Every SentryEvent reported during the execution of the transaction or a span, will have + // trace + // context attached + Sentry.captureMessage("this message is connected to the outerSpan"); + + Sentry.logger().error("Some error log message"); + Sentry.metrics().count("invocations"); + + otelSpan.setStatus(StatusCode.OK); + } finally { + otelSpan.end(); + } + + // All events that have not been sent yet are being flushed on JVM exit. Events can be also + // flushed manually: + // Sentry.close(); + } + + private static void captureMetrics() { + Sentry.metrics().count("countMetric"); + Sentry.metrics().gauge("gaugeMetric", 5.0); + Sentry.metrics().distribution("distributionMetric", 7.0); + } + + private static class SomeEventProcessor implements EventProcessor { + @Override + public SentryEvent process(SentryEvent event, Hint hint) { + // Here you can modify the event as you need + if (event.getLevel() != null && event.getLevel().ordinal() > SentryLevel.INFO.ordinal()) { + event.addBreadcrumb(new Breadcrumb("Processed by " + SomeEventProcessor.class)); + } + + return event; + } + } +} diff --git a/sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/DummyTest.kt new file mode 100644 index 00000000000..6f762b7e453 --- /dev/null +++ b/sentry-samples/sentry-samples-console-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-console-otlp/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt b/sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt new file mode 100644 index 00000000000..0e60c473f5b --- /dev/null +++ b/sentry-samples/sentry-samples-console-otlp/src/test/kotlin/io/sentry/systemtest/ConsoleApplicationSystemTest.kt @@ -0,0 +1,112 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ConsoleApplicationSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8000") + testHelper.reset() + } + + @Test + fun `console application sends expected events when run as JAR`() { + val jarFile = testHelper.findJar("sentry-samples-console-otlp") + val process = + testHelper.launch( + jarFile, + mapOf( + "SENTRY_DSN" to testHelper.dsn, + "SENTRY_TRACES_SAMPLE_RATE" to "1.0", + "SENTRY_ENABLE_PRETTY_SERIALIZATION_OUTPUT" to "false", + "SENTRY_DEBUG" to "true", + "SENTRY_PROFILE_SESSION_SAMPLE_RATE" to "1.0", + "SENTRY_PROFILE_LIFECYCLE" to "TRACE", + ), + ) + + process.waitFor(30, TimeUnit.SECONDS) + assertEquals(0, process.exitValue()) + + // Verify that we received the expected events + verifyExpectedEvents() + } + + private fun verifyExpectedEvents() { + var profilerId: SentryId? = null + // Verify we received a "Fatal message!" event + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Fatal message!" && event.level?.name == "FATAL" + } + + // Verify we received a "Some warning!" event + testHelper.ensureErrorReceived { event -> + event.message?.formatted == "Some warning!" && event.level?.name == "WARNING" + } + + // Verify we received the RuntimeException + testHelper.ensureErrorReceived { event -> + event.exceptions?.any { ex -> ex.type == "RuntimeException" && ex.value == "Some error!" } == + true && testHelper.doesEventHaveFlag(event, "my-feature-flag", true) + } + + // Verify we received the detailed event with fingerprint + testHelper.ensureErrorReceived { event -> + event.message?.message == "Detailed event" && + event.fingerprints?.contains("NewClientDebug") == true && + event.level?.name == "DEBUG" + } + + // Verify we received transaction events + testHelper.ensureTransactionReceived { transaction, _ -> + profilerId = transaction.contexts.profile?.profilerId + transaction.transaction == "transaction name" && + transaction.spans?.any { span -> span.op == "child" } == true + } + + testHelper.ensureProfileChunkReceived { profileChunk, envelopeHeader -> + profileChunk.profilerId == profilerId + } + + // Verify we received the loop messages (should be 10 of them) + var loopMessageCount = 0 + try { + for (i in 0..9) { + testHelper.ensureErrorReceived { event -> + val matches = + event.message?.message?.contains("items we'll wait to flush to Sentry!") == true + if (matches) loopMessageCount++ + matches + } + } + } catch (e: Exception) { + // Some loop messages might be missing, but we should have at least some + } + + assertTrue( + "Should receive at least 5 loop messages, got $loopMessageCount", + loopMessageCount >= 5, + ) + + // Verify we have breadcrumbs + testHelper.ensureErrorReceived { event -> + event.breadcrumbs?.any { breadcrumb -> + breadcrumb.message?.contains("Processed by") == true + } == true + } + + testHelper.ensureMetricsReceived { metricsEvents, sentryEnvelopeHeader -> + testHelper.doesContainMetric(metricsEvents, "countMetric", "counter", 1.0) && + testHelper.doesContainMetric(metricsEvents, "gaugeMetric", "gauge", 5.0) && + testHelper.doesContainMetric(metricsEvents, "distributionMetric", "distribution", 7.0) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 21d0399e54c..75f9640c5d5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -75,6 +75,7 @@ include( "sentry-ktor-client", "sentry-samples:sentry-samples-android", "sentry-samples:sentry-samples-console", + "sentry-samples:sentry-samples-console-otlp", "sentry-samples:sentry-samples-console-opentelemetry-noagent", "sentry-samples:sentry-samples-jul", "sentry-samples:sentry-samples-ktor-client", diff --git a/test/system-test-runner.py b/test/system-test-runner.py index 1188e6efbc4..55a1136fbe0 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -733,7 +733,9 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring-boot-4-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry", "true", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry", "true", "false", "false"), + ModuleConfig("sentry-samples-spring-boot-4-otlp", "false", "true", "false"), ModuleConfig("sentry-samples-console", "false", "true", "false"), + ModuleConfig("sentry-samples-console-otlp", "false", "true", "false"), ModuleConfig("sentry-samples-console-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-logback", "false", "true", "false"), ModuleConfig("sentry-samples-log4j2", "false", "true", "false"),