Skip to content

Commit e01327b

Browse files
authored
Add starlette ServerErrorMiddleware instrumentation (#1674)
* Add starlette ServerErrorMiddleware instrumentation * Set creates_transactions to True so the instrumentation works * Add a test * CHANGELOG
1 parent 533bcb2 commit e01327b

File tree

5 files changed

+83
-3
lines changed

5 files changed

+83
-3
lines changed

CHANGELOG.asciidoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ endif::[]
4747
* Fix Django's `manage.py check` when agent is disabled {pull}1632[#1632]
4848
* Fix an issue with long body truncation for Starlette {pull}1635[#1635]
4949
* Fix an issue with transaction outcomes in Flask for uncaught exceptions {pull}1637[#1637]
50+
* FIx Starlette instrumentation to make sure transaction information is still present during exception handling {pull}1674[#1674]
5051
5152
5253
[[release-notes-6.x]]

elasticapm/contrib/starlette/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ async def request_receive() -> Message:
185185
request = Request(scope, receive=_mocked_receive or receive)
186186
await self._request_started(request)
187187

188+
# We don't end the transaction here, we rely on the starlette
189+
# instrumentation of ServerErrorMiddleware to end the transaction
188190
try:
189191
await self.app(scope, _request_receive or receive, wrapped_send)
190192
elasticapm.set_transaction_outcome(constants.OUTCOME.SUCCESS, override=False)
@@ -197,8 +199,6 @@ async def request_receive() -> Message:
197199
elasticapm.set_context({"status_code": 500}, "response")
198200

199201
raise
200-
finally:
201-
self.client.end_transaction()
202202

203203
async def capture_exception(self, *args, **kwargs):
204204
"""Captures your exception.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# BSD 3-Clause License
2+
#
3+
# Copyright (c) 2019, Elasticsearch BV
4+
# All rights reserved.
5+
#
6+
# Redistribution and use in source and binary forms, with or without
7+
# modification, are permitted provided that the following conditions are met:
8+
#
9+
# * Redistributions of source code must retain the above copyright notice, this
10+
# list of conditions and the following disclaimer.
11+
#
12+
# * Redistributions in binary form must reproduce the above copyright notice,
13+
# this list of conditions and the following disclaimer in the documentation
14+
# and/or other materials provided with the distribution.
15+
#
16+
# * Neither the name of the copyright holder nor the names of its
17+
# contributors may be used to endorse or promote products derived from
18+
# this software without specific prior written permission.
19+
#
20+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
31+
from elasticapm import get_client
32+
from elasticapm.instrumentation.packages.asyncio.base import AsyncAbstractInstrumentedModule
33+
34+
35+
class StarletteServerErrorMiddlewareInstrumentation(AsyncAbstractInstrumentedModule):
36+
name = "starlette"
37+
38+
instrument_list = [("starlette.middleware.errors", "ServerErrorMiddleware.__call__")]
39+
40+
# This instrumentation doesn't actually create transactions. However, it
41+
# does wrap a context outside of the normal starlette transaction, so we
42+
# need to make sure it always calls the wrapped version even if a sampled
43+
# transaction is not active.
44+
creates_transactions = True
45+
46+
async def call(self, module, method, wrapped, instance, args, kwargs):
47+
try:
48+
return await wrapped(*args, **kwargs)
49+
finally:
50+
client = get_client()
51+
if client:
52+
client.end_transaction()

elasticapm/instrumentation/register.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"elasticapm.instrumentation.packages.asyncio.aioredis.RedisConnectionInstrumentation",
8989
"elasticapm.instrumentation.packages.asyncio.aiomysql.AioMySQLInstrumentation",
9090
"elasticapm.instrumentation.packages.asyncio.aiobotocore.AioBotocoreInstrumentation",
91+
"elasticapm.instrumentation.packages.asyncio.starlette.StarletteServerErrorMiddlewareInstrumentation",
9192
]
9293
)
9394

tests/contrib/asyncio/starlette_tests.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,13 @@
2828
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
2929
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
3030

31-
import pytest # isort:skip
31+
from shutil import ExecError
3232

3333
from tests.fixtures import TempStoreClient
3434

35+
import pytest # isort:skip
36+
37+
3538
starlette = pytest.importorskip("starlette") # isort:skip
3639

3740
import os
@@ -65,6 +68,12 @@ def app(elasticapm_client):
6568
app.mount("/sub", sub)
6669
sub.mount("/subsub", subsub)
6770

71+
@app.exception_handler(Exception)
72+
async def handle_exception(request, exc):
73+
transaction_id = elasticapm.get_transaction_id()
74+
exc.transaction_id = transaction_id
75+
return PlainTextResponse(f"{transaction_id}", status_code=500)
76+
6877
@app.route("/", methods=["GET", "POST"])
6978
async def hi(request):
7079
body = await request.body()
@@ -88,6 +97,11 @@ async def raise_exception(request):
8897
await request.body()
8998
raise ValueError()
9099

100+
@app.route("/raise-base-exception", methods=["GET", "POST"])
101+
async def raise_base_exception(request):
102+
await request.body()
103+
raise Exception()
104+
91105
@app.route("/hi/{name}/with/slash/", methods=["GET", "POST"])
92106
async def with_slash(request):
93107
return PlainTextResponse("Hi {}".format(request.path_params["name"]))
@@ -508,3 +522,15 @@ def test_websocket(app, elasticapm_client):
508522
assert data == "Hello, world!"
509523

510524
assert len(elasticapm_client.events[constants.TRANSACTION]) == 0
525+
526+
527+
def test_transaction_active_in_base_exception_handler(app, elasticapm_client):
528+
client = TestClient(app)
529+
try:
530+
response = client.get("/raise-base-exception")
531+
except Exception as exc:
532+
# This is set by the exception handler -- we want to make sure the
533+
# handler has access to the transaction.
534+
assert exc.transaction_id
535+
536+
assert len(elasticapm_client.events[constants.TRANSACTION]) == 1

0 commit comments

Comments
 (0)