Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
0d2f88b
Add opt-in support for emitting exceptions as log signals
trask Feb 22, 2026
ca470b9
Merge branch 'main' into exceptions-over-log-signal
trask Feb 26, 2026
5732f14
Merge remote-tracking branch 'upstream/main' into exceptions-over-log…
trask Mar 3, 2026
d06b0e9
Add default ExceptionEventExtractor
trask Mar 3, 2026
9b2df30
update assertion pattern
trask Mar 9, 2026
79d41ef
Merge branch 'main' into exceptions-over-log-signal
trask Mar 9, 2026
02f15a8
update
trask Mar 9, 2026
7964b79
Merge remote-tracking branch 'upstream/main' into exceptions-over-log…
trask Mar 9, 2026
1df0fb1
update
trask Mar 10, 2026
078fb6b
update fallback event name based on latest semconv PR updates
trask Mar 10, 2026
bc15ede
spotless
trask Mar 10, 2026
fdde948
Merge branch 'main' into exceptions-over-log-signal
trask Apr 30, 2026
debd045
update to latest semconv
trask May 1, 2026
54c294e
simplify
trask May 1, 2026
ce9ef4b
Update HTTP test schema URL expectations
trask May 2, 2026
fa4c66c
Update Netty 3.8 connection schema URL
trask May 2, 2026
cb39859
Fix PR CI failures
trask May 3, 2026
7d2faa2
back
trask May 4, 2026
ad1c897
spotless
trask May 4, 2026
1c3e7b9
fix
trask May 4, 2026
ba08868
Apply review feedback
trask May 8, 2026
d82a2e2
tests
trask May 8, 2026
8d4f7bb
fix
trask May 8, 2026
403d742
spotless
trask May 8, 2026
ed72463
fix
trask May 8, 2026
6081ccf
Address review comments: collectMetadata for all Test tasks; include …
trask May 8, 2026
e69afd1
up
trask May 8, 2026
e931a9f
Potential fix for pull request finding
trask May 8, 2026
ea5c42e
declarative config
trask May 8, 2026
15bcc10
review
trask May 8, 2026
4768165
review
trask May 8, 2026
b6295c4
Fix WebFlux exception signal log assertions
trask May 9, 2026
88d9766
spotless
trask May 9, 2026
2ddd571
Assert no exception span event in logs-only mode
trask May 9, 2026
c22e3fa
fix
trask May 9, 2026
cde1b63
Merge branch 'main' into exceptions-over-log-signal
trask May 9, 2026
2d29ba0
review
trask May 9, 2026
c204a4d
Drop redundant Assertions.assertThat static import in spring-webflux …
trask May 11, 2026
8ea9f27
Merge remote-tracking branch 'upstream/main' into exceptions-over-log…
trask May 11, 2026
0051a0b
rename opt-in to preview
trask May 12, 2026
d0ecb52
Potential fix for pull request finding
trask May 12, 2026
f721eef
Fix Netty connect exception log event name
trask May 15, 2026
4ff6027
Clarify val lambda-parameter rule excludes custom assertion helpers
trask Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/instructions/java-tests.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
16 changes: 15 additions & 1 deletion instrumentation-api-incubator/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -225,7 +226,8 @@ public Instrumenter<REQUEST, RESPONSE> 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))
Expand All @@ -245,7 +247,17 @@ InstrumenterBuilder<BUILDERREQUEST, BUILDERRESPONSE> instrumenterBuilder(
SpanNameExtractor<? super BUILDERREQUEST> spanNameExtractor) {
return Instrumenter.<BUILDERREQUEST, BUILDERRESPONSE>builder(
openTelemetry, instrumentationName, spanNameExtractor)
.setSchemaUrl(SchemaUrls.V1_37_0);
.setSchemaUrl(SchemaUrls.V1_41_0);
}

public static <REQUEST> void setHttpClientExceptionEventExtractor(
InstrumenterBuilder<REQUEST, ?> builder) {
Experimental.setExceptionEventExtractor(
builder,
(logRecordBuilder, context, request) -> {
logRecordBuilder.setEventName("http.client.request.exception");
logRecordBuilder.setSeverity(Severity.WARN);
});
}

@CanIgnoreReturnValue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -230,7 +231,13 @@ public InstrumenterBuilder<REQUEST, RESPONSE> 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))
Expand Down
16 changes: 16 additions & 0 deletions instrumentation-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -72,6 +78,7 @@ public static <REQUEST, RESPONSE> InstrumenterBuilder<REQUEST, RESPONSE> builder

