Skip to content

Commit b2035d0

Browse files
traskCopilot
andauthored
Add opt-in support for emitting exceptions as log signals (#16259)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 9aa050e commit b2035d0

25 files changed

Lines changed: 887 additions & 148 deletions

File tree

.github/instructions/java-tests.instructions.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,11 @@ reserved). Use `v` only for a nested inner-lambda parameter.
6666
This guidance applies only to attribute-assertion `satisfies(...)`; for
6767
`span.satisfies(...)`, `point.satisfies(...)`, etc. use a descriptive name
6868
(`spanData`, `pointData`, `result`).
69+
70+
It also applies only to lambdas written **directly inline** as the
71+
`satisfies(AttributeKey, lambda)` argument, where the attribute key already
72+
documents what is being asserted. Do **not** flag lambdas passed to a custom
73+
helper method (e.g. `assertExceptionLog(typeAssertion, messageAssertion)`),
74+
even though the parameter is the same `AbstractStringAssert` type. There a
75+
descriptive name documents which attribute each lambda asserts, and renaming
76+
multiple parameters to `val` loses that context.

declarative-config-bridge/src/main/java/io/opentelemetry/instrumentation/config/bridge/ConfigPropertiesBackedDeclarativeConfigProperties.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ public final class ConfigPropertiesBackedDeclarativeConfigProperties
5151
"general.sanitization.url.sensitive_query_parameters/development",
5252
"otel.instrumentation.sanitization.url.experimental.sensitive-query-parameters");
5353
SPECIAL_MAPPINGS.put("general.semconv_stability.opt_in", "otel.semconv-stability.opt-in");
54+
SPECIAL_MAPPINGS.put(
55+
"general.semconv_exception.signal.preview", "otel.semconv.exception.signal.preview");
5456
// moving common http, database, messaging, and gen_ai configs under common
5557
SPECIAL_MAPPINGS.put(
5658
"java.common.http.known_methods", "otel.instrumentation.http.known-methods");

instrumentation-api-incubator/build.gradle.kts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,21 @@ tasks {
102102
inputs.dir(jflexOutputDir)
103103
}
104104

105+
val testExceptionSignalLogs by registering(Test::class) {
106+
testClassesDirs = sourceSets.test.get().output.classesDirs
107+
classpath = sourceSets.test.get().runtimeClasspath
108+
jvmArgs("-Dotel.semconv.exception.signal.preview=logs")
109+
inputs.dir(jflexOutputDir)
110+
}
111+
112+
val testExceptionSignalLogsDup by registering(Test::class) {
113+
testClassesDirs = sourceSets.test.get().output.classesDirs
114+
classpath = sourceSets.test.get().runtimeClasspath
115+
jvmArgs("-Dotel.semconv.exception.signal.preview=logs/dup")
116+
inputs.dir(jflexOutputDir)
117+
}
118+
105119
check {
106-
dependsOn(testStableSemconv, testBothSemconv)
120+
dependsOn(testStableSemconv, testBothSemconv, testExceptionSignalLogs, testExceptionSignalLogsDup)
107121
}
108122
}

instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/builder/internal/DefaultHttpClientInstrumenterBuilder.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import com.google.errorprone.annotations.CanIgnoreReturnValue;
1111
import io.opentelemetry.api.OpenTelemetry;
12+
import io.opentelemetry.api.logs.Severity;
1213
import io.opentelemetry.context.Context;
1314
import io.opentelemetry.context.propagation.TextMapSetter;
1415
import io.opentelemetry.instrumentation.api.incubator.config.internal.CommonConfig;
@@ -225,7 +226,8 @@ public Instrumenter<REQUEST, RESPONSE> build() {
225226
.addAttributesExtractor(httpAttributesExtractorBuilder.build())
226227
.addAttributesExtractors(additionalExtractors)
227228
.addOperationMetrics(HttpClientMetrics.get())
228-
.setSchemaUrl(SchemaUrls.V1_37_0);
229+
.setSchemaUrl(SchemaUrls.V1_41_0);
230+
setHttpClientExceptionEventExtractor(builder);
229231
if (emitExperimentalHttpClientTelemetry) {
230232
builder
231233
.addAttributesExtractor(HttpExperimentalAttributesExtractor.create(attributesGetter))
@@ -245,7 +247,17 @@ InstrumenterBuilder<BUILDERREQUEST, BUILDERRESPONSE> instrumenterBuilder(
245247
SpanNameExtractor<? super BUILDERREQUEST> spanNameExtractor) {
246248
return Instrumenter.<BUILDERREQUEST, BUILDERRESPONSE>builder(
247249
openTelemetry, instrumentationName, spanNameExtractor)
248-
.setSchemaUrl(SchemaUrls.V1_37_0);
250+
.setSchemaUrl(SchemaUrls.V1_41_0);
251+
}
252+
253+
public static <REQUEST> void setHttpClientExceptionEventExtractor(
254+
InstrumenterBuilder<REQUEST, ?> builder) {
255+
Experimental.setExceptionEventExtractor(
256+
builder,
257+
(logRecordBuilder, context, request) -> {
258+
logRecordBuilder.setEventName("http.client.request.exception");
259+
logRecordBuilder.setSeverity(Severity.WARN);
260+
});
249261
}
250262

251263
@CanIgnoreReturnValue

instrumentation-api-incubator/src/main/java/io/opentelemetry/instrumentation/api/incubator/builder/internal/DefaultHttpServerInstrumenterBuilder.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import com.google.errorprone.annotations.CanIgnoreReturnValue;
1111
import io.opentelemetry.api.OpenTelemetry;
12+
import io.opentelemetry.api.logs.Severity;
1213
import io.opentelemetry.context.propagation.TextMapGetter;
1314
import io.opentelemetry.instrumentation.api.incubator.config.internal.CommonConfig;
1415
import io.opentelemetry.instrumentation.api.incubator.semconv.http.HttpExperimentalAttributesExtractor;
@@ -230,7 +231,13 @@ public InstrumenterBuilder<REQUEST, RESPONSE> instrumenterBuilder() {
230231
.addAttributesExtractors(additionalExtractors)
231232
.addContextCustomizer(httpServerRouteBuilder.build())
232233
.addOperationMetrics(HttpServerMetrics.get())
233-
.setSchemaUrl(SchemaUrls.V1_37_0);
234+
.setSchemaUrl(SchemaUrls.V1_41_0);
235+
Experimental.setExceptionEventExtractor(
236+
builder,
237+
(logRecordBuilder, context, request) -> {
238+
logRecordBuilder.setEventName("http.server.request.exception");
239+
logRecordBuilder.setSeverity(Severity.ERROR);
240+
});
234241
if (emitExperimentalHttpServerTelemetry) {
235242
builder
236243
.addAttributesExtractor(HttpExperimentalAttributesExtractor.create(attributesGetter))

instrumentation-api/build.gradle.kts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,20 @@ tasks {
4444
jvmArgs("--add-opens=java.base/java.util=ALL-UNNAMED")
4545
jvmArgs("-XX:+IgnoreUnrecognizedVMOptions")
4646
}
47+
48+
val testExceptionSignalLogs by registering(Test::class) {
49+
testClassesDirs = sourceSets.test.get().output.classesDirs
50+
classpath = sourceSets.test.get().runtimeClasspath
51+
jvmArgs("-Dotel.semconv.exception.signal.preview=logs")
52+
}
53+
54+
val testExceptionSignalLogsDup by registering(Test::class) {
55+
testClassesDirs = sourceSets.test.get().output.classesDirs
56+
classpath = sourceSets.test.get().runtimeClasspath
57+
jvmArgs("-Dotel.semconv.exception.signal.preview=logs/dup")
58+
}
59+
60+
check {
61+
dependsOn(testExceptionSignalLogs, testExceptionSignalLogsDup)
62+
}
4763
}

instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/instrumenter/Instrumenter.java

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@
55

66
package io.opentelemetry.instrumentation.api.instrumenter;
77

8+
import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsLogs;
9+
import static io.opentelemetry.instrumentation.api.internal.SemconvExceptionSignal.emitExceptionAsSpanEvents;
810
import static java.util.concurrent.TimeUnit.SECONDS;
911

1012
import io.opentelemetry.api.OpenTelemetry;
13+
import io.opentelemetry.api.logs.LogRecordBuilder;
14+
import io.opentelemetry.api.logs.Logger;
15+
import io.opentelemetry.api.logs.Severity;
1116
import io.opentelemetry.api.trace.Span;
1217
import io.opentelemetry.api.trace.SpanBuilder;
1318
import io.opentelemetry.api.trace.SpanKind;
@@ -18,6 +23,7 @@
1823
import io.opentelemetry.instrumentation.api.internal.InstrumenterAccess;
1924
import io.opentelemetry.instrumentation.api.internal.InstrumenterContext;
2025
import io.opentelemetry.instrumentation.api.internal.InstrumenterUtil;
26+
import io.opentelemetry.instrumentation.api.internal.InternalExceptionEventExtractor;
2127
import io.opentelemetry.instrumentation.api.internal.SupportabilityMetrics;
2228
import java.time.Instant;
2329
import javax.annotation.Nullable;
@@ -72,6 +78,7 @@ public static <REQUEST, RESPONSE> InstrumenterBuilder<REQUEST, RESPONSE> builder
7278

7379
private final String instrumentationName;
7480
private final Tracer tracer;
81+
@Nullable private final Logger logger;
7582
private final SpanNameExtractor<? super REQUEST> spanNameExtractor;
7683
private final SpanKindExtractor<? super REQUEST> spanKindExtractor;
7784
private final SpanStatusExtractor<? super REQUEST, ? super RESPONSE> spanStatusExtractor;
@@ -82,6 +89,7 @@ public static <REQUEST, RESPONSE> InstrumenterBuilder<REQUEST, RESPONSE> builder
8289
private final AttributesExtractor<? super REQUEST, ? super RESPONSE>[]
8390
operationListenerAttributesExtractors;
8491
private final ErrorCauseExtractor errorCauseExtractor;
92+
@Nullable private final InternalExceptionEventExtractor<? super REQUEST> exceptionEventExtractor;
8593
private final boolean propagateOperationListenersToOnEnd;
8694
private final boolean enabled;
8795
private final SpanSuppressor spanSuppressor;
@@ -104,6 +112,17 @@ public static <REQUEST, RESPONSE> InstrumenterBuilder<REQUEST, RESPONSE> builder
104112
this.propagateOperationListenersToOnEnd = builder.propagateOperationListenersToOnEnd;
105113
this.enabled = builder.enabled;
106114
this.spanSuppressor = builder.buildSpanSuppressor();
115+
116+
if (emitExceptionAsLogs()) {
117+
this.logger = builder.buildLogger();
118+
this.exceptionEventExtractor =
119+
builder.exceptionEventExtractor != null
120+
? builder.exceptionEventExtractor
121+
: defaultExceptionEventExtractor(this.spanKindExtractor);
122+
} else {
123+
this.logger = null;
124+
this.exceptionEventExtractor = null;
125+
}
107126
}
108127

109128
/**
@@ -260,7 +279,13 @@ private void doEnd(
260279

261280
if (error != null) {
262281
error = errorCauseExtractor.extract(error);
263-
span.recordException(error);
282+
if (emitExceptionAsSpanEvents()) {
283+
span.recordException(error);
284+
}
285+
// Exception logs are intentionally emitted even when the span is not recording.
286+
if (emitExceptionAsLogs() && exceptionEventExtractor != null) {
287+
emitExceptionLog(context, error, request, endTime);
288+
}
264289
}
265290

266291
UnsafeAttributes attributes = new UnsafeAttributes();
@@ -301,6 +326,40 @@ private void doEnd(
301326
}
302327
}
303328

329+
private void emitExceptionLog(
330+
Context context, Throwable throwable, REQUEST request, @Nullable Instant endTime) {
331+
if (logger == null || exceptionEventExtractor == null) {
332+
// this condition is to keep nullaway happy
333+
// doEnd already guards on exceptionEventExtractor != null, so this is unreachable
334+
return;
335+
}
336+
LogRecordBuilder logRecordBuilder = logger.logRecordBuilder();
337+
logRecordBuilder.setContext(context);
338+
if (endTime != null) {
339+
logRecordBuilder.setTimestamp(endTime);
340+
}
341+
exceptionEventExtractor.extract(logRecordBuilder, context, request);
342+
logRecordBuilder.setException(throwable);
343+
logRecordBuilder.emit();
344+
}
345+
346+
// Per semconv
347+
// (https://opentelemetry.io/docs/specs/semconv/general/recording-errors/#errors-in-logs),
348+
// SERVER and CONSUMER spans should record exceptions with ERROR severity, while CLIENT and
349+
// PRODUCER spans should use WARN.
350+
private static <REQUEST> InternalExceptionEventExtractor<REQUEST> defaultExceptionEventExtractor(
351+
SpanKindExtractor<? super REQUEST> spanKindExtractor) {
352+
return (logRecordBuilder, context, request) -> {
353+
logRecordBuilder.setEventName("exception");
354+
SpanKind spanKind = spanKindExtractor.extract(request);
355+
Severity severity =
356+
(spanKind == SpanKind.SERVER || spanKind == SpanKind.CONSUMER)
357+
? Severity.ERROR
358+
: Severity.WARN;
359+
logRecordBuilder.setSeverity(severity);
360+
};
361+
}
362+
304363
private static long getNanos(@Nullable Instant time) {
305364
if (time == null) {
306365
return System.nanoTime();

instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/instrumenter/InstrumenterBuilder.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import io.opentelemetry.api.OpenTelemetry;
1515
import io.opentelemetry.api.incubator.ExtendedOpenTelemetry;
1616
import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties;
17+
import io.opentelemetry.api.logs.LoggerBuilder;
1718
import io.opentelemetry.api.metrics.Meter;
1819
import io.opentelemetry.api.metrics.MeterBuilder;
1920
import io.opentelemetry.api.trace.SpanKind;
@@ -28,6 +29,7 @@
2829
import io.opentelemetry.instrumentation.api.internal.Experimental;
2930
import io.opentelemetry.instrumentation.api.internal.InstrumenterBuilderAccess;
3031
import io.opentelemetry.instrumentation.api.internal.InstrumenterUtil;
32+
import io.opentelemetry.instrumentation.api.internal.InternalExceptionEventExtractor;
3133
import io.opentelemetry.instrumentation.api.internal.InternalInstrumenterCustomizer;
3234
import io.opentelemetry.instrumentation.api.internal.InternalInstrumenterCustomizerProvider;
3335
import io.opentelemetry.instrumentation.api.internal.InternalInstrumenterCustomizerUtil;
@@ -71,6 +73,7 @@ public final class InstrumenterBuilder<REQUEST, RESPONSE> {
7173
SpanStatusExtractor<? super REQUEST, ? super RESPONSE> spanStatusExtractor =
7274
SpanStatusExtractor.getDefault();
7375
ErrorCauseExtractor errorCauseExtractor = ErrorCauseExtractor.getDefault();
76+
@Nullable InternalExceptionEventExtractor<? super REQUEST> exceptionEventExtractor;
7477
boolean propagateOperationListenersToOnEnd = false;
7578
boolean enabled = true;
7679

@@ -80,6 +83,10 @@ public final class InstrumenterBuilder<REQUEST, RESPONSE> {
8083
builder.operationListenerAttributesExtractors.add(
8184
requireNonNull(
8285
operationListenerAttributesExtractor, "operationListenerAttributesExtractor")));
86+
Experimental.internalSetExceptionEventExtractor(
87+
(builder, exceptionEventExtractor) ->
88+
builder.exceptionEventExtractor =
89+
requireNonNull(exceptionEventExtractor, "exceptionEventExtractor"));
8390
}
8491

8592
InstrumenterBuilder(
@@ -314,6 +321,18 @@ Tracer buildTracer() {
314321
return tracerBuilder.build();
315322
}
316323

324+
io.opentelemetry.api.logs.Logger buildLogger() {
325+
LoggerBuilder loggerBuilder = openTelemetry.getLogsBridge().loggerBuilder(instrumentationName);
326+
if (instrumentationVersion != null) {
327+
loggerBuilder.setInstrumentationVersion(instrumentationVersion);
328+
}
329+
String schemaUrl = getSchemaUrl();
330+
if (schemaUrl != null) {
331+
loggerBuilder.setSchemaUrl(schemaUrl);
332+
}
333+
return loggerBuilder.build();
334+
}
335+
317336
List<OperationListener> buildOperationListeners() {
318337
// just copy the listeners list if there are no metrics registered
319338
if (operationMetrics.isEmpty()) {

instrumentation-api/src/main/java/io/opentelemetry/instrumentation/api/internal/Experimental.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ public final class Experimental {
4040
private static volatile BiConsumer<InstrumenterBuilder<?, ?>, AttributesExtractor<?, ?>>
4141
operationListenerAttributesExtractorAdder;
4242

43+
@Nullable
44+
private static volatile BiConsumer<InstrumenterBuilder<?, ?>, InternalExceptionEventExtractor<?>>
45+
exceptionEventExtractorSetter;
46+
4347
private Experimental() {}
4448

4549
/**
@@ -117,4 +121,24 @@ public static <REQUEST, RESPONSE> void internalAddOperationListenerAttributesExt
117121
Experimental.operationListenerAttributesExtractorAdder =
118122
(BiConsumer) operationListenerAttributesExtractorAdder;
119123
}
124+
125+
/**
126+
* Sets the {@link InternalExceptionEventExtractor} that will determine the exception event name
127+
* and severity. Only used when emitting exceptions as logs is enabled via the {@code
128+
* otel.semconv.exception.signal.preview} flag.
129+
*/
130+
public static <REQUEST> void setExceptionEventExtractor(
131+
InstrumenterBuilder<REQUEST, ?> builder,
132+
InternalExceptionEventExtractor<? super REQUEST> exceptionEventExtractor) {
133+
if (exceptionEventExtractorSetter != null) {
134+
exceptionEventExtractorSetter.accept(builder, exceptionEventExtractor);
135+
}
136+
}
137+
138+
@SuppressWarnings({"rawtypes", "unchecked"}) // we lose the generic type information
139+
public static <REQUEST> void internalSetExceptionEventExtractor(
140+
BiConsumer<InstrumenterBuilder<REQUEST, ?>, InternalExceptionEventExtractor<? super REQUEST>>
141+
exceptionEventExtractorSetter) {
142+
Experimental.exceptionEventExtractorSetter = (BiConsumer) exceptionEventExtractorSetter;
143+
}
120144
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.api.internal;
7+
8+
import io.opentelemetry.api.logs.LogRecordBuilder;
9+
import io.opentelemetry.context.Context;
10+
11+
/**
12+
* Internal functional interface for exception event extraction.
13+
*
14+
* <p>This is temporary bridge API while exception event extraction is not available in the stable
15+
* instrumentation API artifact. This interface should be revisited when a public API is added.
16+
*
17+
* <p>This class is internal and is hence not for public use. Its APIs are unstable and can change
18+
* at any time.
19+
*/
20+
@FunctionalInterface
21+
public interface InternalExceptionEventExtractor<REQUEST> {
22+
23+
/**
24+
* Populates the exception event {@link LogRecordBuilder} with the event name, severity, and any
25+
* additional attributes for the given context and request.
26+
*/
27+
void extract(LogRecordBuilder logRecordBuilder, Context context, REQUEST request);
28+
}

0 commit comments

Comments
 (0)