diff --git a/.github/instructions/java-tests.instructions.md b/.github/instructions/java-tests.instructions.md index a0aeaae62d92..f0b4eb0d466a 100644 --- a/.github/instructions/java-tests.instructions.md +++ b/.github/instructions/java-tests.instructions.md @@ -66,3 +66,11 @@ reserved). Use `v` only for a nested inner-lambda parameter. This guidance applies only to attribute-assertion `satisfies(...)`; for `span.satisfies(...)`, `point.satisfies(...)`, etc. use a descriptive name (`spanData`, `pointData`, `result`). + +It also applies only to lambdas written **directly inline** as the +`satisfies(AttributeKey, lambda)` argument, where the attribute key already +documents what is being asserted. Do **not** flag lambdas passed to a custom +helper method (e.g. `assertExceptionLog(typeAssertion, messageAssertion)`), +even though the parameter is the same `AbstractStringAssert` type. There a +descriptive name documents which attribute each lambda asserts, and renaming +multiple parameters to `val` loses that context. diff --git a/declarative-config-bridge/src/main/java/io/opentelemetry/instrumentation/config/bridge/ConfigPropertiesBackedDeclarativeConfigProperties.java b/declarative-config-bridge/src/main/java/io/opentelemetry/instrumentation/config/bridge/ConfigPropertiesBackedDeclarativeConfigProperties.java index 4e146745fa5d..eb3d0d63003b 100644 --- a/declarative-config-bridge/src/main/java/io/opentelemetry/instrumentation/config/bridge/ConfigPropertiesBackedDeclarativeConfigProperties.java +++ b/declarative-config-bridge/src/main/java/io/opentelemetry/instrumentation/config/bridge/ConfigPropertiesBackedDeclarativeConfigProperties.java @@ -51,6 +51,8 @@ public final class ConfigPropertiesBackedDeclarativeConfigProperties "general.sanitization.url.sensitive_query_parameters/development", "otel.instrumentation.sanitization.url.experimental.sensitive-query-parameters"); SPECIAL_MAPPINGS.put("general.semconv_stability.opt_in", "otel.semconv-stability.opt-in"); + SPECIAL_MAPPINGS.put( + "general.semconv_exception.signal.preview", "otel.semconv.exception.signal.preview"); // moving common http, database, messaging, and gen_ai configs under common SPECIAL_MAPPINGS.put( "java.common.http.known_methods", "otel.instrumentation.http.known-methods"); diff --git a/instrumentation-api-incubator/build.gradle.kts b/instrumentation-api-incubator/build.gradle.kts index 2a0d8dc8bd50..96d124fdd43a 100644 --- a/instrumentation-api-incubator/build.gradle.kts +++ b/instrumentation-api-incubator/build.gradle.kts @@ -102,7 +102,21 @@ tasks { inputs.dir(jflexOutputDir) } + val testExceptionSignalLogs by registering(Test::class) { + testClassesDirs = sourceSets.test.get().output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + jvmArgs("-Dotel.semconv.exception.signal.preview=logs") + inputs.dir(jflexOutputDir) + } + + val testExceptionSignalLogsDup by registering(Test::class) { + testClassesDirs = sourceSets.test.get().output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + jvmArgs("-Dotel.semconv.exception.signal.preview=logs/dup") + inputs.dir(jflexOutputDir) + } + check { - dependsOn(testStableSemconv, testBothSemconv) + dependsOn(testStableSemconv, testBothSemconv, testExceptionSignalLogs, testExceptionSignalLogsDup) } } diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/builder/internal/DefaultHttpClientInstrumenterBuilder.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/builder/internal/DefaultHttpClientInstrumenterBuilder.java index c3e7dee0ae7a..880e9b33bdb4 100644 --- a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/builder/internal/DefaultHttpClientInstrumenterBuilder.java +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/builder/internal/DefaultHttpClientInstrumenterBuilder.java @@ -9,6 +9,7 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.logs.Severity; import io.opentelemetry.context.Context; import io.opentelemetry.context.propagation.TextMapSetter; import io.opentelemetry.instrumentation.api.incubator.config.internal.CommonConfig; @@ -225,7 +226,8 @@ public Instrumenter build() { .addAttributesExtractor(httpAttributesExtractorBuilder.build()) .addAttributesExtractors(additionalExtractors) .addOperationMetrics(HttpClientMetrics.get()) - .setSchemaUrl(SchemaUrls.V1_37_0); + .setSchemaUrl(SchemaUrls.V1_41_0); + setHttpClientExceptionEventExtractor(builder); if (emitExperimentalHttpClientTelemetry) { builder .addAttributesExtractor(HttpExperimentalAttributesExtractor.create(attributesGetter)) @@ -245,7 +247,17 @@ InstrumenterBuilder instrumenterBuilder( SpanNameExtractor spanNameExtractor) { return Instrumenter.builder( openTelemetry, instrumentationName, spanNameExtractor) - .setSchemaUrl(SchemaUrls.V1_37_0); + .setSchemaUrl(SchemaUrls.V1_41_0); + } + + public static void setHttpClientExceptionEventExtractor( + InstrumenterBuilder builder) { + Experimental.setExceptionEventExtractor( + builder, + (logRecordBuilder, context, request) -> { + logRecordBuilder.setEventName("http.client.request.exception"); + logRecordBuilder.setSeverity(Severity.WARN); + }); } @CanIgnoreReturnValue diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/builder/internal/DefaultHttpServerInstrumenterBuilder.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/builder/internal/DefaultHttpServerInstrumenterBuilder.java index 2bcc2a40e2fb..435465f5a6cd 100644 --- a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/builder/internal/DefaultHttpServerInstrumenterBuilder.java +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/builder/internal/DefaultHttpServerInstrumenterBuilder.java @@ -9,6 +9,7 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.logs.Severity; import io.opentelemetry.context.propagation.TextMapGetter; import io.opentelemetry.instrumentation.api.incubator.config.internal.CommonConfig; import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpExperimentalAttributesExtractor; @@ -230,7 +231,13 @@ public InstrumenterBuilder instrumenterBuilder() { .addAttributesExtractors(additionalExtractors) .addContextCustomizer(httpServerRouteBuilder.build()) .addOperationMetrics(HttpServerMetrics.get()) - .setSchemaUrl(SchemaUrls.V1_37_0); + .setSchemaUrl(SchemaUrls.V1_41_0); + Experimental.setExceptionEventExtractor( + builder, + (logRecordBuilder, context, request) -> { + logRecordBuilder.setEventName("http.server.request.exception"); + logRecordBuilder.setSeverity(Severity.ERROR); + }); if (emitExperimentalHttpServerTelemetry) { builder .addAttributesExtractor(HttpExperimentalAttributesExtractor.create(attributesGetter)) diff --git a/instrumentation-api/build.gradle.kts b/instrumentation-api/build.gradle.kts index f22e94e0a33e..909c5313ac74 100644 --- a/instrumentation-api/build.gradle.kts +++ b/instrumentation-api/build.gradle.kts @@ -44,4 +44,20 @@ tasks { jvmArgs("--add-opens=java.base/java.util=ALL-UNNAMED") jvmArgs("-XX:+IgnoreUnrecognizedVMOptions") } + + val testExceptionSignalLogs by registering(Test::class) { + testClassesDirs = sourceSets.test.get().output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + jvmArgs("-Dotel.semconv.exception.signal.preview=logs") + } + + val testExceptionSignalLogsDup by registering(Test::class) { + testClassesDirs = sourceSets.test.get().output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + jvmArgs("-Dotel.semconv.exception.signal.preview=logs/dup") + } + + check { + dependsOn(testExceptionSignalLogs, testExceptionSignalLogsDup) + } } diff --git a/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/instrumenter/Instrumenter.java b/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/instrumenter/Instrumenter.java index 717796919c60..4e52c0e52dce 100644 --- a/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/instrumenter/Instrumenter.java +++ b/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/instrumenter/Instrumenter.java @@ -5,9 +5,14 @@ package io.opentelemetry.instrumentation.api.instrumenter; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsLogs; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsSpanEvents; import static java.util.concurrent.TimeUnit.SECONDS; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.logs.LogRecordBuilder; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.logs.Severity; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanBuilder; import io.opentelemetry.api.trace.SpanKind; @@ -18,6 +23,7 @@ import io.opentelemetry.instrumentation.api.internal.InstrumenterAccess; import io.opentelemetry.instrumentation.api.internal.InstrumenterContext; import io.opentelemetry.instrumentation.api.internal.InstrumenterUtil; +import io.opentelemetry.instrumentation.api.internal.InternalExceptionEventExtractor; import io.opentelemetry.instrumentation.api.internal.SupportabilityMetrics; import java.time.Instant; import javax.annotation.Nullable; @@ -72,6 +78,7 @@ public static InstrumenterBuilder builder private final String instrumentationName; private final Tracer tracer; + @Nullable private final Logger logger; private final SpanNameExtractor spanNameExtractor; private final SpanKindExtractor spanKindExtractor; private final SpanStatusExtractor spanStatusExtractor; @@ -82,6 +89,7 @@ public static InstrumenterBuilder builder private final AttributesExtractor[] operationListenerAttributesExtractors; private final ErrorCauseExtractor errorCauseExtractor; + @Nullable private final InternalExceptionEventExtractor exceptionEventExtractor; private final boolean propagateOperationListenersToOnEnd; private final boolean enabled; private final SpanSuppressor spanSuppressor; @@ -104,6 +112,17 @@ public static InstrumenterBuilder builder this.propagateOperationListenersToOnEnd = builder.propagateOperationListenersToOnEnd; this.enabled = builder.enabled; this.spanSuppressor = builder.buildSpanSuppressor(); + + if (emitExceptionAsLogs()) { + this.logger = builder.buildLogger(); + this.exceptionEventExtractor = + builder.exceptionEventExtractor != null + ? builder.exceptionEventExtractor + : defaultExceptionEventExtractor(this.spanKindExtractor); + } else { + this.logger = null; + this.exceptionEventExtractor = null; + } } /** @@ -260,7 +279,13 @@ private void doEnd( if (error != null) { error = errorCauseExtractor.extract(error); - span.recordException(error); + if (emitExceptionAsSpanEvents()) { + span.recordException(error); + } + // Exception logs are intentionally emitted even when the span is not recording. + if (emitExceptionAsLogs() && exceptionEventExtractor != null) { + emitExceptionLog(context, error, request, endTime); + } } UnsafeAttributes attributes = new UnsafeAttributes(); @@ -301,6 +326,40 @@ private void doEnd( } } + private void emitExceptionLog( + Context context, Throwable throwable, REQUEST request, @Nullable Instant endTime) { + if (logger == null || exceptionEventExtractor == null) { + // this condition is to keep nullaway happy + // doEnd already guards on exceptionEventExtractor != null, so this is unreachable + return; + } + LogRecordBuilder logRecordBuilder = logger.logRecordBuilder(); + logRecordBuilder.setContext(context); + if (endTime != null) { + logRecordBuilder.setTimestamp(endTime); + } + exceptionEventExtractor.extract(logRecordBuilder, context, request); + logRecordBuilder.setException(throwable); + logRecordBuilder.emit(); + } + + // Per semconv + // (https://opentelemetry.io/docs/specs/semconv/general/recording-errors/#errors-in-logs), + // SERVER and CONSUMER spans should record exceptions with ERROR severity, while CLIENT and + // PRODUCER spans should use WARN. + private static InternalExceptionEventExtractor defaultExceptionEventExtractor( + SpanKindExtractor spanKindExtractor) { + return (logRecordBuilder, context, request) -> { + logRecordBuilder.setEventName("exception"); + SpanKind spanKind = spanKindExtractor.extract(request); + Severity severity = + (spanKind == SpanKind.SERVER || spanKind == SpanKind.CONSUMER) + ? Severity.ERROR + : Severity.WARN; + logRecordBuilder.setSeverity(severity); + }; + } + private static long getNanos(@Nullable Instant time) { if (time == null) { return System.nanoTime(); diff --git a/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/instrumenter/InstrumenterBuilder.java b/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/instrumenter/InstrumenterBuilder.java index 6b6a2914bf83..c06f5444fff5 100644 --- a/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/instrumenter/InstrumenterBuilder.java +++ b/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/instrumenter/InstrumenterBuilder.java @@ -14,6 +14,7 @@ import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.api.incubator.ExtendedOpenTelemetry; import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties; +import io.opentelemetry.api.logs.LoggerBuilder; import io.opentelemetry.api.metrics.Meter; import io.opentelemetry.api.metrics.MeterBuilder; import io.opentelemetry.api.trace.SpanKind; @@ -28,6 +29,7 @@ import io.opentelemetry.instrumentation.api.internal.Experimental; import io.opentelemetry.instrumentation.api.internal.InstrumenterBuilderAccess; import io.opentelemetry.instrumentation.api.internal.InstrumenterUtil; +import io.opentelemetry.instrumentation.api.internal.InternalExceptionEventExtractor; import io.opentelemetry.instrumentation.api.internal.InternalInstrumenterCustomizer; import io.opentelemetry.instrumentation.api.internal.InternalInstrumenterCustomizerProvider; import io.opentelemetry.instrumentation.api.internal.InternalInstrumenterCustomizerUtil; @@ -71,6 +73,7 @@ public final class InstrumenterBuilder { SpanStatusExtractor spanStatusExtractor = SpanStatusExtractor.getDefault(); ErrorCauseExtractor errorCauseExtractor = ErrorCauseExtractor.getDefault(); + @Nullable InternalExceptionEventExtractor exceptionEventExtractor; boolean propagateOperationListenersToOnEnd = false; boolean enabled = true; @@ -80,6 +83,10 @@ public final class InstrumenterBuilder { builder.operationListenerAttributesExtractors.add( requireNonNull( operationListenerAttributesExtractor, "operationListenerAttributesExtractor"))); + Experimental.internalSetExceptionEventExtractor( + (builder, exceptionEventExtractor) -> + builder.exceptionEventExtractor = + requireNonNull(exceptionEventExtractor, "exceptionEventExtractor")); } InstrumenterBuilder( @@ -314,6 +321,18 @@ Tracer buildTracer() { return tracerBuilder.build(); } + io.opentelemetry.api.logs.Logger buildLogger() { + LoggerBuilder loggerBuilder = openTelemetry.getLogsBridge().loggerBuilder(instrumentationName); + if (instrumentationVersion != null) { + loggerBuilder.setInstrumentationVersion(instrumentationVersion); + } + String schemaUrl = getSchemaUrl(); + if (schemaUrl != null) { + loggerBuilder.setSchemaUrl(schemaUrl); + } + return loggerBuilder.build(); + } + List buildOperationListeners() { // just copy the listeners list if there are no metrics registered if (operationMetrics.isEmpty()) { diff --git a/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/Experimental.java b/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/Experimental.java index 80dc38a7e9e6..781d4869e1b8 100644 --- a/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/Experimental.java +++ b/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/Experimental.java @@ -40,6 +40,10 @@ public final class Experimental { private static volatile BiConsumer, AttributesExtractor> operationListenerAttributesExtractorAdder; + @Nullable + private static volatile BiConsumer, InternalExceptionEventExtractor> + exceptionEventExtractorSetter; + private Experimental() {} /** @@ -117,4 +121,24 @@ public static void internalAddOperationListenerAttributesExt Experimental.operationListenerAttributesExtractorAdder = (BiConsumer) operationListenerAttributesExtractorAdder; } + + /** + * Sets the {@link InternalExceptionEventExtractor} that will determine the exception event name + * and severity. Only used when emitting exceptions as logs is enabled via the {@code + * otel.semconv.exception.signal.preview} flag. + */ + public static void setExceptionEventExtractor( + InstrumenterBuilder builder, + InternalExceptionEventExtractor exceptionEventExtractor) { + if (exceptionEventExtractorSetter != null) { + exceptionEventExtractorSetter.accept(builder, exceptionEventExtractor); + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) // we lose the generic type information + public static void internalSetExceptionEventExtractor( + BiConsumer, InternalExceptionEventExtractor> + exceptionEventExtractorSetter) { + Experimental.exceptionEventExtractorSetter = (BiConsumer) exceptionEventExtractorSetter; + } } diff --git a/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/InternalExceptionEventExtractor.java b/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/InternalExceptionEventExtractor.java new file mode 100644 index 000000000000..e8b8202c15bf --- /dev/null +++ b/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/InternalExceptionEventExtractor.java @@ -0,0 +1,28 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.internal; + +import io.opentelemetry.api.logs.LogRecordBuilder; +import io.opentelemetry.context.Context; + +/** + * Internal functional interface for exception event extraction. + * + *