private final String instrumentationName;
private final Tracer tracer;
@Nullable private final Logger logger;
private final SpanNameExtractor<? super REQUEST> spanNameExtractor;
private final SpanKindExtractor<? super REQUEST> spanKindExtractor;
private final SpanStatusExtractor<? super REQUEST, ? super RESPONSE> spanStatusExtractor;
Expand All @@ -82,6 +89,7 @@ public static <REQUEST, RESPONSE> InstrumenterBuilder<REQUEST, RESPONSE> builder
private final AttributesExtractor<? super REQUEST, ? super RESPONSE>[]
operationListenerAttributesExtractors;
private final ErrorCauseExtractor errorCauseExtractor;
@Nullable private final InternalExceptionEventExtractor<? super REQUEST> exceptionEventExtractor;
private final boolean propagateOperationListenersToOnEnd;
private final boolean enabled;
private final SpanSuppressor spanSuppressor;
Expand All @@ -104,6 +112,17 @@ public static <REQUEST, RESPONSE> InstrumenterBuilder<REQUEST, RESPONSE> 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;
}
}

/**
Expand Down Expand Up @@ -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) {
Comment thread
trask marked this conversation as resolved.
Comment thread
trask marked this conversation as resolved.
emitExceptionLog(context, error, request, endTime);
}
Comment thread
trask marked this conversation as resolved.
}

UnsafeAttributes attributes = new UnsafeAttributes();
Expand Down Expand Up @@ -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();
Comment thread
laurit marked this conversation as resolved.
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 <REQUEST> InternalExceptionEventExtractor<REQUEST> defaultExceptionEventExtractor(
SpanKindExtractor<? super REQUEST> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -71,6 +73,7 @@ public final class InstrumenterBuilder<REQUEST, RESPONSE> {
SpanStatusExtractor<? super REQUEST, ? super RESPONSE> spanStatusExtractor =
SpanStatusExtractor.getDefault();
ErrorCauseExtractor errorCauseExtractor = ErrorCauseExtractor.getDefault();
@Nullable InternalExceptionEventExtractor<? super REQUEST> exceptionEventExtractor;
boolean propagateOperationListenersToOnEnd = false;
boolean enabled = true;

Expand All @@ -80,6 +83,10 @@ public final class InstrumenterBuilder<REQUEST, RESPONSE> {
builder.operationListenerAttributesExtractors.add(
requireNonNull(
operationListenerAttributesExtractor, "operationListenerAttributesExtractor")));
Experimental.internalSetExceptionEventExtractor(
(builder, exceptionEventExtractor) ->
builder.exceptionEventExtractor =
requireNonNull(exceptionEventExtractor, "exceptionEventExtractor"));
}

InstrumenterBuilder(
Expand Down Expand Up @@ -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<OperationListener> buildOperationListeners() {
// just copy the listeners list if there are no metrics registered
if (operationMetrics.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ public final class Experimental {
private static volatile BiConsumer<InstrumenterBuilder<?, ?>, AttributesExtractor<?, ?>>
operationListenerAttributesExtractorAdder;

@Nullable
private static volatile BiConsumer<InstrumenterBuilder<?, ?>, InternalExceptionEventExtractor<?>>
exceptionEventExtractorSetter;

private Experimental() {}

/**
Expand Down Expand Up @@ -117,4 +121,24 @@ public static <REQUEST, RESPONSE> 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 <REQUEST> void setExceptionEventExtractor(
InstrumenterBuilder<REQUEST, ?> builder,
InternalExceptionEventExtractor<? super REQUEST> exceptionEventExtractor) {
if (exceptionEventExtractorSetter != null) {
exceptionEventExtractorSetter.accept(builder, exceptionEventExtractor);
}
}
Comment thread
trask marked this conversation as resolved.

@SuppressWarnings({"rawtypes", "unchecked"}) // we lose the generic type information
public static <REQUEST> void internalSetExceptionEventExtractor(
BiConsumer<InstrumenterBuilder<REQUEST, ?>, InternalExceptionEventExtractor<? super REQUEST>>
exceptionEventExtractorSetter) {
Experimental.exceptionEventExtractorSetter = (BiConsumer) exceptionEventExtractorSetter;
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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<REQUEST> {

/**
* 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);
}
Loading
Loading