Skip to content

Commit addcf1e

Browse files
authored
Propagate Log4j appender context through async loggers (#18937)
1 parent b0605d4 commit addcf1e

6 files changed

Lines changed: 540 additions & 28 deletions

File tree

instrumentation/log4j/log4j-appender-2.17/library/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,58 @@ The available settings are:
103103

104104
The `otel.event.name` key is supported in `MapMessage` entries and context data entries. When present, its value is used as the log event name and is not emitted as an attribute.
105105

106+
#### Async Loggers
107+
108+
When using Log4j async loggers, for example `AsyncRoot`, `AsyncLogger`, or Log4j's built-in
109+
`AsyncAppender`, Log4j creates the `LogEvent` on the application thread and later invokes appenders
110+
on a background thread. To make the `OpenTelemetryAppender` emit logs with the application thread's
111+
full OpenTelemetry `Context`, configure Log4j to use the OpenTelemetry appender context data
112+
injector:
113+
114+
```properties
115+
log4j2.ContextDataInjector=io.opentelemetry.instrumentation.log4j.appender.v2_17.OpenTelemetryAppenderContextDataInjector
116+
```
117+
118+
This is a Log4j component property and must be configured before Log4j initializes, for example via
119+
a JVM system property:
120+
121+
```shell
122+
-Dlog4j2.ContextDataInjector=io.opentelemetry.instrumentation.log4j.appender.v2_17.OpenTelemetryAppenderContextDataInjector
123+
```
124+
125+
or in a `log4j2.component.properties` file on the classpath. It cannot be configured reliably from
126+
`log4j2.xml`.
127+
128+
With the component property set, the `log4j2.xml` configuration can use normal Log4j async logger
129+
configuration:
130+
131+
```xml
132+
<Configuration status="WARN">
133+
<Appenders>
134+
<OpenTelemetry name="OpenTelemetryAppender"/>
135+
</Appenders>
136+
137+
<Loggers>
138+
<AsyncRoot level="info">
139+
<AppenderRef ref="OpenTelemetryAppender"/>
140+
</AsyncRoot>
141+
</Loggers>
142+
</Configuration>
143+
```
144+
145+
If your application already configures a custom `log4j2.ContextDataInjector`, configure it as the
146+
OpenTelemetry injector's delegate:
147+
148+
```properties
149+
log4j2.ContextDataInjector=io.opentelemetry.instrumentation.log4j.appender.v2_17.OpenTelemetryAppenderContextDataInjector
150+
otel.instrumentation.log4j-appender.context-data-injector.delegate=com.example.CustomContextDataInjector
151+
```
152+
153+
The OpenTelemetry injector will call the delegate injector first, then add the OpenTelemetry
154+
`Context` to the Log4j event context data.
155+
156+
This adds an internal `otel.internal.context` context data entry to carry the OpenTelemetry `Context`.
157+
Applications that render all Log4j context data, for example with `%X` or JSON layouts, should
158+
exclude this key from log output because its value is not stable and may change without notice.
159+
106160
[source code attributes]: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/general/attributes.md#source-code-attributes

instrumentation/log4j/log4j-appender-2.17/library/build.gradle.kts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ dependencies {
66
library("org.apache.logging.log4j:log4j-core:2.17.0")
77
annotationProcessor("org.apache.logging.log4j:log4j-core:2.17.0")
88

9+
// to be removed in 3.0
910
implementation(project(":instrumentation:log4j:log4j-context-data:log4j-context-data-2.17:library-autoconfigure"))
1011

1112
testImplementation(project(":instrumentation:log4j:log4j-appender-2.17:testing"))
@@ -26,7 +27,10 @@ tasks {
2627
val testAsyncLogger by registering(Test::class) {
2728
testClassesDirs = sourceSets.test.get().output.classesDirs
2829
classpath = sourceSets.test.get().runtimeClasspath
29-
jvmArgs("-DLog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector")
30+
jvmArgs(
31+
"-DLog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector",
32+
"-Dlog4j2.ContextDataInjector=io.opentelemetry.instrumentation.log4j.appender.v2_17.OpenTelemetryAppenderContextDataInjector",
33+
)
3034
}
3135

3236
val testStableSemconv by registering(Test::class) {

instrumentation/log4j/log4j-appender-2.17/library/src/main/java/io/opentelemetry/instrumentation/log4j/appender/v2_17/OpenTelemetryAppender.java

Lines changed: 95 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
package io.opentelemetry.instrumentation.log4j.appender.v2_17;
77

8+
import static io.opentelemetry.instrumentation.log4j.appender.v2_17.internal.ContextDataKeys.OTEL_CONTEXT_DATA_KEY;
89
import static java.util.Collections.emptyList;
910
import static java.util.concurrent.TimeUnit.MILLISECONDS;
1011
import static java.util.concurrent.TimeUnit.NANOSECONDS;
@@ -52,6 +53,7 @@
5253
import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory;
5354
import org.apache.logging.log4j.core.time.Instant;
5455
import org.apache.logging.log4j.message.MapMessage;
56+
import org.apache.logging.log4j.status.StatusLogger;
5557
import org.apache.logging.log4j.util.ReadOnlyStringMap;
5658

5759
@Plugin(
@@ -67,8 +69,10 @@ public class OpenTelemetryAppender extends AbstractAppender {
6769

6870
private final BlockingQueue<LogEventToReplay> eventsToReplay;
6971
private final AtomicBoolean replayLimitWarningLogged = new AtomicBoolean();
72+
private final AtomicBoolean legacyContextDataWarningLogged = new AtomicBoolean();
7073
private final ReadWriteLock lock = new ReentrantReadWriteLock();
7174
private final boolean captureCodeAttributes;
75+
private final boolean v3Preview;
7276

7377
/**
7478
* Installs the {@code openTelemetry} instance on any {@link OpenTelemetryAppender}s identified in
@@ -225,16 +229,16 @@ private OpenTelemetryAppender(
225229
boolean v3Preview = commonConfig.getBoolean("v3_preview", false);
226230

227231
this.mapper =
228-
new LogEventMapper<>(
229-
ContextDataAccessorImpl.INSTANCE,
232+
createMapper(
230233
captureExperimentalAttributes,
231234
captureCodeAttributes,
232235
captureMapMessageAttributes,
233236
captureMarkerAttribute,
234-
splitAndFilterBlanksAndNulls(captureContextDataAttributes),
237+
captureContextDataAttributes,
235238
v3Preview);
236239
this.openTelemetry = openTelemetry;
237240
this.captureCodeAttributes = captureCodeAttributes;
241+
this.v3Preview = v3Preview;
238242
if (numLogsCapturedBeforeOtelInstall != 0) {
239243
this.eventsToReplay = new ArrayBlockingQueue<>(numLogsCapturedBeforeOtelInstall);
240244
} else {
@@ -252,6 +256,23 @@ private static List<String> splitAndFilterBlanksAndNulls(@Nullable String value)
252256
.collect(toList());
253257
}
254258

259+
private static LogEventMapper<ReadOnlyStringMap> createMapper(
260+
boolean captureExperimentalAttributes,
261+
boolean captureCodeAttributes,
262+
boolean captureMapMessageAttributes,
263+
boolean captureMarkerAttribute,
264+
@Nullable String captureContextDataAttributes,
265+
boolean v3Preview) {
266+
return new LogEventMapper<>(
267+
ContextDataAccessorImpl.INSTANCE,
268+
captureExperimentalAttributes,
269+
captureCodeAttributes,
270+
captureMapMessageAttributes,
271+
captureMarkerAttribute,
272+
splitAndFilterBlanksAndNulls(captureContextDataAttributes),
273+
v3Preview);
274+
}
275+
255276
/**
256277
* Configures the {@link OpenTelemetry} used to append logs. This MUST be called for the appender
257278
* to function. See {@link #install(OpenTelemetry)} for simple installation option.
@@ -280,6 +301,7 @@ private void resetAppenderForTest() {
280301
openTelemetry = null;
281302
eventsToReplay.clear();
282303
replayLimitWarningLogged.set(false);
304+
legacyContextDataWarningLogged.set(false);
283305
} finally {
284306
writeLock.unlock();
285307
}
@@ -325,28 +347,7 @@ private void emit(OpenTelemetry openTelemetry, LogEvent event) {
325347
LogRecordBuilder builder =
326348
openTelemetry.getLogsBridge().loggerBuilder(instrumentationName).build().logRecordBuilder();
327349
ReadOnlyStringMap contextData = event.getContextData();
328-
Context context = Context.current();
329-
// when using async logger we'll be executing on a different thread than what started logging
330-
// reconstruct the context from context data
331-
if (context == Context.root()) {
332-
ContextDataAccessor<ReadOnlyStringMap> contextDataAccessor = ContextDataAccessorImpl.INSTANCE;
333-
ContextDataKeys contextDataKeys = ContextDataKeys.create(openTelemetry);
334-
String traceId = contextDataAccessor.getValue(contextData, contextDataKeys.getTraceIdKey());
335-
String spanId = contextDataAccessor.getValue(contextData, contextDataKeys.getSpanIdKey());
336-
String traceFlags =
337-
contextDataAccessor.getValue(contextData, contextDataKeys.getTraceFlags());
338-
if (traceId != null && spanId != null && traceFlags != null) {
339-
context =
340-
Context.root()
341-
.with(
342-
Span.wrap(
343-
SpanContext.create(
344-
traceId,
345-
spanId,
346-
TraceFlags.fromHex(traceFlags, 0),
347-
TraceState.getDefault())));
348-
}
349-
}
350+
Context context = getContext(openTelemetry, event, contextData);
350351

351352
mapper.mapLogEvent(
352353
builder,
@@ -369,18 +370,85 @@ private void emit(OpenTelemetry openTelemetry, LogEvent event) {
369370
builder.emit();
370371
}
371372

373+
private Context getContext(
374+
OpenTelemetry openTelemetry, LogEvent event, ReadOnlyStringMap contextData) {
375+
Object context = contextData.getValue(OTEL_CONTEXT_DATA_KEY);
376+
if (context instanceof Context) {
377+
return (Context) context;
378+
}
379+
Context currentContext = Context.current();
380+
if (currentContext != Context.root()) {
381+
return currentContext;
382+
}
383+
384+
if (!v3Preview) {
385+
// when using async logger we'll be executing on a different thread than what started logging
386+
// reconstruct the context from context data
387+
ContextDataAccessor<ReadOnlyStringMap> contextDataAccessor = ContextDataAccessorImpl.INSTANCE;
388+
ContextDataKeys contextDataKeys = ContextDataKeys.create(openTelemetry);
389+
String traceId = contextDataAccessor.getValue(contextData, contextDataKeys.getTraceIdKey());
390+
String spanId = contextDataAccessor.getValue(contextData, contextDataKeys.getSpanIdKey());
391+
String traceFlags =
392+
contextDataAccessor.getValue(contextData, contextDataKeys.getTraceFlags());
393+
if (traceId != null && spanId != null && traceFlags != null) {
394+
warnIfUsingLegacyContextDataForAsyncLoggers(event);
395+
return Context.root()
396+
.with(
397+
Span.wrap(
398+
SpanContext.create(
399+
traceId,
400+
spanId,
401+
TraceFlags.fromHex(traceFlags, 0),
402+
TraceState.getDefault())));
403+
}
404+
}
405+
return currentContext;
406+
}
407+
408+
private void warnIfUsingLegacyContextDataForAsyncLoggers(LogEvent event) {
409+
if (!wasLoggedOnDifferentThread(event)) {
410+
return;
411+
}
412+
if (legacyContextDataWarningLogged.getAndSet(true)) {
413+
return;
414+
}
415+
StatusLogger.getLogger()
416+
.warn(
417+
"OpenTelemetry Log4j appender is recovering span context from Log4j context data "
418+
+ "for an event logged on another thread. This compatibility behavior only "
419+
+ "propagates span context and will be removed in 3.0. Configure "
420+
+ "log4j2.ContextDataInjector="
421+
+ OpenTelemetryAppenderContextDataInjector.class.getName()
422+
+ " to propagate the full OpenTelemetry Context for async loggers.");
423+
}
424+
425+
private static boolean wasLoggedOnDifferentThread(LogEvent event) {
426+
long eventThreadId = event.getThreadId();
427+
// Only treat this as async handoff when Log4j captured a usable, different thread id.
428+
return eventThreadId > 0 && eventThreadId != Thread.currentThread().getId();
429+
}
430+
372431
private enum ContextDataAccessorImpl implements ContextDataAccessor<ReadOnlyStringMap> {
373432
INSTANCE;
374433

375434
@Override
376435
@Nullable
377436
public String getValue(ReadOnlyStringMap contextData, String key) {
378-
return contextData.getValue(key);
437+
Object value = contextData.getValue(key);
438+
if (value instanceof String) {
439+
return (String) value;
440+
}
441+
return null;
379442
}
380443

381444
@Override
382445
public void forEach(ReadOnlyStringMap contextData, BiConsumer<String, String> action) {
383-
contextData.forEach(action::accept);
446+
contextData.forEach(
447+
(key, value) -> {
448+
if (value instanceof String) {
449+
action.accept(key, (String) value);
450+
}
451+
});
384452
}
385453
}
386454
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.log4j.appender.v2_17;
7+
8+
import static io.opentelemetry.instrumentation.log4j.appender.v2_17.internal.ContextDataKeys.OTEL_CONTEXT_DATA_KEY;
9+
10+
import io.opentelemetry.context.Context;
11+
import java.util.List;
12+
import org.apache.logging.log4j.ThreadContext;
13+
import org.apache.logging.log4j.core.ContextDataInjector;
14+
import org.apache.logging.log4j.core.config.Property;
15+
import org.apache.logging.log4j.core.impl.ContextDataFactory;
16+
import org.apache.logging.log4j.core.impl.ThreadContextDataInjector;
17+
import org.apache.logging.log4j.core.util.Loader;
18+
import org.apache.logging.log4j.spi.CopyOnWrite;
19+
import org.apache.logging.log4j.spi.DefaultThreadContextMap;
20+
import org.apache.logging.log4j.spi.ReadOnlyThreadContextMap;
21+
import org.apache.logging.log4j.status.StatusLogger;
22+
import org.apache.logging.log4j.util.PropertiesUtil;
23+
import org.apache.logging.log4j.util.ReadOnlyStringMap;
24+
import org.apache.logging.log4j.util.StringMap;
25+
26+
/**
27+
* Injects the full OpenTelemetry {@link Context} into Log4j event context data for async logger
28+
* handoff.
29+
*
30+
* <p>This uses Log4j's ContextDataInjector extension point instead of ContextDataProvider because
31+
* the appender needs to carry the {@link Context} object itself, not only string key/value pairs.
32+
* Although Log4j 2.17 added ContextDataProvider.supplyStringMap(), Log4j does not call it for every
33+
* thread context map. In web-app mode, which is enabled by default when the Servlet API is on the
34+
* classpath, Log4j disables thread locals and uses DefaultThreadContextMap. That path calls
35+
* ContextDataProvider.supplyContextData(), which is limited to Map&lt;String, String&gt;.
36+
*
37+
* <p>By delegating to Log4j's selected injector first and then adding the {@link Context} object to
38+
* the resulting {@link StringMap}, this works for DefaultThreadContextMap, copy-on-write, and
39+
* garbage-free thread context maps.
40+
*/
41+
public final class OpenTelemetryAppenderContextDataInjector implements ContextDataInjector {
42+
43+
static final String DELEGATE_CONTEXT_DATA_INJECTOR_PROPERTY =
44+
"otel.instrumentation.log4j-appender.context-data-injector.delegate";
45+
46+
private final ContextDataInjector delegate = createDelegateInjector();
47+
48+
@Override
49+
public StringMap injectContextData(List<Property> properties, StringMap reusable) {
50+
Context context = Context.current();
51+
if (context == Context.root()) {
52+
return delegate.injectContextData(properties, reusable);
53+
}
54+
StringMap contextData = delegate.injectContextData(properties, reusable);
55+
if (contextData.isFrozen()) {
56+
contextData = ContextDataFactory.createContextData(contextData);
57+
}
58+
contextData.putValue(OTEL_CONTEXT_DATA_KEY, context);
59+
return contextData;
60+
}
61+
62+
@Override
63+
public ReadOnlyStringMap rawContextData() {
64+
Context context = Context.current();
65+
if (context == Context.root()) {
66+
return delegate.rawContextData();
67+
}
68+
StringMap contextData = ContextDataFactory.createContextData(delegate.rawContextData());
69+
contextData.putValue(OTEL_CONTEXT_DATA_KEY, context);
70+
return contextData;
71+
}
72+
73+
private static ContextDataInjector createDelegateInjector() {
74+
String className =
75+
PropertiesUtil.getProperties().getStringProperty(DELEGATE_CONTEXT_DATA_INJECTOR_PROPERTY);
76+
if (className != null) {
77+
try {
78+
if (className.equals(OpenTelemetryAppenderContextDataInjector.class.getName())) {
79+
throw new IllegalArgumentException("Delegate injector cannot be this injector");
80+
}
81+
return Loader.newCheckedInstanceOf(className, ContextDataInjector.class);
82+
} catch (Exception e) {
83+
StatusLogger.getLogger()
84+
.warn(
85+
"Could not create configured ContextDataInjector delegate '{}'; ignoring delegate override",
86+
className,
87+
e);
88+
}
89+
}
90+
91+
// Mirror Log4j's ContextDataInjectorFactory#createDefaultInjector() selection.
92+
ReadOnlyThreadContextMap threadContextMap = ThreadContext.getThreadContextMap();
93+
if (threadContextMap == null || threadContextMap instanceof DefaultThreadContextMap) {
94+
return new ThreadContextDataInjector.ForDefaultThreadContextMap();
95+
}
96+
if (threadContextMap instanceof CopyOnWrite) {
97+
return new ThreadContextDataInjector.ForCopyOnWriteThreadContextMap();
98+
}
99+
return new ThreadContextDataInjector.ForGarbageFreeThreadContextMap();
100+
}
101+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.log4j.appender.v2_17.internal;
7+
8+
/**
9+
* This class is internal and is hence not for public use. Its APIs are unstable and can change at
10+
* any time.
11+
*/
12+
public final class ContextDataKeys {
13+
14+
public static final String OTEL_CONTEXT_DATA_KEY = "otel.internal.context";
15+
16+
private ContextDataKeys() {}
17+
}

0 commit comments

Comments
 (0)