Skip to content

Commit f90fb5e

Browse files
authored
Update test framework for starlette 0.15.0 (#1174)
* Update test framework for starlette 0.15.0 * Migrate to ASGI middleware for starlette * CHANGELOG * Don't capture response body Turns out we don't do this in other frameworks.
1 parent bfae7bb commit f90fb5e

File tree

9 files changed

+74
-53
lines changed

9 files changed

+74
-53
lines changed

.ci/.jenkins_exclude.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ exclude:
204204
FRAMEWORK: starlette-0.13
205205
- PYTHON_VERSION: python-3.6
206206
FRAMEWORK: starlette-0.13
207+
- PYTHON_VERSION: pypy-3
208+
FRAMEWORK: starlette-0.14
209+
- PYTHON_VERSION: python-3.6
210+
FRAMEWORK: starlette-0.14
207211
- PYTHON_VERSION: pypy-3
208212
FRAMEWORK: starlette-newest
209213
- PYTHON_VERSION: python-3.6

.ci/.jenkins_framework_full.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ FRAMEWORK:
7070
- asyncpg-newest
7171
- tornado-newest
7272
- starlette-0.13
73-
# - starlette-newest # disabled for now, see https://github.com/elastic/apm-agent-python/issues/1172
73+
- starlette-0.14
74+
- starlette-newest
7475
- pymemcache-3.0
7576
- pymemcache-newest
7677
- graphene-2

CHANGELOG.asciidoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ endif::[]
4242
[float]
4343
===== Bug fixes
4444
45+
* Fix for Starlette 0.15.0 error collection {pull}1174[#1174]
4546
* Fix for Starlette static files {pull}1137[#1137]
4647
4748
[[release-notes-6.x]]

elasticapm/contrib/starlette/__init__.py

Lines changed: 54 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@
3131

3232
from __future__ import absolute_import
3333

34+
import asyncio
35+
import functools
3436
from typing import Dict, Optional
3537

3638
import starlette
37-
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
3839
from starlette.requests import Request
39-
from starlette.responses import Response
4040
from starlette.routing import Match, Mount
41-
from starlette.types import ASGIApp
41+
from starlette.types import ASGIApp, Message
4242

4343
import elasticapm
4444
import elasticapm.instrumentation.control
@@ -70,32 +70,28 @@ def make_apm_client(config: Optional[Dict] = None, client_cls=Client, **defaults
7070
return client_cls(config, **defaults)
7171

7272

73-
class ElasticAPM(BaseHTTPMiddleware):
73+
class ElasticAPM:
7474
"""
7575
Starlette / FastAPI middleware for Elastic APM capturing.
7676
77-
>>> elasticapm = make_apm_client({
77+
>>> apm = make_apm_client({
7878
>>> 'SERVICE_NAME': 'myapp',
7979
>>> 'DEBUG': True,
8080
>>> 'SERVER_URL': 'http://localhost:8200',
8181
>>> 'CAPTURE_HEADERS': True,
8282
>>> 'CAPTURE_BODY': 'all'
8383
>>> })
8484
85-
>>> app.add_middleware(ElasticAPM, client=elasticapm)
85+
>>> app.add_middleware(ElasticAPM, client=apm)
8686
8787
Pass an arbitrary APP_NAME and SECRET_TOKEN::
8888
8989
>>> elasticapm = ElasticAPM(app, service_name='myapp', secret_token='asdasdasd')
9090
91-
Pass an explicit client::
91+
Pass an explicit client (don't pass in additional options in this case)::
9292
9393
>>> elasticapm = ElasticAPM(app, client=client)
9494
95-
Automatically configure logging::
96-
97-
>>> elasticapm = ElasticAPM(app, logging=True)
98-
9995
Capture an exception::
10096
10197
>>> try:
@@ -108,34 +104,69 @@ class ElasticAPM(BaseHTTPMiddleware):
108104
>>> elasticapm.capture_message('hello, world!')
109105
"""
110106

111-
def __init__(self, app: ASGIApp, client: Client):
107+
def __init__(self, app: ASGIApp, client: Optional[Client], **kwargs):
112108
"""
113109
114110
Args:
115111
app (ASGIApp): Starlette app
116112
client (Client): ElasticAPM Client
117113
"""
118-
self.client = client
114+
if client:
115+
self.client = client
116+
else:
117+
self.client = make_apm_client(**kwargs)
119118

120119
if self.client.config.instrument and self.client.config.enabled:
121120
elasticapm.instrumentation.control.instrument()
122121

123-
super().__init__(app)
124-
125-
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
126-
"""Processes the whole request APM capturing.
122+
# If we ever make this a general-use ASGI middleware we should use
123+
# `asgiref.conpatibility.guarantee_single_callable(app)` here
124+
self.app = app
127125

126+
async def __call__(self, scope, receive, send):
127+
"""
128128
Args:
129-
request (Request)
130-
call_next (RequestResponseEndpoint): Next request process in Starlette.
131-
132-
Returns:
133-
Response
129+
scope: ASGI scope dictionary
130+
receive: receive awaitable callable
131+
send: send awaitable callable
134132
"""
133+
134+
@functools.wraps(send)
135+
async def wrapped_send(message):
136+
if message.get("type") == "http.response.start":
137+
await set_context(
138+
lambda: get_data_from_response(message, self.client.config, constants.TRANSACTION), "response"
139+
)
140+
result = "HTTP {}xx".format(message["status"] // 100)
141+
elasticapm.set_transaction_result(result, override=False)
142+
await send(message)
143+
144+
# When we consume the body from receive, we replace the streaming
145+
# mechanism with a mocked version -- this workaround came from
146+
# https://github.com/encode/starlette/issues/495#issuecomment-513138055
147+
body = b""
148+
while True:
149+
message = await receive()
150+
if not message:
151+
break
152+
if message["type"] == "http.request":
153+
b = message.get("body", b"")
154+
if b:
155+
body += b
156+
if not message.get("more_body", False):
157+
break
158+
if message["type"] == "http.disconnect":
159+
break
160+
161+
async def _receive() -> Message:
162+
await asyncio.sleep(0)
163+
return {"type": "http.request", "body": body}
164+
165+
request = Request(scope, receive=_receive)
135166
await self._request_started(request)
136167

137168
try:
138-
response = await call_next(request)
169+
await self.app(scope, _receive, wrapped_send)
139170
elasticapm.set_transaction_outcome(constants.OUTCOME.SUCCESS, override=False)
140171
except Exception:
141172
await self.capture_exception(
@@ -146,13 +177,9 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
146177
elasticapm.set_context({"status_code": 500}, "response")
147178

148179
raise
149-
else:
150-
await self._request_finished(response)
151180
finally:
152181
self.client.end_transaction()
153182

154-
return response
155-
156183
async def capture_exception(self, *args, **kwargs):
157184
"""Captures your exception.
158185
@@ -195,19 +222,6 @@ async def _request_started(self, request: Request):
195222
transaction_name = self.get_route_name(request) or request.url.path
196223
elasticapm.set_transaction_name("{} {}".format(request.method, transaction_name), override=False)
197224

198-
async def _request_finished(self, response: Response):
199-
"""Captures the end of the request processing to APM.
200-
201-
Args:
202-
response (Response)
203-
"""
204-
await set_context(
205-
lambda: get_data_from_response(response, self.client.config, constants.TRANSACTION), "response"
206-
)
207-
208-
result = "HTTP {}xx".format(response.status_code // 100)
209-
elasticapm.set_transaction_result(result, override=False)
210-
211225
def get_route_name(self, request: Request) -> str:
212226
app = request.app
213227
scope = request.scope

elasticapm/contrib/starlette/utils.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
3030
import asyncio
3131

32+
from starlette.datastructures import Headers
3233
from starlette.requests import Request
33-
from starlette.responses import Response
3434
from starlette.types import Message
3535

3636
from elasticapm.conf import Config, constants
@@ -73,11 +73,11 @@ async def get_data_from_request(request: Request, config: Config, event_type: st
7373
return result
7474

7575

76-
async def get_data_from_response(response: Response, config: Config, event_type: str) -> dict:
76+
async def get_data_from_response(message: dict, config: Config, event_type: str) -> dict:
7777
"""Loads data from response for APM capturing.
7878
7979
Args:
80-
response (Response)
80+
message (dict)
8181
config (Config)
8282
event_type (str)
8383
@@ -86,16 +86,13 @@ async def get_data_from_response(response: Response, config: Config, event_type:
8686
"""
8787
result = {}
8888

89-
if isinstance(getattr(response, "status_code", None), compat.integer_types):
90-
result["status_code"] = response.status_code
89+
if "status_code" in message:
90+
result["status_code"] = message["status"]
9191

92-
if config.capture_headers and getattr(response, "headers", None):
93-
headers = response.headers
92+
if config.capture_headers and "headers" in message:
93+
headers = Headers(raw=message["headers"])
9494
result["headers"] = {key: ";".join(headers.getlist(key)) for key in compat.iterkeys(headers)}
9595

96-
if config.capture_body in ("all", event_type) and hasattr(response, "body"):
97-
result["body"] = response.body.decode("utf-8")
98-
9996
return result
10097

10198

tests/contrib/asyncio/starlette_tests.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def app(elasticapm_client):
6666

6767
@app.route("/", methods=["GET", "POST"])
6868
async def hi(request):
69+
await request.body()
6970
with async_capture_span("test"):
7071
pass
7172
return PlainTextResponse("ok")
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
starlette==0.13.0
1+
starlette>=0.13,<0.14
22
aiofiles==0.7.0
33
requests==2.23.0
44
-r reqs-base.txt
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
starlette>=0.14,<0.15
2+
requests==2.23.0
3+
-r reqs-base.txt

tests/requirements/reqs-starlette-newest.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
starlette
1+
starlette>=0.15
22
aiofiles
33
requests
44
flask

0 commit comments

Comments
 (0)