@@ -62,10 +62,20 @@ def render_with_instana(
6262 span = tracer .start_span (
6363 "twisted-server" , context = parent_context )
6464
65- # Set span as current so downstream code
66- # (e.g. twisted-client) can find it during the synchronous
67- # wrapped() call. We detach unconditionally in the finally
68- # block below once wrapped() has returned.
65+ # Set span as current so that any async work started during
66+ # wrapped() (e.g. outgoing Agent.request Deferreds) can find
67+ # this span as their parent after the event loop resumes.
68+ #
69+ # IMPORTANT: we do NOT detach the token here in the `finally`
70+ # block. Twisted's render() returns NOT_DONE_YET for async
71+ # handlers, and the event loop only fires pending Deferreds
72+ # after render() has returned — by which point a `finally`
73+ # detach would have already removed the context, leaving
74+ # downstream spans (e.g. twisted-client) with no active parent.
75+ #
76+ # Instead the token is stored on the request object and detached
77+ # inside finish_tracing(), which is called by notifyFinish() only
78+ # after the full async response lifecycle has completed.
6979 ctx = trace .set_span_in_context (span )
7080 token = context .attach (ctx )
7181
@@ -119,21 +129,24 @@ def render_with_instana(
119129 for key , value in response_headers .items ():
120130 request .setHeader (key .encode ("latin-1" ), value .encode ("utf-8" ))
121131
122- # Store span on request for later retrieval
132+ # Store span and context token on the request so finish_tracing
133+ # can detach the token after the full async lifecycle completes.
123134 request ._instana = span
135+ request ._instana_token = token
124136 request ._instana_finished = False
125137
126138 finish_deferred = request .notifyFinish ()
127139 finish_deferred .addBoth (finish_tracing , request )
128140
129141 return wrapped (* argv , ** kwargs )
130142 except Exception :
143+ # On instrumentation error detach immediately (we never reach
144+ # finish_tracing in this path) and fall through to the bare call.
145+ if token is not None :
146+ context .detach (token )
131147 if span is not None and span .is_recording ():
132148 span .end ()
133149 logger .debug ("twisted server render_with_instana" , exc_info = True )
134- finally :
135- if token is not None :
136- context .detach (token )
137150
138151 return wrapped (* argv , ** kwargs )
139152
@@ -146,6 +159,7 @@ def finish_tracing(
146159
147160 request ._instana_finished = True
148161 span = request ._instana
162+ token = getattr (request , "_instana_token" , None )
149163 try :
150164 status_code = request .code
151165 if isinstance (status_code , int ):
@@ -159,12 +173,19 @@ def finish_tracing(
159173 extract_custom_headers (span , response_hdrs )
160174
161175 if isinstance (status_code , int ) and status_code >= 500 :
176+ phrase = request .code_message .decode ("latin-1" )
162177 span .mark_as_errored ({
163- "http.error" : request . code_message . decode ( "latin-1" )
178+ "http.error" : f" { status_code } { phrase } "
164179 })
165180 except Exception :
166181 logger .debug ("twisted server finish_tracing" , exc_info = True )
167182 finally :
183+ # Detach the OTel context token here — after the full async
184+ # response lifecycle — instead of in render_with_instana's
185+ # finally block. This ensures the server span remains the
186+ # active context for any Deferreds started during render().
187+ if token is not None :
188+ context .detach (token )
168189 if span .is_recording ():
169190 span .end ()
170191
0 commit comments