diff --git a/instrumentation-api-incubator/build.gradle.kts b/instrumentation-api-incubator/build.gradle.kts index 2a0d8dc8bd50..dc48bfa745f9 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.opt-in=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.opt-in=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 f817fe5d4a56..e52000e16e24 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 @@ -14,6 +14,7 @@ import io.opentelemetry.instrumentation.api.incubator.config.internal.CommonConfig; import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpClientExperimentalMetrics; import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpClientServicePeerAttributesExtractor; +import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpExceptionEventExtractors; import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpExperimentalAttributesExtractor; import io.opentelemetry.instrumentation.api.incubator.semconv.http.internal.HttpClientUrlTemplateUtil; import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; @@ -226,6 +227,7 @@ public Instrumenter build() { .addAttributesExtractors(additionalExtractors) .addOperationMetrics(HttpClientMetrics.get()) .setSchemaUrl(SchemaUrls.V1_37_0); + Experimental.setExceptionEventExtractor(builder, HttpExceptionEventExtractors.client()); if (emitExperimentalHttpClientTelemetry) { builder .addAttributesExtractor(HttpExperimentalAttributesExtractor.create(attributesGetter)) 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 084e331fa6a6..bce4d3d7093f 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 @@ -11,6 +11,7 @@ import io.opentelemetry.api.OpenTelemetry; import io.opentelemetry.context.propagation.TextMapGetter; import io.opentelemetry.instrumentation.api.incubator.config.internal.CommonConfig; +import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpExceptionEventExtractors; import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpExperimentalAttributesExtractor; import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpServerExperimentalMetrics; import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor; @@ -231,6 +232,7 @@ public InstrumenterBuilder instrumenterBuilder() { .addContextCustomizer(httpServerRouteBuilder.build()) .addOperationMetrics(HttpServerMetrics.get()) .setSchemaUrl(SchemaUrls.V1_37_0); + Experimental.setExceptionEventExtractor(builder, HttpExceptionEventExtractors.server()); if (emitExperimentalHttpServerTelemetry) { builder .addAttributesExtractor(HttpExperimentalAttributesExtractor.create(attributesGetter)) diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/instrumenter/ExceptionEventExtractor.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/instrumenter/ExceptionEventExtractor.java new file mode 100644 index 000000000000..9ec7eb95f315 --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/instrumenter/ExceptionEventExtractor.java @@ -0,0 +1,40 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.instrumenter; + +import io.opentelemetry.api.logs.LogRecordBuilder; +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.context.Context; +import io.opentelemetry.instrumentation.api.internal.InternalExceptionEventExtractor; + +/** + * Extractor that populates the exception event {@link LogRecordBuilder} for a request. This allows + * instrumentations to set the event name, severity, and any additional attributes on the exception + * log event. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +@FunctionalInterface +public interface ExceptionEventExtractor extends InternalExceptionEventExtractor { + + /** + * Returns an {@link ExceptionEventExtractor} that always sets the given event name and severity. + */ + static ExceptionEventExtractor create(String eventName, Severity severity) { + return (logRecordBuilder, context, request) -> { + logRecordBuilder.setEventName(eventName); + logRecordBuilder.setSeverity(severity); + }; + } + + /** + * Populates the exception event {@link LogRecordBuilder} with the event name, severity, and any + * additional attributes for the given context and request. + */ + @Override + void extract(LogRecordBuilder logRecordBuilder, Context context, REQUEST request); +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbExceptionEventExtractors.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbExceptionEventExtractors.java new file mode 100644 index 000000000000..88bf90614ea9 --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/db/DbExceptionEventExtractors.java @@ -0,0 +1,25 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.db; + +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.instrumentation.api.incubator.instrumenter.ExceptionEventExtractor; + +/** + * {@link ExceptionEventExtractor} constants for DB client instrumentations. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public final class DbExceptionEventExtractors { + + /** Exception event extractor for DB client spans. */ + public static ExceptionEventExtractor client() { + return ExceptionEventExtractor.create("db.client.operation.exception", Severity.WARN); + } + + private DbExceptionEventExtractors() {} +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/GenAiExceptionEventExtractors.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/GenAiExceptionEventExtractors.java new file mode 100644 index 000000000000..5cd5487fb0ca --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/genai/GenAiExceptionEventExtractors.java @@ -0,0 +1,25 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.genai; + +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.instrumentation.api.incubator.instrumenter.ExceptionEventExtractor; + +/** + * {@link ExceptionEventExtractor} constants for GenAI client instrumentations. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public final class GenAiExceptionEventExtractors { + + /** Exception event extractor for GenAI client spans. */ + public static ExceptionEventExtractor client() { + return ExceptionEventExtractor.create("gen_ai.client.operation.exception", Severity.WARN); + } + + private GenAiExceptionEventExtractors() {} +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/http/HttpExceptionEventExtractors.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/http/HttpExceptionEventExtractors.java new file mode 100644 index 000000000000..57e28b51cffe --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/http/HttpExceptionEventExtractors.java @@ -0,0 +1,30 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.http; + +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.instrumentation.api.incubator.instrumenter.ExceptionEventExtractor; + +/** + * {@link ExceptionEventExtractor} constants for HTTP client and server instrumentations. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public final class HttpExceptionEventExtractors { + + /** Exception event extractor for HTTP client spans. */ + public static ExceptionEventExtractor client() { + return ExceptionEventExtractor.create("http.client.request.exception", Severity.WARN); + } + + /** Exception event extractor for HTTP server spans. */ + public static ExceptionEventExtractor server() { + return ExceptionEventExtractor.create("http.server.request.exception", Severity.ERROR); + } + + private HttpExceptionEventExtractors() {} +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/messaging/MessagingExceptionEventExtractors.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/messaging/MessagingExceptionEventExtractors.java new file mode 100644 index 000000000000..791bdfb09f6d --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/messaging/MessagingExceptionEventExtractors.java @@ -0,0 +1,30 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.messaging; + +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.instrumentation.api.incubator.instrumenter.ExceptionEventExtractor; + +/** + * {@link ExceptionEventExtractor} constants for messaging instrumentations. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public final class MessagingExceptionEventExtractors { + + /** Exception event extractor for messaging client operation spans. */ + public static ExceptionEventExtractor client() { + return ExceptionEventExtractor.create("messaging.client.operation.exception", Severity.WARN); + } + + /** Exception event extractor for messaging process spans. */ + public static ExceptionEventExtractor process() { + return ExceptionEventExtractor.create("messaging.process.exception", Severity.ERROR); + } + + private MessagingExceptionEventExtractors() {} +} diff --git a/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/rpc/RpcExceptionEventExtractors.java b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/rpc/RpcExceptionEventExtractors.java new file mode 100644 index 000000000000..90f4ab58e3a0 --- /dev/null +++ b/instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/semconv/rpc/RpcExceptionEventExtractors.java @@ -0,0 +1,30 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.incubator.semconv.rpc; + +import io.opentelemetry.api.logs.Severity; +import io.opentelemetry.instrumentation.api.incubator.instrumenter.ExceptionEventExtractor; + +/** + * {@link ExceptionEventExtractor} constants for RPC client and server instrumentations. + * + *

This class is internal and is hence not for public use. Its APIs are unstable and can change + * at any time. + */ +public final class RpcExceptionEventExtractors { + + /** Exception event extractor for RPC client spans. */ + public static ExceptionEventExtractor client() { + return ExceptionEventExtractor.create("rpc.client.call.exception", Severity.WARN); + } + + /** Exception event extractor for RPC server spans. */ + public static ExceptionEventExtractor server() { + return ExceptionEventExtractor.create("rpc.server.call.exception", Severity.ERROR); + } + + private RpcExceptionEventExtractors() {} +} diff --git a/instrumentation-api/build.gradle.kts b/instrumentation-api/build.gradle.kts index f22e94e0a33e..df55de2903d0 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.opt-in=logs") + } + + val testExceptionSignalLogsDup by registering(Test::class) { + testClassesDirs = sourceSets.test.get().output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + jvmArgs("-Dotel.semconv.exception.signal.opt-in=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..9471a1ceee79 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(); + } else { + this.logger = null; + this.exceptionEventExtractor = null; + } } /** @@ -260,7 +279,12 @@ private void doEnd( if (error != null) { error = errorCauseExtractor.extract(error); - span.recordException(error); + if (emitExceptionAsSpanEvents()) { + span.recordException(error); + } + if (emitExceptionAsLogs() && exceptionEventExtractor != null) { + emitExceptionLog(context, error, request); + } } UnsafeAttributes attributes = new UnsafeAttributes(); @@ -301,6 +325,27 @@ private void doEnd( } } + private void emitExceptionLog(Context context, Throwable throwable, REQUEST request) { + 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); + exceptionEventExtractor.extract(logRecordBuilder, context, request); + logRecordBuilder.setException(throwable); + logRecordBuilder.emit(); + } + + private static + InternalExceptionEventExtractor defaultExceptionEventExtractor() { + return (logRecordBuilder, context, request) -> { + logRecordBuilder.setEventName("exception"); + logRecordBuilder.setSeverity(Severity.WARN); + }; + } + 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..c8bdad2df47c 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,23 @@ public static void internalAddOperationListenerAttributesExt Experimental.operationListenerAttributesExtractorAdder = (BiConsumer) operationListenerAttributesExtractorAdder; } + + /** + * Sets the {@link InternalExceptionEventExtractor} that will determine the exception event name + * and severity. Only used when stable exception semconv is enabled. + */ + 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..f1b8f6b04cfd --- /dev/null +++ b/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/InternalExceptionEventExtractor.java @@ -0,0 +1,26 @@ +/* + * 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. Public API is in {@code + * ExceptionEventExtractor} in the incubator module. + * + *

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..d46eb17fa0c9 --- /dev/null +++ b/instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/SemconvExceptionSignal.java @@ -0,0 +1,57 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.api.internal; + +import java.util.logging.Logger; + +/** + * This class is internal and is hence not for public use. Its APIs are unstable and can change at + * any time. + */ +public final class SemconvExceptionSignal { + + private static final Logger logger = Logger.getLogger(SemconvExceptionSignal.class.getName()); + + private static final boolean emitExceptionAsSpanEvents; + private static final boolean emitExceptionAsLogs; + + static { + boolean spanEvents = true; + boolean logs = false; + + String value = System.getProperty("otel.semconv.exception.signal.opt-in"); + if (value == null) { + value = System.getenv("OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN"); + } + if (value != null) { + if (value.equals("logs")) { + spanEvents = false; + logs = true; + } else if (value.equals("logs/dup")) { + spanEvents = true; + logs = true; + } else if (!value.isEmpty()) { + logger.warning( + "Unrecognized value for otel.semconv.exception.signal.opt-in: \"" + + value + + "\". Expected \"logs\" or \"logs/dup\". Defaulting to span events."); + } + } + + emitExceptionAsSpanEvents = spanEvents; + emitExceptionAsLogs = logs; + } + + public static boolean emitExceptionAsSpanEvents() { + return emitExceptionAsSpanEvents; + } + + public static boolean emitExceptionAsLogs() { + return emitExceptionAsLogs; + } + + 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..c18f0e6b552b 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,18 @@ 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.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 +228,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, context1, 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 +356,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, context1, 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 +377,116 @@ 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()))); + 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 consumer_error_with_explicit_severity() { + // Verifies that log event severity comes from setExceptionEventExtractor, not from span status. + // The span gets ERROR status (because there's an error), but the exception log event + // should use the extractor's severity (WARN), not mirror the span status. + InstrumenterBuilder, Map> builder = + Instrumenter., Map>builder( + otelTesting.getOpenTelemetry(), "test", unused -> "span"); + Experimental.setExceptionEventExtractor( + builder, + (logRecordBuilder, context1, request) -> { + logRecordBuilder.setEventName("messaging.client.operation.exception"); + logRecordBuilder.setSeverity(Severity.WARN); + }); + Instrumenter, Map> instrumenter = + builder.buildConsumerInstrumenter(new MapGetter()); + + 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") + .hasKind(SpanKind.CONSUMER) + .hasStatus(StatusData.error()) + .hasException(emitExceptionAsSpanEvents() ? error : null))); + + if (emitExceptionAsLogs()) { + List logs = otelTesting.getLogRecords(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0)) + // Should be WARN (from setExceptionEventExtractor), not ERROR (from span status) + .hasSeverity(Severity.WARN) + .hasEventName("messaging.client.operation.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 ExceptionEventExtractor is explicitly set, 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()) + .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