Skip to content

Commit 1723ac4

Browse files
wantsuimergify[bot]
authored andcommitted
fix(asgi, starlette, fastapi): exclude background task durations from web requests (#3799)
Following up on #3023, currently libraries traced via the `asgi` integration, such as`fastapi.request` and `starlette.request` spans include the duration of background tasks. This PR updates the asgi middleware code so that spans end earlier by excluding background tasks to better represent the web requests response times. **Current Behavior** $$\begin{align}duration = {http response time} + {background tasks time}\end{align}$$ **What it looks like with the PR** $$\begin{align}duration = {http response time}\end{align}$$ **Example:** An application return a "hello world!" to the user in $2 milliseconds$, but the events that happen in the background lasts another $8 seconds$. With this PR, the span will report a duration of $2 milliseconds$, instead of $2 milliseconds + 8 seconds$. Fixes #3023 Also fixes this failing test: https://app.circleci.com/pipelines/github/DataDog/dd-trace-py/18150/workflows/24caa985-5805-4bff-a4eb-5b143f24a2fb/jobs/1233079 ## Checklist - [ ] Library documentation is updated. - [ ] [Corp site](https://github.com/DataDog/documentation/) documentation is updated (link to the PR). ## Reviewer Checklist - [ ] Title is accurate. - [ ] Description motivates each change. - [ ] No unnecessary changes were introduced in this PR. - [ ] PR cannot be broken up into smaller PRs. - [ ] Avoid breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [ ] Tests provided or description of manual testing performed is included in the code or PR. - [ ] Release note has been added for fixes and features, or else `changelog/no-changelog` label added. - [ ] All relevant GitHub issues are correctly linked. - [ ] Backports are identified and tagged with Mergifyio. - [ ] Add to milestone. (cherry picked from commit 5b97489)
1 parent 97e020a commit 1723ac4

File tree

7 files changed

+149
-1
lines changed

7 files changed

+149
-1
lines changed

ddtrace/contrib/asgi/middleware.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,13 @@ async def wrapped_send(message):
169169
span, self.integration_config, status_code=status_code, response_headers=response_headers
170170
)
171171

172-
return await send(message)
172+
try:
173+
return await send(message)
174+
finally:
175+
# Per asgi spec, "more_body" is used if there is still data to send
176+
# Close the span if "http.response.body" has no more data left to send in the response.
177+
if message.get("type") == "http.response.body" and not message.get("more_body", False):
178+
span.finish()
173179

174180
try:
175181
return await self.app(scope, receive, wrapped_send)
@@ -183,4 +189,5 @@ async def wrapped_send(message):
183189
del scope["datadog"]["request_span"]
184190
except KeyError:
185191
pass
192+
186193
span.finish()
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
fixes:
3+
- |
4+
asgi, starlette, fastapi: Exclude background tasks duration from web request spans.

tests/contrib/asgi/test_asgi.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,44 @@ def double_callable_app(scope):
9494
return partial(basic_app, scope)
9595

9696

97+
async def tasks_app_without_more_body(scope, receive, send):
98+
"""
99+
An app that does something in the background after the response is sent without having more data to send.
100+
"more_body" with a true value is used in the asgi spec to indicate that there is more data to send.
101+
"""
102+
assert scope["type"] == "http"
103+
message = await receive()
104+
if message.get("type") == "http.request":
105+
await send({"type": "http.response.start", "status": 200, "headers": [[b"Content-Type", b"text/plain"]]})
106+
await send({"type": "http.response.body", "body": b"*"})
107+
await asyncio.sleep(1)
108+
109+
110+
async def tasks_app_with_more_body(scope, receive, send):
111+
"""
112+
An app that does something in the background but has a more_body response that starts off as true,
113+
but then turns to false.
114+
"more_body" with a true value is used in the asgi spec to indicate that there is more data to send.
115+
"""
116+
assert scope["type"] == "http"
117+
message = await receive()
118+
request_span = scope["datadog"]["request_spans"][0]
119+
if message.get("type") == "http.request":
120+
121+
# assert that the request span hasn't finished at the start of a response
122+
await send({"type": "http.response.start", "status": 200, "headers": [[b"Content-Type", b"text/plain"]]})
123+
assert not request_span.finished
124+
125+
# assert that the request span hasn't finished while more_body is True
126+
await send({"type": "http.response.body", "body": b"*", "more_body": True})
127+
assert not request_span.finished
128+
129+
# assert that the span has finished after more_body is False
130+
await send({"type": "http.response.body", "body": b"*", "more_body": False})
131+
assert request_span.finished
132+
await asyncio.sleep(1)
133+
134+
97135
def _check_span_tags(scope, span):
98136
assert span.get_tag("http.method") == scope["method"]
99137
server = scope.get("server")
@@ -445,3 +483,49 @@ async def test_app_no_middleware(scope, receive, send):
445483
async with httpx.AsyncClient(app=test_app_no_middleware) as client:
446484
response = await client.get("http://testserver/")
447485
assert response.status_code == 200
486+
487+
488+
@pytest.mark.asyncio
489+
async def test_tasks_asgi_without_more_body(scope, tracer, test_spans):
490+
"""
491+
When an application doesn't have more_body calls and does background tasks,
492+
the asgi span only captures the time it took for a user to get a response, not the time
493+
it took for other tasks in the background.
494+
"""
495+
app = TraceMiddleware(tasks_app_without_more_body, tracer=tracer)
496+
async with httpx.AsyncClient(app=app) as client:
497+
response = await client.get("http://testserver/")
498+
assert response.status_code == 200
499+
500+
spans = test_spans.pop_traces()
501+
assert len(spans) == 1
502+
assert len(spans[0]) == 1
503+
request_span = spans[0][0]
504+
assert request_span.name == "asgi.request"
505+
assert request_span.span_type == "web"
506+
# typical duration without background task should be in less than 10 ms
507+
# duration with background task will take approximately 1.1s
508+
assert request_span.duration < 1
509+
510+
511+
@pytest.mark.asyncio
512+
async def test_tasks_asgi_with_more_body(scope, tracer, test_spans):
513+
"""
514+
When an application does have more_body calls and does background tasks,
515+
the asgi span only captures the time it took for a user to get a response, not the time
516+
it took for other tasks in the background.
517+
"""
518+
app = TraceMiddleware(tasks_app_with_more_body, tracer=tracer)
519+
async with httpx.AsyncClient(app=app) as client:
520+
response = await client.get("http://testserver/")
521+
assert response.status_code == 200
522+
523+
spans = test_spans.pop_traces()
524+
assert len(spans) == 1
525+
assert len(spans[0]) == 1
526+
request_span = spans[0][0]
527+
assert request_span.name == "asgi.request"
528+
assert request_span.span_type == "web"
529+
# typical duration without background task should be in less than 10 ms
530+
# duration with background task will take approximately 1.1s
531+
assert request_span.duration < 1

