Skip to content

Commit f210877

Browse files
Meemawclaude
andcommitted
Fix concurrent span event recording in OTel shim
OtelSpan stored span events in a plain ArrayList that was mutated by addEvent()/recordException() without synchronization and iterated at span finish. When events are recorded from multiple threads (e.g. GraphQL DataLoaders running on virtual threads) while the span is being finished, the backing list gets corrupted, leaving a null hole that triggers a NullPointerException in OtelSpanEvent.toTag. That NPE escapes span finish (DDSpan.finishAndAddToTrace) and can surface to the application as a request failure. Guard all event-list mutations by synchronizing on the span, and copy the list under lock at finish so serialization iterates a private snapshot. The events field is volatile so onSpanFinished keeps a lock-free fast path (single volatile read) for the common case of a span with no events, avoiding overhead on the hot finish path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent af2d214 commit f210877

2 files changed

Lines changed: 77 additions & 15 deletions

File tree

  • dd-java-agent

dd-java-agent/agent-otel/otel-shim/src/main/java/datadog/opentelemetry/shim/trace/OtelSpan.java

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,14 @@ public class OtelSpan implements Span, WithAgentSpan, SpanWrapper {
3737
private StatusCode statusCode;
3838
private boolean recording;
3939

40-
/** Span events ({@code null} until an event is added). */
41-
private List<OtelSpanEvent> events;
40+
/**
41+
* Span events ({@code null} until an event is added). Mutations are guarded by synchronizing on
42+
* {@code this}: events can be recorded from application threads while the span is finished (and
43+
* its events serialized) from a different thread, so unsynchronized access would corrupt the
44+
* backing list. Declared {@code volatile} so {@link #onSpanFinished()} can take a lock-free fast
45+
* path for the common case of a span with no events.
46+
*/
47+
private volatile List<OtelSpanEvent> events;
4248

4349
public OtelSpan(AgentSpan delegate) {
4450
this.delegate = delegate;
@@ -85,25 +91,26 @@ public <T> Span setAttribute(AttributeKey<T> key, T value) {
8591
@Override
8692
public Span addEvent(String name, Attributes attributes) {
8793
if (this.recording) {
88-
if (this.events == null) {
89-
this.events = new ArrayList<>();
90-
}
91-
this.events.add(new OtelSpanEvent(name, attributes));
94+
addEvent(new OtelSpanEvent(name, attributes));
9295
}
9396
return this;
9497
}
9598

9699
@Override
97100
public Span addEvent(String name, Attributes attributes, long timestamp, TimeUnit unit) {
98101
if (this.recording) {
99-
if (this.events == null) {
100-
this.events = new ArrayList<>();
101-
}
102-
this.events.add(new OtelSpanEvent(name, attributes, timestamp, unit));
102+
addEvent(new OtelSpanEvent(name, attributes, timestamp, unit));
103103
}
104104
return this;
105105
}
106106

107+
private synchronized void addEvent(OtelSpanEvent event) {
108+
if (this.events == null) {
109+
this.events = new ArrayList<>();
110+
}
111+
this.events.add(event);
112+
}
113+
107114
@Override
108115
public Span setStatus(StatusCode statusCode, String description) {
109116
if (this.recording) {
@@ -123,12 +130,9 @@ public Span setStatus(StatusCode statusCode, String description) {
123130
@Override
124131
public Span recordException(Throwable exception, Attributes additionalAttributes) {
125132
if (this.recording) {
126-
if (this.events == null) {
127-
this.events = new ArrayList<>();
128-
}
129133
additionalAttributes = initializeExceptionAttributes(exception, additionalAttributes);
130134
applySpanEventExceptionAttributesAsTags(this.delegate, additionalAttributes);
131-
this.events.add(new OtelSpanEvent(EXCEPTION_SPAN_EVENT_NAME, additionalAttributes));
135+
addEvent(new OtelSpanEvent(EXCEPTION_SPAN_EVENT_NAME, additionalAttributes));
132136
}
133137
return this;
134138
}
@@ -179,7 +183,17 @@ public AgentSpan asAgentSpan() {
179183
@Override
180184
public void onSpanFinished() {
181185
applyNamingConvention(this.delegate);
182-
setEventsAsTag(this.delegate, this.events);
186+
// Fast path: most spans have no events, so avoid taking the lock entirely (single volatile
187+
// read).
188+
List<OtelSpanEvent> eventsSnapshot = this.events;
189+
if (eventsSnapshot != null) {
190+
// Copy under lock so serialization iterates a private snapshot that cannot be mutated
191+
// concurrently by an application thread still recording events.
192+
synchronized (this) {
193+
eventsSnapshot = new ArrayList<>(this.events);
194+
}
195+
}
196+
setEventsAsTag(this.delegate, eventsSnapshot);
183197
}
184198

185199
private static class NoopSpan implements Span {

dd-java-agent/instrumentation/opentelemetry/opentelemetry-1.4/src/test/java/opentelemetry14/OpenTelemetry14Test.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,11 @@
6767
import java.util.HashMap;
6868
import java.util.List;
6969
import java.util.Map;
70+
import java.util.concurrent.CountDownLatch;
7071
import java.util.concurrent.TimeUnit;
7172
import java.util.stream.Stream;
7273
import opentelemetry14.context.propagation.TextMap;
74+
import org.json.JSONArray;
7375
import org.json.JSONException;
7476
import org.junit.jupiter.api.Test;
7577
import org.junit.jupiter.params.ParameterizedTest;
@@ -289,6 +291,52 @@ void testAddMultipleSpanEvents() {
289291
tag(SPAN_EVENTS, isJson(expectedEventTag)))));
290292
}
291293

294+
@Test
295+
void testConcurrentAddEvents() throws Exception {
296+
// Regression test for the concurrent recording of span events. Events used to be stored in a
297+
// plain ArrayList mutated without synchronization; recording events from multiple threads (as
298+
// GraphQL DataLoaders do on virtual threads) corrupted the backing list, leaving null holes
299+
// that
300+
// triggered a NullPointerException in OtelSpanEvent.toTag when the span was finished. See trace
301+
// b8b8e4edde47f90a92e832b63251a577.
302+
int threadCount = 8;
303+
int eventsPerThread = 2000;
304+
Span span = this.otelTracer.spanBuilder("some-name").startSpan();
305+
306+
CountDownLatch start = new CountDownLatch(1);
307+
List<Thread> threads = new ArrayList<>();
308+
for (int t = 0; t < threadCount; t++) {
309+
Thread thread =
310+
new Thread(
311+
() -> {
312+
try {
313+
start.await();
314+
} catch (InterruptedException e) {
315+
Thread.currentThread().interrupt();
316+
return;
317+
}
318+
for (int i = 0; i < eventsPerThread; i++) {
319+
span.addEvent("event");
320+
}
321+
});
322+
thread.start();
323+
threads.add(thread);
324+
}
325+
326+
start.countDown();
327+
for (Thread thread : threads) {
328+
thread.join();
329+
}
330+
span.end();
331+
332+
writer.waitForTraces(1);
333+
List<DDSpan> firstTrace = writer.firstTrace();
334+
assertEquals(1, firstTrace.size());
335+
Object eventsTag = firstTrace.get(0).getTags().get(SPAN_EVENTS);
336+
JSONArray events = new JSONArray((String) eventsTag);
337+
assertEquals(threadCount * eventsPerThread, events.length());
338+
}
339+
292340
@Test
293341
void testSimpleSpanLinks() {
294342
String traceId = "1234567890abcdef1234567890abcdef";

0 commit comments

Comments
 (0)