Skip to content

Commit 77cc414

Browse files
committed
Fix Netty HTTP span lifecycle for chunked/streaming responses
HttpServerResponseTracingHandler: route by message type (FullHttpResponse, HttpResponse, LastHttpContent) instead of finishing every span on HttpResponse. FullHttpResponse finishes immediately; HttpResponse defers to LastHttpContent via STREAMING_CONTEXT_KEY to avoid keep-alive race. WebSocket upgrades and bodyless responses (204, 205, 304) finish immediately since they never produce LastHttpContent. HttpServerRequestTracingHandler: channelInactive now checks STREAMING_CONTEXT_KEY and finishes leaked spans when channel closes mid-stream. AttributeKeys: added STREAMING_CONTEXT_KEY for chunked response context.
1 parent d3bb33d commit 77cc414

3 files changed

Lines changed: 177 additions & 8 deletions

File tree

dd-java-agent/instrumentation/netty/netty-4.1/src/main/java/datadog/trace/instrumentation/netty41/server/HttpServerRequestTracingHandler.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import static datadog.trace.instrumentation.netty41.AttributeKeys.CONTEXT_ATTRIBUTE_KEY;
77
import static datadog.trace.instrumentation.netty41.AttributeKeys.PARENT_CONTEXT_ATTRIBUTE_KEY;
88
import static datadog.trace.instrumentation.netty41.AttributeKeys.REQUEST_HEADERS_ATTRIBUTE_KEY;
9+
import static datadog.trace.instrumentation.netty41.AttributeKeys.STREAMING_CONTEXT_KEY;
910
import static datadog.trace.instrumentation.netty41.server.NettyHttpServerDecorator.DECORATE;
1011

