Skip to content

Commit 816e8a3

Browse files
Support HTTP trailers
1 parent 597d091 commit 816e8a3

File tree

2 files changed

+101
-4
lines changed

2 files changed

+101
-4
lines changed

instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -655,8 +655,11 @@ async def otel_receive():
655655
def _get_otel_send(
656656
self, server_span, server_span_name, scope, send, duration_attrs
657657
):
658+
expecting_trailers = False
659+
658660
@wraps(send)
659661
async def otel_send(message):
662+
nonlocal expecting_trailers
660663
with self.tracer.start_as_current_span(
661664
" ".join((server_span_name, scope["type"], "send"))
662665
) as send_span:
@@ -670,6 +673,8 @@ async def otel_send(message):
670673
] = status_code
671674
set_status_code(server_span, status_code)
672675
set_status_code(send_span, status_code)
676+
677+
expecting_trailers = message.get("trailers", False)
673678
elif message["type"] == "websocket.send":
674679
set_status_code(server_span, 200)
675680
set_status_code(send_span, 200)
@@ -705,8 +710,15 @@ async def otel_send(message):
705710
pass
706711

707712
await send(message)
708-
if message["type"] == "http.response.body":
709-
if not message.get("more_body", False):
710-
server_span.end()
713+
if (
714+
not expecting_trailers
715+
and message["type"] == "http.response.body"
716+
and not message.get("more_body", False)
717+
) or (
718+
expecting_trailers
719+
and message["type"] == "http.response.trailers"
720+
and not message.get("more_trailers", False)
721+
):
722+
server_span.end()
711723

712724
return otel_send

instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,51 @@ async def background_execution_asgi(scope, receive, send):
159159
time.sleep(_SIMULATED_BACKGROUND_TASK_EXECUTION_TIME_S)
160160

161161

162+
async def background_execution_trailers_asgi(scope, receive, send):
163+
assert isinstance(scope, dict)
164+
assert scope["type"] == "http"
165+
message = await receive()
166+
scope["headers"] = [(b"content-length", b"128")]
167+
assert scope["type"] == "http"
168+
if message.get("type") == "http.request":
169+
await send(
170+
{
171+
"type": "http.response.start",
172+
"status": 200,
173+
"headers": [
174+
[b"Content-Type", b"text/plain"],
175+
[b"content-length", b"1024"],
176+
],
177+
"trailers": True,
178+
}
179+
)
180+
await send(
181+
{"type": "http.response.body", "body": b"*", "more_body": True}
182+
)
183+
await send(
184+
{"type": "http.response.body", "body": b"*", "more_body": False}
185+
)
186+
await send(
187+
{
188+
"type": "http.response.trailers",
189+
"headers": [
190+
[b"trailer", b"test-trailer"],
191+
],
192+
"more_trailers": True,
193+
}
194+
)
195+
await send(
196+
{
197+
"type": "http.response.trailers",
198+
"headers": [
199+
[b"trailer", b"second-test-trailer"],
200+
],
201+
"more_trailers": False,
202+
}
203+
)
204+
time.sleep(_SIMULATED_BACKGROUND_TASK_EXECUTION_TIME_S)
205+
206+
162207
async def error_asgi(scope, receive, send):
163208
assert isinstance(scope, dict)
164209
assert scope["type"] == "http"
@@ -188,7 +233,12 @@ def validate_outputs(self, outputs, error=None, modifiers=None):
188233
modifiers = modifiers or []
189234
# Check for expected outputs
190235
response_start = outputs[0]
191-
response_final_body = outputs[-1]
236+
response_final_body = [
237+
output
238+
for output in outputs
239+
if output["type"] == "http.response.body"
240+
][-1]
241+
192242
self.assertEqual(response_start["type"], "http.response.start")
193243
self.assertEqual(response_final_body["type"], "http.response.body")
194244
self.assertEqual(response_final_body.get("more_body", False), False)
@@ -331,6 +381,41 @@ def test_background_execution(self):
331381
_SIMULATED_BACKGROUND_TASK_EXECUTION_TIME_S * 10**9,
332382
)
333383

384+
def test_trailers(self):
385+
"""Test that trailers are emitted as expected and that the server span is ended
386+
BEFORE the background task is finished."""
387+
app = otel_asgi.OpenTelemetryMiddleware(
388+
background_execution_trailers_asgi
389+
)
390+
self.seed_app(app)
391+
self.send_default_request()
392+
outputs = self.get_all_output()
393+
394+
def add_body_and_trailer_span(expected: list):
395+
body_span = {
396+
"name": "GET / http send",
397+
"kind": trace_api.SpanKind.INTERNAL,
398+
"attributes": {"type": "http.response.body"},
399+
}
400+
trailer_span = {
401+
"name": "GET / http send",
402+
"kind": trace_api.SpanKind.INTERNAL,
403+
"attributes": {"type": "http.response.trailers"},
404+
}
405+
expected[2:2] = [body_span]
406+
expected[4:4] = [trailer_span] * 2
407+
return expected
408+
409+
self.validate_outputs(outputs, modifiers=[add_body_and_trailer_span])
410+
span_list = self.memory_exporter.get_finished_spans()
411+
server_span = span_list[-1]
412+
assert server_span.kind == SpanKind.SERVER
413+
span_duration_nanos = server_span.end_time - server_span.start_time
414+
self.assertLessEqual(
415+
span_duration_nanos,
416+
_SIMULATED_BACKGROUND_TASK_EXECUTION_TIME_S * 10**9,
417+
)
418+
334419
def test_override_span_name(self):
335420
"""Test that default span_names can be overwritten by our callback function."""
336421
span_name = "Dymaxion"

0 commit comments

Comments
 (0)