tests/contrib/fastapi/app.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import asyncio
12
from tempfile import NamedTemporaryFile
23
import time
34
from typing import Optional
45

6+
from fastapi import BackgroundTasks
57
from fastapi import FastAPI
68
from fastapi import HTTPException
79
from fastapi import Header
@@ -102,6 +104,14 @@ async def file():
102104
fp.flush()
103105
return FileResponse(fp.name)
104106

107+
async def custom_task():
108+
await asyncio.sleep(1)
109+
110+
@app.get("/asynctask")
111+
async def asynctask(bg_tasks: BackgroundTasks):
112+
bg_tasks.add_task(custom_task)
113+
return "task added"
114+
105115
subapp = FastAPI()
106116

107117
@subapp.get("/hello/{name}")

tests/contrib/fastapi/test_fastapi.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,3 +552,20 @@ def test_table_query_snapshot(snapshot_client):
552552
"name": "Test Name",
553553
"description": "This request adds a new entry to the test db",
554554
}
555+
556+
557+
def test_background_task(client, tracer, test_spans):
558+
"""Tests if background tasks have been excluded from span duration"""
559+
response = client.get("/asynctask")
560+
assert response.status_code == 200
561+
assert response.json() == "task added"
562+
spans = test_spans.pop_traces()
563+
assert len(spans) == 1
564+
assert len(spans[0]) == 2
565+
request_span, serialize_span = spans[0]
566+
567+
assert request_span.name == "fastapi.request"
568+
assert request_span.resource == "GET /asynctask"
569+
# typical duration without background task should be in less than 10 ms
570+
# duration with background task will take approximately 1.1s
571+
assert request_span.duration < 1

tests/contrib/starlette/app.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import asyncio
12
from tempfile import NamedTemporaryFile
23
import time
34

45
import databases
56
import sqlalchemy
67
from starlette.applications import Starlette
8+
from starlette.background import BackgroundTask
79
from starlette.responses import FileResponse
10+
from starlette.responses import JSONResponse
811
from starlette.responses import PlainTextResponse
912
from starlette.responses import Response
1013
from starlette.responses import StreamingResponse
@@ -106,6 +109,14 @@ async def add_note(request):
106109
response = "Success"
107110
return PlainTextResponse(response)
108111

112+
async def custom_task():
113+
await asyncio.sleep(1)
114+
115+
async def background_task(request):
116+
"""An example endpoint that just adds to background tasks"""
117+
jsonmsg = {"result": "Background task added"}
118+
return JSONResponse(jsonmsg, background=BackgroundTask(custom_task))
119+
109120
routes = [
110121
Route("/", endpoint=homepage, name="homepage", methods=["GET"]),
111122
Route("/200", endpoint=success, name="200", methods=["GET"]),
@@ -119,6 +130,7 @@ async def add_note(request):
119130
Route("/notes", endpoint=list_notes, methods=["GET"]),
120131
Route("/notes", endpoint=add_note, methods=["POST"]),
121132
Mount("/sub-app", Starlette(routes=[Route("/hello/{name}", endpoint=success, name="200", methods=["GET"])])),
133+
Route("/backgroundtask", endpoint=background_task, name="200", methods=["GET"]),
122134
]
123135

124136
app = Starlette(routes=routes, on_startup=[database.connect], on_shutdown=[database.disconnect])

tests/contrib/starlette/test_starlette.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,3 +450,17 @@ def test_incorrect_patching(run_python_code_in_subprocess):
450450
assert status == 0, err
451451
assert out == b"", err
452452
assert err == b"datadog context not present in ASGI request scope, trace middleware may be missing\n"
453+
454+
455+
def test_background_task(client, tracer, test_spans):
456+
"""Tests if background tasks have been excluded from span duration"""
457+
r = client.get("/backgroundtask")
458+
assert r.status_code == 200
459+
assert r.text == '{"result":"Background task added"}'
460+
461+
request_span = next(test_spans.filter_spans(name="starlette.request"))
462+
assert request_span.name == "starlette.request"
463+
assert request_span.resource == "GET /backgroundtask"
464+
# typical duration without background task should be in less than 10ms
465+
# duration with background task will take approximately 1.1s
466+
assert request_span.duration < 1

0 commit comments

Comments
 (0)