1112
import datadog.context.Context;
@@ -88,11 +89,26 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
8889
try {
8990
super.channelInactive(ctx);
9091
} finally {
92+
// Finish any in-flight streaming span first — during chunked responses the span context
93+
// is stored in STREAMING_CONTEXT_KEY, and CONTEXT_ATTRIBUTE_KEY may already belong to
94+
// the next keep-alive request.
95+
try {
96+
final Context streamingContext = ctx.channel().attr(STREAMING_CONTEXT_KEY).getAndRemove();
97+
if (streamingContext != null) {
98+
final AgentSpan streamingSpan = spanFromContext(streamingContext);
99+
if (streamingSpan != null) {
100+
DECORATE.onError(
101+
streamingSpan, new Exception("Channel closed before response completed"));
102+
DECORATE.beforeFinish(streamingContext);
103+
streamingSpan.finish();
104+
}
105+
}
106+
} catch (final Throwable ignored) {
107+
}
91108
try {
92109
final Context storedContext = ctx.channel().attr(CONTEXT_ATTRIBUTE_KEY).getAndRemove();
93110
final AgentSpan span = spanFromContext(storedContext);
94111
if (span != null && span.phasedFinish()) {
95-
// at this point we can just publish this span to avoid loosing the rest of the trace
96112
span.publish();
97113
}
98114
} catch (final Throwable ignored) {

dd-java-agent/instrumentation/netty/netty-4.1/src/main/java/datadog/trace/instrumentation/netty41/server/HttpServerResponseTracingHandler.java

Lines changed: 150 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import static datadog.trace.bootstrap.instrumentation.api.Java8BytecodeBridge.spanFromContext;
44
import static datadog.trace.instrumentation.netty41.AttributeKeys.CONTEXT_ATTRIBUTE_KEY;
5+
import static datadog.trace.instrumentation.netty41.AttributeKeys.STREAMING_CONTEXT_KEY;
56
import static datadog.trace.instrumentation.netty41.AttributeKeys.WEBSOCKET_SENDER_HANDLER_CONTEXT;
67
import static datadog.trace.instrumentation.netty41.server.NettyHttpServerDecorator.DECORATE;
78

@@ -13,9 +14,11 @@
1314
import io.netty.channel.ChannelHandlerContext;
1415
import io.netty.channel.ChannelOutboundHandlerAdapter;
1516
import io.netty.channel.ChannelPromise;
17+
import io.netty.handler.codec.http.FullHttpResponse;
1618
import io.netty.handler.codec.http.HttpHeaderNames;
1719
import io.netty.handler.codec.http.HttpResponse;
1820
import io.netty.handler.codec.http.HttpResponseStatus;
21+
import io.netty.handler.codec.http.LastHttpContent;
1922

2023
@ChannelHandler.Sharable
2124
public class HttpServerResponseTracingHandler extends ChannelOutboundHandlerAdapter {
@@ -26,36 +29,176 @@ public void write(final ChannelHandlerContext ctx, final Object msg, final Chann
2629
final Context storedContext = ctx.channel().attr(CONTEXT_ATTRIBUTE_KEY).get();
2730
final AgentSpan span = spanFromContext(storedContext);
2831

29-
if (span == null || !(msg instanceof HttpResponse)) {
30-
ctx.write(msg, prm);
32+
// FullHttpResponse must be checked BEFORE LastHttpContent and HttpResponse,
33+
// because FullHttpResponse extends both LastHttpContent and HttpResponse.
34+
if (msg instanceof FullHttpResponse) {
35+
handleFullHttpResponse(ctx, storedContext, span, (FullHttpResponse) msg, prm);
3136
return;
3237
}
3338

34-
try (final ContextScope scope = storedContext.attach()) {
35-
final HttpResponse response = (HttpResponse) msg;
39+
// Handle HttpResponse (headers only — start of chunked/streaming response).
40+
// Must be checked BEFORE LastHttpContent/HttpContent.
41+
if (msg instanceof HttpResponse) {
42+
handleHttpResponse(ctx, storedContext, span, (HttpResponse) msg, prm);
43+
return;
44+
}
45+
46+
// Handle LastHttpContent (end of chunked/streaming response).
47+
// Must be checked BEFORE HttpContent (LastHttpContent extends HttpContent).
48+
// IMPORTANT: Use STREAMING_CONTEXT_KEY to avoid keep-alive race condition where
49+
// channelRead for the next request may overwrite CONTEXT_ATTRIBUTE_KEY before
50+
// this LastHttpContent write task runs on the EventLoop.
51+
if (msg instanceof LastHttpContent) {
52+
Context streamingContext = ctx.channel().attr(STREAMING_CONTEXT_KEY).getAndRemove();
53+
Context contextForLastContent = streamingContext != null ? streamingContext : storedContext;
54+
AgentSpan spanForLastContent =
55+
streamingContext != null ? spanFromContext(streamingContext) : span;
56+
handleLastHttpContent(
57+
ctx, contextForLastContent, spanForLastContent, (LastHttpContent) msg, prm);
58+
return;
59+
}
60+
61+
// Intermediate HttpContent chunks — pass through without touching the span.
62+
ctx.write(msg, prm);
63+
}
3664

65+
/** Complete response in a single message (non-streaming). Finish span immediately. */
66+
private void handleFullHttpResponse(
67+
final ChannelHandlerContext ctx,
68+
final Context storedContext,
69+
final AgentSpan span,
70+
final FullHttpResponse response,
71+
final ChannelPromise prm) {
72+
73+
if (span == null) {
74+
ctx.write(response, prm);
75+
return;
76+
}
77+
78+
try (final ContextScope scope = storedContext.attach()) {
3779
try {
38-
ctx.write(msg, prm);
80+
ctx.write(response, prm);
3981
} catch (final Throwable throwable) {
4082
DECORATE.onError(span, throwable);
4183
span.setHttpStatusCode(500);
42-
span.finish(); // Finish the span manually since finishSpanOnClose was false
84+
span.finish();
4385
ctx.channel().attr(CONTEXT_ATTRIBUTE_KEY).remove();
4486
throw throwable;
4587
}
88+
4689
final boolean isWebsocketUpgrade =
4790
response.status() == HttpResponseStatus.SWITCHING_PROTOCOLS
4891
&& "websocket".equals(response.headers().get(HttpHeaderNames.UPGRADE));
92+
4993
if (isWebsocketUpgrade) {
5094
ctx.channel()
5195
.attr(WEBSOCKET_SENDER_HANDLER_CONTEXT)
5296
.set(new HandlerContext.Sender(span, ctx.channel().id().asShortText()));
5397
}
98+
5499
if (response.status() != HttpResponseStatus.CONTINUE
55100
&& (response.status() != HttpResponseStatus.SWITCHING_PROTOCOLS || isWebsocketUpgrade)) {
56101
DECORATE.onResponse(span, response);
57102
DECORATE.beforeFinish(scope.context());
58-
span.finish(); // Finish the span manually since finishSpanOnClose was false
103+
span.finish();
104+
ctx.channel().attr(CONTEXT_ATTRIBUTE_KEY).remove();
105+
}
106+
}
107+
}
108+
109+
/**
110+
* Chunked response headers — record status but do NOT finish the span yet. The span will be
111+
* finished when the corresponding LastHttpContent is written. Context is saved to
112+
* STREAMING_CONTEXT_KEY so that a keep-alive channelRead for the next request cannot overwrite it
113+
* before LastHttpContent arrives.
114+
*/
115+
private void handleHttpResponse(
116+
final ChannelHandlerContext ctx,
117+
final Context storedContext,
118+
final AgentSpan span,
119+
final HttpResponse response,
120+
final ChannelPromise prm) {
121+
122+
if (span == null) {
123+
ctx.write(response, prm);
124+
return;
125+
}
126+
127+
try (final ContextScope scope = storedContext.attach()) {
128+
try {
129+
ctx.write(response, prm);
130+
} catch (final Throwable throwable) {
131+
DECORATE.onError(span, throwable);
132+
span.setHttpStatusCode(500);
133+
span.finish();
134+
ctx.channel().attr(CONTEXT_ATTRIBUTE_KEY).remove();
135+
throw throwable;
136+
}
137+
138+
final boolean isWebsocketUpgrade =
139+
response.status() == HttpResponseStatus.SWITCHING_PROTOCOLS
140+
&& "websocket".equals(response.headers().get(HttpHeaderNames.UPGRADE));
141+
142+
if (isWebsocketUpgrade) {
143+
ctx.channel()
144+
.attr(WEBSOCKET_SENDER_HANDLER_CONTEXT)
145+
.set(new HandlerContext.Sender(span, ctx.channel().id().asShortText()));
146+
}
147+
148+
if (response.status() != HttpResponseStatus.CONTINUE
149+
&& (response.status() != HttpResponseStatus.SWITCHING_PROTOCOLS || isWebsocketUpgrade)) {
150+
DECORATE.onResponse(span, response);
151+
152+
int statusCode = response.status().code();
153+
boolean isBodyless = statusCode == 204 || statusCode == 205 || statusCode == 304;
154+
155+
if (isWebsocketUpgrade || isBodyless) {
156+
// WebSocket upgrades and bodyless responses (204, 205, 304) don't produce
157+
// LastHttpContent — finish span immediately.
158+
DECORATE.beforeFinish(scope.context());
159+
span.finish();
160+
ctx.channel().attr(CONTEXT_ATTRIBUTE_KEY).remove();
161+
} else {
162+
ctx.channel().attr(STREAMING_CONTEXT_KEY).set(storedContext);
163+
ctx.channel().attr(CONTEXT_ATTRIBUTE_KEY).remove();
164+
// Span finish is deferred to handleLastHttpContent.
165+
}
166+
}
167+
}
168+
}
169+
170+
/** End of chunked/streaming response — finish the span now that the full duration is known. */
171+
private void handleLastHttpContent(
172+
final ChannelHandlerContext ctx,
173+
final Context storedContext,
174+
final AgentSpan span,
175+
final LastHttpContent msg,
176+
final ChannelPromise prm) {
177+
178+
if (span == null) {
179+
ctx.write(msg, prm);
180+
return;
181+
}
182+
183+
try (final ContextScope scope = storedContext.attach()) {
184+
try {
185+
ctx.write(msg, prm);
186+
} catch (final Throwable throwable) {
187+
DECORATE.onError(span, throwable);
188+
span.setHttpStatusCode(500);
189+
span.finish();
190+
if (ctx.channel().attr(CONTEXT_ATTRIBUTE_KEY).get() == storedContext) {
191+
ctx.channel().attr(CONTEXT_ATTRIBUTE_KEY).remove();
192+
}
193+
throw throwable;
194+
}
195+
196+
DECORATE.beforeFinish(scope.context());
197+
span.finish();
198+
// Only remove CONTEXT_ATTRIBUTE_KEY if it still holds our context.
199+
// Under keep-alive a new request's channelRead may have already replaced it.
200+
// All channel ops run on the same EventLoop thread so this check is race-free.
201+
if (ctx.channel().attr(CONTEXT_ATTRIBUTE_KEY).get() == storedContext) {
59202
ctx.channel().attr(CONTEXT_ATTRIBUTE_KEY).remove();
60203
}
61204
}

dd-java-agent/instrumentation/netty/netty-common/src/main/java/datadog/trace/instrumentation/netty41/AttributeKeys.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ public final class AttributeKeys {
2020
public static final AttributeKey<Context> CONTEXT_ATTRIBUTE_KEY =
2121
attributeKey(DD_CONTEXT_ATTRIBUTE);
2222

23+
/**
24+
* Stores the context of the currently-streaming (chunked) response. Set when the HTTP response
25+
* headers are sent, cleared when LastHttpContent is processed. Using a separate key (instead of
26+
* CONTEXT_ATTRIBUTE_KEY) avoids a keep-alive race: Netty can process the next request's
27+
* channelRead before the current response's LastHttpContent write task runs, overwriting
28+
* CONTEXT_ATTRIBUTE_KEY with the new request's span.
29+
*/
30+
public static final AttributeKey<Context> STREAMING_CONTEXT_KEY =
31+
attributeKey("datadog.server.streaming.context");
32+
2333
public static final AttributeKey<AgentSpan> CLIENT_PARENT_ATTRIBUTE_KEY =
2434
attributeKey("datadog.client.parent.span");
2535

0 commit comments

Comments
 (0)