This is temporary bridge API while exception event extraction is not available in the stable + * instrumentation API artifact. This interface should be revisited when a public API is added. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +@FunctionalInterface +public interface InternalExceptionEventExtractor { + + /** + * Populates the exception event {@link LogRecordBuilder} with the event name, severity, and any + * additional attributes for the given context and request. + */ + void extract(LogRecordBuilder logRecordBuilder, Context context, REQUEST request); +} diff --git a/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/SemconvExceptionSignal.java b/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/SemconvExceptionSignal.java new file mode 100644 index 000000000000..77e284f0ea81 --- /dev/null +++ b/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/SemconvExceptionSignal.java @@ -0,0 +1,89 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.internal; + +import static io.opentelemetry.api.incubator.config.DeclarativeConfigProperties.empty; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.incubator.ExtendedOpenTelemetry; +import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties; +import java.util.logging.Logger; +import javax.annotation.Nullable; + +/** + * This class is internal and is hence not for public use. Its APIs are unstable and can change at + * any time. + */ +@SuppressWarnings("deprecation") +public final class SemconvExceptionSignal { + + private static final String CONFIG_PROPERTY = "otel.semconv.exception.signal.preview"; + + private static final Logger logger = Logger.getLogger(SemconvExceptionSignal.class.getName()); + + private static final boolean emitExceptionAsSpanEvents; + private static final boolean emitExceptionAsLogs; + + static { + OpenTelemetry openTelemetry = GlobalOpenTelemetry.getOrNoop(); + String previewValue = resolvePreviewValue(openTelemetry); + + emitExceptionAsSpanEvents = shouldEmitSpanEvents(previewValue); + emitExceptionAsLogs = shouldEmitLogs(previewValue); + } + + public static boolean emitExceptionAsSpanEvents() { + return emitExceptionAsSpanEvents; + } + + public static boolean emitExceptionAsLogs() { + return emitExceptionAsLogs; + } + + private static boolean shouldEmitSpanEvents(@Nullable String value) { + return !"logs".equals(value); + } + + private static boolean shouldEmitLogs(@Nullable String value) { + if (value == null || value.isEmpty()) { + return false; + } + if ("logs".equals(value) || "logs/dup".equals(value)) { + return true; + } + logger.warning( + "Unrecognized value for " + + CONFIG_PROPERTY + + ": \"" + + value + + "\". Expected \"logs\" or \"logs/dup\". Defaulting to span events."); + return false; + } + + @Nullable + private static String resolvePreviewValue(OpenTelemetry openTelemetry) { + // Try declarative config via GlobalOpenTelemetry first + String value = + getGeneralInstrumentationConfig(openTelemetry) + .get("semconv_exception") + .get("signal") + .getString("preview"); + if (value != null) { + return value; + } + return ConfigPropertiesUtil.getString(CONFIG_PROPERTY); + } + + private static DeclarativeConfigProperties getGeneralInstrumentationConfig( + OpenTelemetry openTelemetry) { + return openTelemetry instanceof ExtendedOpenTelemetry + ? ((ExtendedOpenTelemetry) openTelemetry).getGeneralInstrumentationConfig() + : empty(); + } + + private SemconvExceptionSignal() {} +} diff --git a/instrumentation-api/src/test/java/io/opentelemetry/instrumentation/api/instrumenter/InstrumenterTest.java b/instrumentation-api/src/test/java/io/opentelemetry/instrumentation/api/instrumenter/InstrumenterTest.java index 2d9f7ca8f22d..fe0085edfe6f 100644 --- a/instrumentation-api/src/test/java/io/opentelemetry/instrumentation/api/instrumenter/InstrumenterTest.java +++ b/instrumentation-api/src/test/java/io/opentelemetry/instrumentation/api/instrumenter/InstrumenterTest.java @@ -6,8 +6,14 @@ package io.opentelemetry.instrumentation.api.instrumenter; import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsLogs; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsSpanEvents; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.satisfies; +import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_MESSAGE; +import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_STACKTRACE; +import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_TYPE; import static java.util.Collections.emptyMap; import static java.util.stream.Collectors.toMap; import static org.assertj.core.api.Assertions.entry; @@ -15,6 +21,7 @@ import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.logs.Severity; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanContext; import io.opentelemetry.api.trace.SpanId; @@ -31,15 +38,19 @@ import io.opentelemetry.instrumentation.api.internal.SpanKey; import io.opentelemetry.instrumentation.api.internal.SpanKeyProvider; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; +import io.opentelemetry.sdk.logs.data.LogRecordData; import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; import io.opentelemetry.sdk.trace.data.LinkData; import io.opentelemetry.sdk.trace.data.StatusData; +import java.time.Instant; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; import javax.annotation.Nullable; +import org.assertj.core.api.AbstractAssert; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.RegisterExtension; @@ -218,24 +229,47 @@ void server() { @Test void server_error() { - Instrumenter, Map> instrumenter = + InstrumenterBuilder, Map> builder = Instrumenter., Map>builder( otelTesting.getOpenTelemetry(), "test", unused -> "span") .addAttributesExtractor(new AttributesExtractor1()) - .addAttributesExtractor(new AttributesExtractor2()) - .buildServerInstrumenter(new MapGetter()); + .addAttributesExtractor(new AttributesExtractor2()); + Experimental.setExceptionEventExtractor( + builder, + (logRecordBuilder, context, request) -> { + logRecordBuilder.setEventName("http.server.request.exception"); + logRecordBuilder.setSeverity(Severity.ERROR); + }); + Instrumenter, Map> instrumenter = + builder.buildServerInstrumenter(new MapGetter()); Context context = instrumenter.start(Context.root(), REQUEST); assertThat(Span.fromContext(context).getSpanContext().isValid()).isTrue(); - instrumenter.end(context, REQUEST, RESPONSE, new IllegalStateException("test")); + IllegalStateException error = new IllegalStateException("test"); + instrumenter.end(context, REQUEST, RESPONSE, error); otelTesting .assertTraces() .hasTracesSatisfyingExactly( trace -> trace.hasSpansSatisfyingExactly( - span -> span.hasName("span").hasStatus(StatusData.error()))); + span -> + span.hasName("span") + .hasStatus(StatusData.error()) + .hasException(emitExceptionAsSpanEvents() ? error : null))); + + if (emitExceptionAsLogs()) { + List logs = otelTesting.getLogRecords(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0)) + .hasSeverity(Severity.ERROR) + .hasEventName("http.server.request.exception") + .hasAttributesSatisfyingExactly( + equalTo(EXCEPTION_TYPE, "java.lang.IllegalStateException"), + equalTo(EXCEPTION_MESSAGE, "test"), + satisfies(EXCEPTION_STACKTRACE, AbstractAssert::isNotNull)); + } } @Test @@ -323,12 +357,19 @@ void client() { @Test void client_error() { - Instrumenter, Map> instrumenter = + InstrumenterBuilder, Map> builder = Instrumenter., Map>builder( otelTesting.getOpenTelemetry(), "test", unused -> "span") .addAttributesExtractor(new AttributesExtractor1()) - .addAttributesExtractor(new AttributesExtractor2()) - .buildClientInstrumenter(Map::put); + .addAttributesExtractor(new AttributesExtractor2()); + Experimental.setExceptionEventExtractor( + builder, + (logRecordBuilder, context, request) -> { + logRecordBuilder.setEventName("http.client.request.exception"); + logRecordBuilder.setSeverity(Severity.WARN); + }); + Instrumenter, Map> instrumenter = + builder.buildClientInstrumenter(Map::put); Map request = new HashMap<>(REQUEST); Context context = instrumenter.start(Context.root(), request); @@ -337,14 +378,87 @@ void client_error() { assertThat(spanContext.isValid()).isTrue(); assertThat(request).containsKey("traceparent"); - instrumenter.end(context, request, RESPONSE, new IllegalStateException("test")); + IllegalStateException error = new IllegalStateException("test"); + instrumenter.end(context, request, RESPONSE, error); + + otelTesting + .assertTraces() + .hasTracesSatisfyingExactly( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName("span") + .hasStatus(StatusData.error()) + .hasException(emitExceptionAsSpanEvents() ? error : null))); + + if (emitExceptionAsLogs()) { + List logs = otelTesting.getLogRecords(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0)) + .hasSeverity(Severity.WARN) + .hasEventName("http.client.request.exception") + .hasAttributesSatisfyingExactly( + equalTo(EXCEPTION_TYPE, "java.lang.IllegalStateException"), + equalTo(EXCEPTION_MESSAGE, "test"), + satisfies(EXCEPTION_STACKTRACE, AbstractAssert::isNotNull)); + } + } + + @Test + void error_default_exception_event_extractor() { + // When no exception event extractor is provided, a default should be used + // that sets event name to "exception" and severity to WARN. + Instrumenter, Map> instrumenter = + Instrumenter., Map>builder( + otelTesting.getOpenTelemetry(), "test", unused -> "span") + .buildInstrumenter(); + + Context context = instrumenter.start(Context.root(), REQUEST); + assertThat(Span.fromContext(context).getSpanContext().isValid()).isTrue(); + + IllegalStateException error = new IllegalStateException("test"); + instrumenter.end(context, REQUEST, RESPONSE, error); otelTesting .assertTraces() .hasTracesSatisfyingExactly( trace -> trace.hasSpansSatisfyingExactly( - span -> span.hasName("span").hasStatus(StatusData.error()))); + span -> + span.hasName("span") + .hasStatus(StatusData.error()) + .hasException(emitExceptionAsSpanEvents() ? error : null))); + + if (emitExceptionAsLogs()) { + List logs = otelTesting.getLogRecords(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0)) + .hasSeverity(Severity.WARN) + .hasEventName("exception") + .hasAttributesSatisfyingExactly( + equalTo(EXCEPTION_TYPE, "java.lang.IllegalStateException"), + equalTo(EXCEPTION_MESSAGE, "test"), + satisfies(EXCEPTION_STACKTRACE, AbstractAssert::isNotNull)); + } + } + + @Test + void error_log_usesExplicitEndTime() { + Instrumenter, Map> instrumenter = + Instrumenter., Map>builder( + otelTesting.getOpenTelemetry(), "test", unused -> "span") + .buildInstrumenter(); + + IllegalStateException error = new IllegalStateException("test"); + Instant endTime = Instant.ofEpochSecond(123, 456); + instrumenter.startAndEnd( + Context.root(), REQUEST, RESPONSE, error, Instant.ofEpochSecond(100), endTime); + + if (emitExceptionAsLogs()) { + List logs = otelTesting.getLogRecords(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getTimestampEpochNanos()).isEqualTo(123_000_000_456L); + } } @Test diff --git a/instrumentation/netty/netty-3.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/netty/v3_8/client/NettyClientSingletons.java b/instrumentation/netty/netty-3.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/netty/v3_8/client/NettyClientSingletons.java index 3875d22c4dc6..281138ab7b85 100644 --- a/instrumentation/netty/netty-3.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/netty/v3_8/client/NettyClientSingletons.java +++ b/instrumentation/netty/netty-3.8/javaagent/src/main/java/io/opentelemetry/javaagent/instrumentation/netty/v3_8/client/NettyClientSingletons.java @@ -5,9 +5,12 @@ package io.opentelemetry.javaagent.instrumentation.netty.v3_8.client; +import static io.opentelemetry.instrumentation.api.incubator.builder.internal.DefaultHttpClientInstrumenterBuilder.setHttpClientExceptionEventExtractor; + import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpClientServicePeerAttributesExtractor; import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter; +import io.opentelemetry.instrumentation.api.instrumenter.InstrumenterBuilder; import io.opentelemetry.instrumentation.api.instrumenter.SpanKindExtractor; import io.opentelemetry.instrumentation.api.semconv.http.HttpClientAttributesExtractor; import io.opentelemetry.instrumentation.netty.common.internal.NettyConnectionRequest; @@ -36,7 +39,7 @@ public class NettyClientSingletons { (context, requestAndChannel, startAttributes) -> NettyErrorHolder.init(context))); - connectionInstrumenter = + InstrumenterBuilder builder = Instrumenter.builder( GlobalOpenTelemetry.get(), INSTRUMENTATION_NAME, NettyConnectionRequest::spanName) .addAttributesExtractor( @@ -44,8 +47,9 @@ public class NettyClientSingletons { .addAttributesExtractor( HttpClientServicePeerAttributesExtractor.create( new NettyConnectHttpAttributesGetter(), GlobalOpenTelemetry.get())) - .setSchemaUrl(SchemaUrls.V1_37_0) - .buildInstrumenter(SpanKindExtractor.alwaysClient()); + .setSchemaUrl(SchemaUrls.V1_41_0); + setHttpClientExceptionEventExtractor(builder); + connectionInstrumenter = builder.buildInstrumenter(SpanKindExtractor.alwaysClient()); } public static Instrumenter instrumenter() { diff --git a/instrumentation/netty/netty-common-4.0/library/src/main/java/io/opentelemetry/instrumentation/netty/common/v4_0/internal/client/NettyClientInstrumenterFactory.java b/instrumentation/netty/netty-common-4.0/library/src/main/java/io/opentelemetry/instrumentation/netty/common/v4_0/internal/client/NettyClientInstrumenterFactory.java index b8da71e1d8d4..090ccd3bad77 100644 --- a/instrumentation/netty/netty-common-4.0/library/src/main/java/io/opentelemetry/instrumentation/netty/common/v4_0/internal/client/NettyClientInstrumenterFactory.java +++ b/instrumentation/netty/netty-common-4.0/library/src/main/java/io/opentelemetry/instrumentation/netty/common/v4_0/internal/client/NettyClientInstrumenterFactory.java @@ -5,6 +5,8 @@ package io.opentelemetry.instrumentation.netty.common.v4_0.internal.client; +import static io.opentelemetry.instrumentation.api.incubator.builder.internal.DefaultHttpClientInstrumenterBuilder.setHttpClientExceptionEventExtractor; + import io.netty.channel.Channel; import io.netty.handler.codec.http.HttpResponse; import io.opentelemetry.api.OpenTelemetry; @@ -71,6 +73,10 @@ public NettyConnectionInstrumenter createConnectionInstrumenter(OpenTelemetry op builder.addAttributesExtractor( HttpClientServicePeerAttributesExtractor.create(getter, openTelemetry)); + if (!connectionTelemetryFullyEnabled) { + setHttpClientExceptionEventExtractor(builder); + } + Instrumenter instrumenter = builder.buildInstrumenter( connectionTelemetryFullyEnabled diff --git a/instrumentation/okhttp/okhttp-3.0/javaagent/build.gradle.kts b/instrumentation/okhttp/okhttp-3.0/javaagent/build.gradle.kts index 2b299e870b41..f925bc714419 100644 --- a/instrumentation/okhttp/okhttp-3.0/javaagent/build.gradle.kts +++ b/instrumentation/okhttp/okhttp-3.0/javaagent/build.gradle.kts @@ -53,7 +53,14 @@ tasks { systemProperty("metadataConfig", "otel.semconv-stability.opt-in=service.peer") } + val testExceptionSignalLogs by registering(Test::class) { + testClassesDirs = sourceSets.test.get().output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + jvmArgs("-Dotel.semconv.exception.signal.preview=logs") + systemProperty("metadataConfig", "otel.semconv.exception.signal.preview=logs") + } + check { - dependsOn(testStableSemconv) + dependsOn(testStableSemconv, testExceptionSignalLogs) } } diff --git a/instrumentation/spring/spring-webflux/spring-webflux-5.0/javaagent/build.gradle.kts b/instrumentation/spring/spring-webflux/spring-webflux-5.0/javaagent/build.gradle.kts index 58757a4b3e2a..0ddf301dbede 100644 --- a/instrumentation/spring/spring-webflux/spring-webflux-5.0/javaagent/build.gradle.kts +++ b/instrumentation/spring/spring-webflux/spring-webflux-5.0/javaagent/build.gradle.kts @@ -90,8 +90,19 @@ tasks { ) } + val testExceptionSignalLogs by registering(Test::class) { + testClassesDirs = sourceSets.test.get().output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + jvmArgs("-Dotel.semconv.exception.signal.preview=logs") + systemProperty( + "metadataConfig", + "otel.instrumentation.common.experimental.controller-telemetry.enabled=true," + + "otel.semconv.exception.signal.preview=logs" + ) + } + check { - dependsOn(testStableSemconv) + dependsOn(testStableSemconv, testExceptionSignalLogs) } } diff --git a/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractControllerSpringWebFluxServerTest.java b/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractControllerSpringWebFluxServerTest.java index eb9d87d9f798..c3abc94c33df 100644 --- a/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractControllerSpringWebFluxServerTest.java +++ b/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractControllerSpringWebFluxServerTest.java @@ -5,6 +5,8 @@ package io.opentelemetry.instrumentation.spring.webflux.server; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsLogs; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsSpanEvents; import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.EXCEPTION; import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.NOT_FOUND; import static io.opentelemetry.instrumentation.testing.util.TestLatestDeps.testLatestDeps; @@ -35,43 +37,67 @@ protected SpanDataAssert assertHandlerSpan( span.hasName(handlerSpanName).hasKind(SpanKind.INTERNAL); if (endpoint == EXCEPTION) { span.hasStatus(StatusData.error()); - span.hasEventsSatisfyingExactly( - event -> - event - .hasName("exception") - .hasAttributesSatisfyingExactly( - equalTo(EXCEPTION_TYPE, "java.lang.IllegalStateException"), - equalTo(EXCEPTION_MESSAGE, EXCEPTION.getBody()), - satisfies(EXCEPTION_STACKTRACE, val -> val.isInstanceOf(String.class)))); - } else if (endpoint == NOT_FOUND) { - span.hasStatus(StatusData.error()); - if (testLatestDeps()) { + if (emitExceptionAsSpanEvents()) { span.hasEventsSatisfyingExactly( event -> event .hasName("exception") .hasAttributesSatisfyingExactly( - equalTo( - EXCEPTION_TYPE, - "org.springframework.web.reactive.resource.NoResourceFoundException"), - equalTo( - EXCEPTION_MESSAGE, "404 NOT_FOUND \"No static resource notFound.\""), + equalTo(EXCEPTION_TYPE, "java.lang.IllegalStateException"), + equalTo(EXCEPTION_MESSAGE, EXCEPTION.getBody()), satisfies(EXCEPTION_STACKTRACE, val -> val.isInstanceOf(String.class)))); } else { - span.hasEventsSatisfyingExactly( - event -> - event - .hasName("exception") - .hasAttributesSatisfyingExactly( - satisfies( - EXCEPTION_TYPE, - val -> - val.isIn( - "org.springframework.web.server.ResponseStatusException", - // Changed in spring 7+ - "org.springframework.web.reactive.resource.NoResourceFoundException")), - satisfies(EXCEPTION_MESSAGE, val -> val.contains("404")), - satisfies(EXCEPTION_STACKTRACE, val -> val.isInstanceOf(String.class)))); + span.hasEventsSatisfyingExactly(); + } + } else if (endpoint == NOT_FOUND) { + span.hasStatus(StatusData.error()); + if (emitExceptionAsSpanEvents()) { + if (testLatestDeps()) { + span.hasEventsSatisfyingExactly( + event -> + event + .hasName("exception") + .hasAttributesSatisfyingExactly( + equalTo( + EXCEPTION_TYPE, + "org.springframework.web.reactive.resource.NoResourceFoundException"), + equalTo( + EXCEPTION_MESSAGE, "404 NOT_FOUND \"No static resource notFound.\""), + satisfies(EXCEPTION_STACKTRACE, val -> val.isInstanceOf(String.class)))); + } else { + span.hasEventsSatisfyingExactly( + event -> + event + .hasName("exception") + .hasAttributesSatisfyingExactly( + satisfies( + EXCEPTION_TYPE, + val -> + val.isIn( + "org.springframework.web.server.ResponseStatusException", + // Changed in spring 7+ + "org.springframework.web.reactive.resource.NoResourceFoundException")), + satisfies(EXCEPTION_MESSAGE, val -> val.contains("404")), + satisfies(EXCEPTION_STACKTRACE, val -> val.isInstanceOf(String.class)))); + } + } else { + span.hasEventsSatisfyingExactly(); + } + if (emitExceptionAsLogs()) { + if (testLatestDeps()) { + assertHandlerExceptionLog( + type -> + type.isEqualTo( + "org.springframework.web.reactive.resource.NoResourceFoundException"), + message -> message.isEqualTo("404 NOT_FOUND \"No static resource notFound.\"")); + } else { + assertHandlerExceptionLog( + type -> + type.isIn( + "org.springframework.web.server.ResponseStatusException", + "org.springframework.web.reactive.resource.NoResourceFoundException"), + message -> message.contains("404")); + } } } return span; diff --git a/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractHandlerSpringWebFluxServerTest.java b/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractHandlerSpringWebFluxServerTest.java index a12410c88f0a..fedbaa16fdf2 100644 --- a/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractHandlerSpringWebFluxServerTest.java +++ b/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractHandlerSpringWebFluxServerTest.java @@ -5,6 +5,8 @@ package io.opentelemetry.instrumentation.spring.webflux.server; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsLogs; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsSpanEvents; import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.EXCEPTION; import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.NOT_FOUND; import static io.opentelemetry.instrumentation.testing.util.TestLatestDeps.testLatestDeps; @@ -34,43 +36,67 @@ protected SpanDataAssert assertHandlerSpan( span.hasName(handlerSpanName).hasKind(SpanKind.INTERNAL); if (endpoint == EXCEPTION) { span.hasStatus(StatusData.error()); - span.hasEventsSatisfyingExactly( - event -> - event - .hasName("exception") - .hasAttributesSatisfyingExactly( - equalTo(EXCEPTION_TYPE, "java.lang.IllegalStateException"), - equalTo(EXCEPTION_MESSAGE, EXCEPTION.getBody()), - satisfies(EXCEPTION_STACKTRACE, val -> val.isInstanceOf(String.class)))); - } else if (endpoint == NOT_FOUND) { - span.hasStatus(StatusData.error()); - if (testLatestDeps()) { + if (emitExceptionAsSpanEvents()) { span.hasEventsSatisfyingExactly( event -> event .hasName("exception") .hasAttributesSatisfyingExactly( - equalTo( - EXCEPTION_TYPE, - "org.springframework.web.reactive.resource.NoResourceFoundException"), - equalTo( - EXCEPTION_MESSAGE, "404 NOT_FOUND \"No static resource notFound.\""), + equalTo(EXCEPTION_TYPE, "java.lang.IllegalStateException"), + equalTo(EXCEPTION_MESSAGE, EXCEPTION.getBody()), satisfies(EXCEPTION_STACKTRACE, val -> val.isInstanceOf(String.class)))); } else { - span.hasEventsSatisfyingExactly( - event -> - event - .hasName("exception") - .hasAttributesSatisfyingExactly( - satisfies( - EXCEPTION_TYPE, - val -> - val.isIn( - // changed in spring 7+ - "org.springframework.web.server.ResponseStatusException", - "org.springframework.web.reactive.resource.NoResourceFoundException")), - satisfies(EXCEPTION_STACKTRACE, val -> val.isInstanceOf(String.class)), - satisfies(EXCEPTION_MESSAGE, val -> val.contains("404")))); + span.hasEventsSatisfyingExactly(); + } + } else if (endpoint == NOT_FOUND) { + span.hasStatus(StatusData.error()); + if (emitExceptionAsSpanEvents()) { + if (testLatestDeps()) { + span.hasEventsSatisfyingExactly( + event -> + event + .hasName("exception") + .hasAttributesSatisfyingExactly( + equalTo( + EXCEPTION_TYPE, + "org.springframework.web.reactive.resource.NoResourceFoundException"), + equalTo( + EXCEPTION_MESSAGE, "404 NOT_FOUND \"No static resource notFound.\""), + satisfies(EXCEPTION_STACKTRACE, val -> val.isInstanceOf(String.class)))); + } else { + span.hasEventsSatisfyingExactly( + event -> + event + .hasName("exception") + .hasAttributesSatisfyingExactly( + satisfies( + EXCEPTION_TYPE, + val -> + val.isIn( + // changed in spring 7+ + "org.springframework.web.server.ResponseStatusException", + "org.springframework.web.reactive.resource.NoResourceFoundException")), + satisfies(EXCEPTION_STACKTRACE, val -> val.isInstanceOf(String.class)), + satisfies(EXCEPTION_MESSAGE, val -> val.contains("404")))); + } + } else { + span.hasEventsSatisfyingExactly(); + } + if (emitExceptionAsLogs()) { + if (testLatestDeps()) { + assertHandlerExceptionLog( + type -> + type.isEqualTo( + "org.springframework.web.reactive.resource.NoResourceFoundException"), + message -> message.isEqualTo("404 NOT_FOUND \"No static resource notFound.\"")); + } else { + assertHandlerExceptionLog( + type -> + type.isIn( + "org.springframework.web.server.ResponseStatusException", + "org.springframework.web.reactive.resource.NoResourceFoundException"), + message -> message.contains("404")); + } } } return span; diff --git a/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractSpringWebFluxServerTest.java b/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractSpringWebFluxServerTest.java index c3681d8cb53d..45503df517df 100644 --- a/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractSpringWebFluxServerTest.java +++ b/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractSpringWebFluxServerTest.java @@ -7,14 +7,25 @@ import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.NOT_FOUND; import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.PATH_PARAM; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.satisfies; +import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_MESSAGE; +import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_STACKTRACE; +import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_TYPE; +import static java.util.stream.Collectors.toList; +import io.opentelemetry.api.logs.Severity; import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension; import io.opentelemetry.instrumentation.testing.junit.http.AbstractHttpServerTest; import io.opentelemetry.instrumentation.testing.junit.http.HttpServerInstrumentationExtension; import io.opentelemetry.instrumentation.testing.junit.http.HttpServerTestOptions; import io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.StringAssertConsumer; import java.util.HashMap; +import java.util.List; import java.util.Map; +import org.awaitility.Awaitility; import org.junit.jupiter.api.extension.RegisterExtension; import org.springframework.boot.SpringApplication; import org.springframework.context.ConfigurableApplicationContext; @@ -22,6 +33,8 @@ public abstract class AbstractSpringWebFluxServerTest extends AbstractHttpServerTest { + private static final String INSTRUMENTATION_NAME = "io.opentelemetry.spring-webflux-5.0"; + public static final ServerEndpoint NESTED_PATH = new ServerEndpoint("NESTED_PATH", "nestedPath/hello/world", 200, "nested path"); @@ -64,4 +77,29 @@ protected void configure(HttpServerTestOptions options) { options.setTestPathParam(true); options.setHasHandlerSpan(unused -> true); } + + protected static void assertHandlerExceptionLog( + StringAssertConsumer exceptionTypeAssertion, StringAssertConsumer exceptionMessageAssertion) { + Awaitility.await() + .untilAsserted( + () -> { + List logs = + testing.logRecords().stream() + .filter(log -> "exception".equals(log.getEventName())) + .filter( + log -> + INSTRUMENTATION_NAME.equals( + log.getInstrumentationScopeInfo().getName())) + .collect(toList()); + + assertThat(logs).hasSize(1); + assertThat(logs.get(0)) + .hasSeverity(Severity.WARN) + .hasEventName("exception") + .hasAttributesSatisfyingExactly( + satisfies(EXCEPTION_TYPE, exceptionTypeAssertion), + satisfies(EXCEPTION_MESSAGE, exceptionMessageAssertion), + satisfies(EXCEPTION_STACKTRACE, val -> val.isInstanceOf(String.class))); + }); + } } diff --git a/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractSpringWebfluxTest.java b/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractSpringWebfluxTest.java index 79e3465b72ab..a1cf6582a5fc 100644 --- a/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractSpringWebfluxTest.java +++ b/instrumentation/spring/spring-webflux/spring-webflux-5.0/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/server/AbstractSpringWebfluxTest.java @@ -5,9 +5,12 @@ package io.opentelemetry.instrumentation.spring.webflux.server; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsLogs; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsSpanEvents; import static io.opentelemetry.instrumentation.testing.junit.code.SemconvCodeStabilityUtil.codeFunctionAssertions; import static io.opentelemetry.instrumentation.testing.junit.code.SemconvCodeStabilityUtil.codeFunctionPrefixAssertions; import static io.opentelemetry.instrumentation.testing.util.TestLatestDeps.testLatestDeps; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.satisfies; import static io.opentelemetry.semconv.ClientAttributes.CLIENT_ADDRESS; @@ -27,14 +30,16 @@ import static io.opentelemetry.semconv.UrlAttributes.URL_SCHEME; import static io.opentelemetry.semconv.UserAgentAttributes.USER_AGENT_ORIGINAL; import static java.util.stream.Collectors.toList; -import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Named.named; +import io.opentelemetry.api.logs.Severity; import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.instrumentation.testing.junit.AgentInstrumentationExtension; import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension; +import io.opentelemetry.sdk.logs.data.LogRecordData; import io.opentelemetry.sdk.testing.assertj.AttributeAssertion; import io.opentelemetry.sdk.testing.assertj.EventDataAssert; +import io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.StringAssertConsumer; import io.opentelemetry.sdk.testing.assertj.TraceAssert; import io.opentelemetry.sdk.trace.data.StatusData; import io.opentelemetry.testing.internal.armeria.client.WebClient; @@ -47,6 +52,7 @@ import java.util.function.Consumer; import java.util.stream.IntStream; import java.util.stream.Stream; +import org.awaitility.Awaitility; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -63,6 +69,8 @@ @SuppressWarnings("deprecation") // using deprecated semconv public abstract class AbstractSpringWebfluxTest { + private static final String INSTRUMENTATION_NAME = "io.opentelemetry.spring-webflux-5.0"; + @RegisterExtension static final InstrumentationExtension testing = AgentInstrumentationExtension.create(); @@ -401,16 +409,25 @@ void get404Test() { equalTo(URL_SCHEME, "http"), satisfies(USER_AGENT_ORIGINAL, val -> val.isInstanceOf(String.class)), equalTo(HTTP_ROUTE, "/**")), - span -> - span.hasName("ResourceWebHandler.handle") - .hasKind(SpanKind.INTERNAL) - .hasParent(trace.getSpan(0)) - .hasStatus(StatusData.error()) - .hasEventsSatisfyingExactly(AbstractSpringWebfluxTest::resource404Exception) - .hasAttributesSatisfyingExactly( - codeFunctionAssertions( - "org.springframework.web.reactive.resource.ResourceWebHandler", - "handle")))); + span -> { + span.hasName("ResourceWebHandler.handle") + .hasKind(SpanKind.INTERNAL) + .hasParent(trace.getSpan(0)) + .hasStatus(StatusData.error()); + if (emitExceptionAsSpanEvents()) { + span.hasEventsSatisfyingExactly( + AbstractSpringWebfluxTest::resource404Exception); + } else { + span.hasEventsSatisfyingExactly(); + } + span.hasAttributesSatisfyingExactly( + codeFunctionAssertions( + "org.springframework.web.reactive.resource.ResourceWebHandler", + "handle")); + })); + if (emitExceptionAsLogs()) { + assertResource404ExceptionLog(); + } } private static void resource404Exception(EventDataAssert event) { @@ -439,6 +456,23 @@ private static void resource404Exception(EventDataAssert event) { } } + private static void assertResource404ExceptionLog() { + if (testLatestDeps()) { + assertHandlerExceptionLog( + type -> + type.isEqualTo("org.springframework.web.reactive.resource.NoResourceFoundException"), + message -> message.isInstanceOf(String.class)); + } else { + assertHandlerExceptionLog( + type -> + type.isIn( + "org.springframework.web.server.ResponseStatusException", + // Changed in spring 7+ + "org.springframework.web.reactive.resource.NoResourceFoundException"), + message -> message.contains("404")); + } + } + @Test void basicPostTest() { String echoString = "TEST"; @@ -516,19 +550,53 @@ void getToBadEndpointTest(Parameter parameter) { } span.hasKind(SpanKind.INTERNAL) .hasParent(trace.getSpan(0)) - .hasStatus(StatusData.error()) - .hasEventsSatisfyingExactly( - event -> - event - .hasName("exception") - .hasAttributesSatisfyingExactly( - equalTo(EXCEPTION_TYPE, "java.lang.IllegalStateException"), - equalTo(EXCEPTION_MESSAGE, "bad things happen"), - satisfies( - EXCEPTION_STACKTRACE, - val -> val.isInstanceOf(String.class)))) - .hasAttributesSatisfyingExactly(assertCodeFunction(parameter)); + .hasStatus(StatusData.error()); + if (emitExceptionAsSpanEvents()) { + span.hasEventsSatisfyingExactly( + event -> + event + .hasName("exception") + .hasAttributesSatisfyingExactly( + equalTo(EXCEPTION_TYPE, "java.lang.IllegalStateException"), + equalTo(EXCEPTION_MESSAGE, "bad things happen"), + satisfies( + EXCEPTION_STACKTRACE, + val -> val.isInstanceOf(String.class)))); + } else { + span.hasEventsSatisfyingExactly(); + } + span.hasAttributesSatisfyingExactly(assertCodeFunction(parameter)); })); + if (emitExceptionAsLogs()) { + assertHandlerExceptionLog( + type -> type.isEqualTo("java.lang.IllegalStateException"), + message -> message.isEqualTo("bad things happen")); + } + } + + private static void assertHandlerExceptionLog( + StringAssertConsumer exceptionTypeAssertion, StringAssertConsumer exceptionMessageAssertion) { + Awaitility.await() + .untilAsserted( + () -> { + List logs = + testing.logRecords().stream() + .filter(log -> "exception".equals(log.getEventName())) + .filter( + log -> + INSTRUMENTATION_NAME.equals( + log.getInstrumentationScopeInfo().getName())) + .collect(toList()); + + assertThat(logs).hasSize(1); + assertThat(logs.get(0)) + .hasSeverity(Severity.WARN) + .hasEventName("exception") + .hasAttributesSatisfyingExactly( + satisfies(EXCEPTION_TYPE, exceptionTypeAssertion), + satisfies(EXCEPTION_MESSAGE, exceptionMessageAssertion), + satisfies(EXCEPTION_STACKTRACE, val -> val.isInstanceOf(String.class))); + }); } private static Stream provideBadEndpointParameters() { diff --git a/instrumentation/spring/spring-webflux/spring-webflux-5.3/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/client/AbstractSpringWebfluxClientInstrumentationTest.java b/instrumentation/spring/spring-webflux/spring-webflux-5.3/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/client/AbstractSpringWebfluxClientInstrumentationTest.java index 808c6ead76e3..b20a8a98ab56 100644 --- a/instrumentation/spring/spring-webflux/spring-webflux-5.3/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/client/AbstractSpringWebfluxClientInstrumentationTest.java +++ b/instrumentation/spring/spring-webflux/spring-webflux-5.3/testing/src/main/java/io/opentelemetry/instrumentation/spring/webflux/client/AbstractSpringWebfluxClientInstrumentationTest.java @@ -6,6 +6,7 @@ package io.opentelemetry.instrumentation.spring.webflux.client; import static io.opentelemetry.api.trace.SpanKind.CLIENT; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsSpanEvents; import static io.opentelemetry.instrumentation.testing.junit.service.SemconvServiceStabilityUtil.maybeStablePeerService; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; import static io.opentelemetry.semconv.ErrorAttributes.ERROR_TYPE; @@ -197,7 +198,7 @@ void shouldEndSpanOnMonoTimeout() { .hasKind(SpanKind.INTERNAL) .hasNoParent() .hasStatus(StatusData.error()) - .hasException(thrown), + .hasException(emitExceptionAsSpanEvents() ? thrown : null), span -> span.hasName("GET") .hasKind(CLIENT) diff --git a/instrumentation/undertow-1.4/javaagent/build.gradle.kts b/instrumentation/undertow-1.4/javaagent/build.gradle.kts index bf5e867a4b1b..853155f9d8e4 100644 --- a/instrumentation/undertow-1.4/javaagent/build.gradle.kts +++ b/instrumentation/undertow-1.4/javaagent/build.gradle.kts @@ -19,8 +19,21 @@ dependencies { bootstrap(project(":instrumentation:undertow-1.4:bootstrap")) } -tasks.test { - systemProperty("collectMetadata", otelProps.collectMetadata) +tasks { + withType().configureEach { + systemProperty("collectMetadata", otelProps.collectMetadata) + } + + val testExceptionSignalLogs by registering(Test::class) { + testClassesDirs = sourceSets.test.get().output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + jvmArgs("-Dotel.semconv.exception.signal.preview=logs") + systemProperty("metadataConfig", "otel.semconv.exception.signal.preview=logs") + } + + check { + dependsOn(testExceptionSignalLogs) + } } // since 2.3.x, undertow is compiled by JDK 11 diff --git a/instrumentation/undertow-1.4/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/undertow/v1_4/UndertowServerTest.java b/instrumentation/undertow-1.4/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/undertow/v1_4/UndertowServerTest.java index c7393afa204e..945d2730c043 100644 --- a/instrumentation/undertow-1.4/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/undertow/v1_4/UndertowServerTest.java +++ b/instrumentation/undertow-1.4/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/undertow/v1_4/UndertowServerTest.java @@ -5,6 +5,8 @@ package io.opentelemetry.javaagent.instrumentation.undertow.v1_4; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsLogs; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsSpanEvents; import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.CAPTURE_HEADERS; import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.ERROR; import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.EXCEPTION; @@ -244,38 +246,49 @@ void testSendResponseWithException() { testing.waitAndAssertTraces( trace -> trace.hasSpansSatisfyingExactly( - span -> - span.hasName("GET") - .hasNoParent() - .hasKind(SpanKind.SERVER) - .hasEventsSatisfyingExactly( - event -> event.hasName("before-event"), - event -> event.hasName("after-event"), - event -> - event - .hasName("exception") - .hasAttributesSatisfyingExactly( - equalTo(EXCEPTION_TYPE, Exception.class.getName()), - equalTo( - EXCEPTION_MESSAGE, "exception after sending response"), - satisfies( - EXCEPTION_STACKTRACE, - val -> val.isInstanceOf(String.class)))) - .hasAttributesSatisfyingExactly( - equalTo(CLIENT_ADDRESS, TEST_CLIENT_IP), - equalTo(URL_SCHEME, uri.getScheme()), - equalTo(URL_PATH, uri.getPath()), - equalTo(HTTP_REQUEST_METHOD, "GET"), - equalTo(HTTP_RESPONSE_STATUS_CODE, 200), - equalTo(USER_AGENT_ORIGINAL, TEST_USER_AGENT), - equalTo(NETWORK_PROTOCOL_VERSION, useHttp2() ? "2" : "1.1"), - equalTo(SERVER_ADDRESS, uri.getHost()), - equalTo(SERVER_PORT, uri.getPort()), - equalTo(NETWORK_PEER_ADDRESS, "127.0.0.1"), - satisfies(NETWORK_PEER_PORT, val -> val.isInstanceOf(Long.class))), + span -> { + span.hasName("GET") + .hasNoParent() + .hasKind(SpanKind.SERVER) + .hasAttributesSatisfyingExactly( + equalTo(CLIENT_ADDRESS, TEST_CLIENT_IP), + equalTo(URL_SCHEME, uri.getScheme()), + equalTo(URL_PATH, uri.getPath()), + equalTo(HTTP_REQUEST_METHOD, "GET"), + equalTo(HTTP_RESPONSE_STATUS_CODE, 200), + equalTo(USER_AGENT_ORIGINAL, TEST_USER_AGENT), + equalTo(NETWORK_PROTOCOL_VERSION, useHttp2() ? "2" : "1.1"), + equalTo(SERVER_ADDRESS, uri.getHost()), + equalTo(SERVER_PORT, uri.getPort()), + equalTo(NETWORK_PEER_ADDRESS, "127.0.0.1"), + satisfies(NETWORK_PEER_PORT, val -> val.isInstanceOf(Long.class))); + if (emitExceptionAsSpanEvents()) { + span.hasEventsSatisfyingExactly( + event -> event.hasName("before-event"), + event -> event.hasName("after-event"), + event -> + event + .hasName("exception") + .hasAttributesSatisfyingExactly( + equalTo(EXCEPTION_TYPE, Exception.class.getName()), + equalTo(EXCEPTION_MESSAGE, "exception after sending response"), + satisfies( + EXCEPTION_STACKTRACE, + val -> val.isInstanceOf(String.class)))); + } else { + span.hasEventsSatisfyingExactly( + event -> event.hasName("before-event"), + event -> event.hasName("after-event")); + } + }, span -> span.hasName("sendResponseWithException") .hasKind(SpanKind.INTERNAL) .hasParent(trace.getSpan(0)))); + + if (emitExceptionAsLogs()) { + assertExceptionLogs( + new Exception("exception after sending response"), "http.server.request.exception"); + } } } diff --git a/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/junit/http/AbstractHttpClientTest.java b/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/junit/http/AbstractHttpClientTest.java index da8ff53be8f0..2ce3147cfb28 100644 --- a/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/junit/http/AbstractHttpClientTest.java +++ b/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/junit/http/AbstractHttpClientTest.java @@ -7,12 +7,18 @@ import static io.opentelemetry.api.common.AttributeKey.longKey; import static io.opentelemetry.api.common.AttributeKey.stringArrayKey; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsLogs; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsSpanEvents; import static io.opentelemetry.instrumentation.testing.junit.service.SemconvServiceStabilityUtil.maybeStablePeerService; import static io.opentelemetry.instrumentation.testing.util.TelemetryDataUtil.comparingRootSpanAttribute; import static io.opentelemetry.instrumentation.testing.util.TelemetryDataUtil.orderByRootSpanName; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.satisfies; import static io.opentelemetry.semconv.ErrorAttributes.ERROR_TYPE; +import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_MESSAGE; +import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_STACKTRACE; +import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_TYPE; import static io.opentelemetry.semconv.HttpAttributes.HTTP_REQUEST_METHOD; import static io.opentelemetry.semconv.HttpAttributes.HTTP_REQUEST_METHOD_ORIGINAL; import static io.opentelemetry.semconv.HttpAttributes.HTTP_REQUEST_RESEND_COUNT; @@ -39,11 +45,13 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.logs.Severity; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.instrumentation.api.internal.HttpConstants; import io.opentelemetry.instrumentation.test.utils.PortUtils; import io.opentelemetry.instrumentation.testing.InstrumentationTestRunner; +import io.opentelemetry.sdk.logs.data.LogRecordData; import io.opentelemetry.sdk.testing.assertj.SpanDataAssert; import io.opentelemetry.sdk.testing.assertj.TraceAssert; import io.opentelemetry.sdk.trace.data.SpanData; @@ -65,6 +73,7 @@ import java.util.function.Consumer; import java.util.stream.IntStream; import javax.annotation.Nullable; +import org.awaitility.Awaitility; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -399,13 +408,20 @@ void circularRedirects() { assertClientSpan( span, uri, method, options.getResponseCodeOnRedirectError(), null) .hasNoParent() - .hasException(clientError)); + .hasException(emitExceptionAsSpanEvents() ? clientError : null)); for (int i = 0; i < options.getMaxRedirects(); i++) { assertions.add(span -> assertServerSpan(span).hasParent(trace.getSpan(0))); } trace.hasSpansSatisfyingExactly(assertions); }); } + + // For low-level instrumentation, individual redirect requests succeed (302) and the overall + // redirect-loop exception is thrown above the instrumentation layer, so no exception log is + // emitted by the instrumenter. + if (emitExceptionAsLogs() && !options.isLowLevelInstrumentation()) { + assertClientExceptionLog(clientError, "http.client.request.exception"); + } } private Consumer makeCircularRedirectAssertForLolLevelTrace( @@ -595,12 +611,17 @@ void connectionErrorUnopenedPort() { .hasKind(SpanKind.INTERNAL) .hasNoParent() .hasStatus(StatusData.error()) - .hasException(ex), + .hasException(emitExceptionAsSpanEvents() ? ex : null), span -> assertClientSpan(span, uri, method, null, null) .hasParent(trace.getSpan(0)) - .hasException(clientError)); + .hasException(emitExceptionAsSpanEvents() ? clientError : null)); }); + + if (emitExceptionAsLogs()) { + assertParentExceptionLog(ex); + assertClientExceptionLog(clientError, "http.client.request.exception"); + } } @Test @@ -635,10 +656,14 @@ void connectionErrorUnopenedPortWithCallback() throws Exception { span -> assertClientSpan(span, uri, method, null, null) .hasParent(trace.getSpan(0)) - .hasException(clientError), + .hasException(emitExceptionAsSpanEvents() ? clientError : null), span -> span.hasName("callback").hasKind(SpanKind.INTERNAL).hasParent(trace.getSpan(0))); }); + + if (emitExceptionAsLogs()) { + assertClientExceptionLog(clientError, "http.client.request.exception"); + } } @Test @@ -667,12 +692,17 @@ void connectionErrorNonRoutableAddress() { .hasKind(SpanKind.INTERNAL) .hasNoParent() .hasStatus(StatusData.error()) - .hasException(ex), + .hasException(emitExceptionAsSpanEvents() ? ex : null), span -> assertClientSpan(span, uri, method, null, null) .hasParent(trace.getSpan(0)) - .hasException(clientError)); + .hasException(emitExceptionAsSpanEvents() ? clientError : null)); }); + + if (emitExceptionAsLogs()) { + assertParentExceptionLog(ex); + assertClientExceptionLog(clientError, "http.client.request.exception"); + } } @Test @@ -701,13 +731,18 @@ void readTimedOut() { .hasKind(SpanKind.INTERNAL) .hasNoParent() .hasStatus(StatusData.error()) - .hasException(ex), + .hasException(emitExceptionAsSpanEvents() ? ex : null), span -> assertClientSpan(span, uri, method, null, null) .hasParent(trace.getSpan(0)) - .hasException(clientError), + .hasException(emitExceptionAsSpanEvents() ? clientError : null), span -> assertServerSpan(span).hasParent(trace.getSpan(1))); }); + + if (emitExceptionAsLogs()) { + assertParentExceptionLog(ex); + assertClientExceptionLog(clientError, "http.client.request.exception"); + } } @DisabledIfSystemProperty( @@ -1158,7 +1193,7 @@ protected SpanDataAssert assertClientSpan( .satisfies( spanData -> assertThat(spanData.getInstrumentationScopeInfo().getSchemaUrl()) - .isEqualTo(SchemaUrls.V1_37_0)); + .isEqualTo(SchemaUrls.V1_41_0)); } protected static SpanDataAssert assertServerSpan(SpanDataAssert span) { @@ -1216,4 +1251,62 @@ private static int defaultPortForScheme(String scheme) { } throw new IllegalArgumentException("Unexpected URI scheme: " + scheme); } + + private void assertParentExceptionLog(Throwable exception) { + String exceptionType = exceptionType(exception); + Awaitility.await() + .untilAsserted( + () -> { + List logs = + testing.getExportedLogRecords().stream() + .filter(log -> "exception".equals(log.getEventName())) + .filter(log -> exceptionType.equals(log.getAttributes().get(EXCEPTION_TYPE))) + .collect(toList()); + + assertThat(logs).hasSize(1); + assertThat(logs.get(0)) + .hasSeverity(Severity.WARN) + .hasAttributesSatisfyingExactly( + equalTo(EXCEPTION_TYPE, exceptionType), + satisfies( + EXCEPTION_MESSAGE, + val -> { + if (exception.getMessage() != null) { + val.isEqualTo(exception.getMessage()); + } + }), + satisfies(EXCEPTION_STACKTRACE, val -> val.isNotNull())); + }); + } + + private void assertClientExceptionLog(Throwable exception, String eventName) { + String exceptionType = exceptionType(exception); + Awaitility.await() + .untilAsserted( + () -> { + List logs = + testing.getExportedLogRecords().stream() + .filter(log -> eventName.equals(log.getEventName())) + .collect(toList()); + + assertThat(logs).hasSize(1); + assertThat(logs.get(0)) + .hasSeverity(Severity.WARN) + .hasAttributesSatisfyingExactly( + equalTo(EXCEPTION_TYPE, exceptionType), + satisfies( + EXCEPTION_MESSAGE, + val -> { + if (exception.getMessage() != null) { + val.isEqualTo(exception.getMessage()); + } + }), + satisfies(EXCEPTION_STACKTRACE, val -> val.isNotNull())); + }); + } + + private static String exceptionType(Throwable exception) { + String canonicalName = exception.getClass().getCanonicalName(); + return canonicalName != null ? canonicalName : exception.getClass().getName(); + } } diff --git a/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/junit/http/AbstractHttpServerTest.java b/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/junit/http/AbstractHttpServerTest.java index 57dc67b8c5e7..5742c2477764 100644 --- a/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/junit/http/AbstractHttpServerTest.java +++ b/testing-common/src/main/java/io/opentelemetry/instrumentation/testing/junit/http/AbstractHttpServerTest.java @@ -8,6 +8,8 @@ import static io.opentelemetry.api.common.AttributeKey.longKey; import static io.opentelemetry.api.common.AttributeKey.stringArrayKey; import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsLogs; +import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsSpanEvents; import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.CAPTURE_HEADERS; import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.CAPTURE_PARAMETERS; import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.ERROR; @@ -20,9 +22,13 @@ import static io.opentelemetry.instrumentation.testing.junit.http.ServerEndpoint.SUCCESS; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.satisfies; import static io.opentelemetry.semconv.ClientAttributes.CLIENT_ADDRESS; import static io.opentelemetry.semconv.ClientAttributes.CLIENT_PORT; import static io.opentelemetry.semconv.ErrorAttributes.ERROR_TYPE; +import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_MESSAGE; +import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_STACKTRACE; +import static io.opentelemetry.semconv.ExceptionAttributes.EXCEPTION_TYPE; import static io.opentelemetry.semconv.HttpAttributes.HTTP_REQUEST_METHOD; import static io.opentelemetry.semconv.HttpAttributes.HTTP_REQUEST_METHOD_ORIGINAL; import static io.opentelemetry.semconv.HttpAttributes.HTTP_RESPONSE_STATUS_CODE; @@ -42,12 +48,14 @@ import static java.nio.charset.StandardCharsets.US_ASCII; import static java.util.Collections.singletonList; import static java.util.concurrent.TimeUnit.SECONDS; +import static java.util.stream.Collectors.toList; import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.google.errorprone.annotations.CanIgnoreReturnValue; import io.opentelemetry.api.GlobalOpenTelemetry; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.logs.Severity; import io.opentelemetry.api.trace.Span; import io.opentelemetry.api.trace.SpanKind; import io.opentelemetry.context.Context; @@ -57,6 +65,7 @@ import io.opentelemetry.instrumentation.testing.GlobalTraceUtil; import io.opentelemetry.instrumentation.testing.util.ThrowingRunnable; import io.opentelemetry.instrumentation.testing.util.ThrowingSupplier; +import io.opentelemetry.sdk.logs.data.LogRecordData; import io.opentelemetry.sdk.testing.assertj.SpanDataAssert; import io.opentelemetry.sdk.testing.assertj.TraceAssert; import io.opentelemetry.sdk.trace.data.SpanData; @@ -1016,18 +1025,50 @@ protected void assertTheTraces( } testing.waitAndAssertTraces(assertions); + + if (endpoint == EXCEPTION + && emitExceptionAsLogs() + && options.hasExceptionOnServerSpan.test(endpoint)) { + assertExceptionLogs(options.expectedException, "http.server.request.exception"); + } } @CanIgnoreReturnValue protected SpanDataAssert assertControllerSpan(SpanDataAssert span, Throwable expectedException) { span.hasName("controller").hasKind(SpanKind.INTERNAL); if (expectedException != null) { - span.hasStatus(StatusData.error()); - span.hasException(expectedException); + span.hasStatus(StatusData.error()) + .hasException(emitExceptionAsSpanEvents() ? expectedException : null); } return span; } + protected void assertExceptionLogs(Throwable expectedException, String expectedEventName) { + Awaitility.await() + .untilAsserted( + () -> { + List logs = + testing.getExportedLogRecords().stream() + .filter(log -> expectedEventName.equals(log.getEventName())) + .collect(toList()); + + assertThat(logs).hasSize(1); + assertThat(logs.get(0)) + .hasSeverity(Severity.ERROR) + .hasEventName(expectedEventName) + .hasAttributesSatisfyingExactly( + equalTo(EXCEPTION_TYPE, expectedException.getClass().getName()), + satisfies( + EXCEPTION_MESSAGE, + val -> { + if (expectedException.getMessage() != null) { + val.isEqualTo(expectedException.getMessage()); + } + }), + satisfies(EXCEPTION_STACKTRACE, val -> val.isNotNull())); + }); + } + protected SpanDataAssert assertHandlerSpan( SpanDataAssert span, String method, ServerEndpoint endpoint) { throw new UnsupportedOperationException( @@ -1083,13 +1124,13 @@ protected SpanDataAssert assertServerSpan( .satisfies( spanData -> assertThat(spanData.getInstrumentationScopeInfo().getSchemaUrl()) - .isEqualTo(SchemaUrls.V1_37_0)); + .isEqualTo(SchemaUrls.V1_41_0)); if (statusCode >= 500) { span.hasStatus(StatusData.error()); } if (endpoint == EXCEPTION && options.hasExceptionOnServerSpan.test(endpoint)) { - span.hasException(options.expectedException); + span.hasException(emitExceptionAsSpanEvents() ? options.expectedException : null); } span.hasAttributesSatisfying(