Skip to content

Commit e4a9827

Browse files
committed
implement "outcome" property for transactions and spans
This implements elastic/apm#299. Additionally, the "status_code" attribute has been added to HTTP spans.
1 parent ed4ce5f commit e4a9827

File tree

13 files changed

+228
-9
lines changed

13 files changed

+228
-9
lines changed

elasticapm/instrumentation/packages/asyncio/aiohttp_client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,12 @@ async def call(self, module, method, wrapped, instance, args, kwargs):
6969
headers = kwargs.get("headers") or {}
7070
self._set_disttracing_headers(headers, trace_parent, transaction)
7171
kwargs["headers"] = headers
72-
return await wrapped(*args, **kwargs)
72+
response = await wrapped(*args, **kwargs)
73+
if response:
74+
if span.context:
75+
span.context["http"]["status_code"] = response.status
76+
span.set_success() if response.status < 400 else span.set_failure()
77+
return response
7378

7479
def mutate_unsampled_call_args(self, module, method, wrapped, instance, args, kwargs, transaction):
7580
# since we don't have a span, we set the span id to the transaction id

elasticapm/instrumentation/packages/requests.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,11 @@ def call(self, module, method, wrapped, instance, args, kwargs):
5555
span_subtype="http",
5656
extra={"http": {"url": url}, "destination": destination},
5757
leaf=True,
58-
):
59-
return wrapped(*args, **kwargs)
58+
) as span:
59+
response = wrapped(*args, **kwargs)
60+
# requests.Response objects are falsy if status code > 400, so we have to check for None instead
61+
if response is not None:
62+
if span.context:
63+
span.context["http"]["status_code"] = response.status_code
64+
span.set_success() if response.status_code < 400 else span.set_failure()
65+
return response

elasticapm/instrumentation/packages/urllib.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,12 @@ def call(self, module, method, wrapped, instance, args, kwargs):
9696
span_id=parent_id, trace_options=TracingOptions(recorded=True)
9797
)
9898
self._set_disttracing_headers(request_object, trace_parent, transaction)
99-
return wrapped(*args, **kwargs)
99+
response = wrapped(*args, **kwargs)
100+
if response:
101+
if span.context:
102+
span.context["http"]["status_code"] = response.status
103+
span.set_success() if response.status < 400 else span.set_failure()
104+
return response
100105

101106
def mutate_unsampled_call_args(self, module, method, wrapped, instance, args, kwargs, transaction):
102107
request_object = args[1] if len(args) > 1 else kwargs["req"]

elasticapm/instrumentation/packages/urllib3.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,12 @@ def call(self, module, method, wrapped, instance, args, kwargs):
9595
span_id=parent_id, trace_options=TracingOptions(recorded=True)
9696
)
9797
self._set_disttracing_headers(headers, trace_parent, transaction)
98-
return wrapped(*args, **kwargs)
98+
response = wrapped(*args, **kwargs)
99+
if response:
100+
if span.context:
101+
span.context["http"]["status_code"] = response.status
102+
span.set_success() if response.status < 400 else span.set_failure()
103+
return response
99104

100105
def mutate_unsampled_call_args(self, module, method, wrapped, instance, args, kwargs, transaction):
101106
# since we don't have a span, we set the span id to the transaction id

elasticapm/traces.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ class BaseSpan(object):
8888
def __init__(self, labels=None):
8989
self._child_durations = ChildDuration(self)
9090
self.labels = {}
91+
self.outcome = "unknown"
9192
if labels:
9293
self.label(**labels)
9394

@@ -132,6 +133,12 @@ def tag(self, **tags):
132133
for key in tags.keys():
133134
self.labels[LABEL_RE.sub("_", compat.text_type(key))] = encoding.keyword_field(compat.text_type(tags[key]))
134135

136+
def set_success(self):
137+
self.outcome = "success"
138+
139+
def set_failure(self):
140+
self.outcome = "failure"
141+
135142

136143
class Transaction(BaseSpan):
137144
def __init__(self, tracer, transaction_type="custom", trace_parent=None, is_sampled=True, start=None):
@@ -176,6 +183,10 @@ def end(self, skip_frames=0, duration=None):
176183
reset_on_collect=True,
177184
**{"transaction.name": self.name, "transaction.type": self.transaction_type}
178185
).update(self.duration)
186+
if self.outcome == "unknown" and self.context:
187+
status_code = self.context.get("response", {}).get("status_code", None)
188+
if isinstance(status_code, int):
189+
self.outcome = "success" if status_code < 500 else "failure"
179190
if self._breakdown:
180191
for (span_type, span_subtype), timer in compat.iteritems(self._span_timers):
181192
labels = {
@@ -274,17 +285,22 @@ def begin_span(
274285
start=start,
275286
)
276287

277-
def end_span(self, skip_frames=0, duration=None):
288+
def end_span(self, skip_frames=0, duration=None, outcome=None):
278289
"""
279290
End the currently active span
280291
:param skip_frames: numbers of frames to skip in the stack trace
281292
:param duration: override duration, mostly useful for testing
293+
:param outcome: outcome of the span, either success, failure or unknown
282294
:return: the ended span
283295
"""
284296
span = execution_context.get_span()
285297
if span is None:
286298
raise LookupError()
287299

300+
# only overwrite span outcome if it is still unknown
301+
if span.outcome == "unknown":
302+
span.outcome = outcome
303+
288304
span.end(skip_frames=skip_frames, duration=duration)
289305
return span
290306

@@ -309,6 +325,7 @@ def to_dict(self):
309325
"duration": self.duration * 1000, # milliseconds
310326
"result": encoding.keyword_field(str(self.result)),
311327
"timestamp": int(self.timestamp * 1000000), # microseconds
328+
"outcome": self.outcome,
312329
"sampled": self.is_sampled,
313330
"span_count": {"started": self._span_counter - self.dropped_spans, "dropped": self.dropped_spans},
314331
}
@@ -346,6 +363,7 @@ class Span(BaseSpan):
346363
"frames",
347364
"labels",
348365
"sync",
366+
"outcome",
349367
"_child_durations",
350368
)
351369

@@ -423,6 +441,7 @@ def to_dict(self):
423441
"action": encoding.keyword_field(self.action),
424442
"timestamp": int(self.timestamp * 1000000), # microseconds
425443
"duration": self.duration * 1000, # milliseconds
444+
"outcome": self.outcome,
426445
}
427446
if self.sync is not None:
428447
result["sync"] = self.sync
@@ -512,6 +531,14 @@ def action(self):
512531
def context(self):
513532
return None
514533

534+
@property
535+
def outcome(self):
536+
return "unknown"
537+
538+
@outcome.setter
539+
def outcome(self, value):
540+
return
541+
515542

516543
class Tracer(object):
517544
def __init__(self, frames_collector_func, frames_processing_func, queue_func, config, agent):
@@ -663,7 +690,8 @@ def __exit__(self, exc_type, exc_val, exc_tb):
663690

664691
if transaction and transaction.is_sampled:
665692
try:
666-
span = transaction.end_span(self.skip_frames, duration=self.duration)
693+
outcome = "failure" if exc_val else "success"
694+
span = transaction.end_span(self.skip_frames, duration=self.duration, outcome=outcome)
667695
if exc_val and not isinstance(span, DroppedSpan):
668696
try:
669697
exc_val._elastic_apm_span_id = span.id

tests/contrib/django/django_tests.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,28 @@ def test_transaction_metrics(django_elasticapm_client, client):
757757
assert transaction["duration"] > 0
758758
assert transaction["result"] == "HTTP 2xx"
759759
assert transaction["name"] == "GET tests.contrib.django.testapp.views.no_error"
760+
assert transaction["outcome"] == "success"
761+
762+
763+
def test_transaction_metrics_error(django_elasticapm_client, client):
764+
with override_settings(
765+
**middleware_setting(django.VERSION, ["elasticapm.contrib.django.middleware.TracingMiddleware"])
766+
):
767+
assert len(django_elasticapm_client.events[TRANSACTION]) == 0
768+
try:
769+
client.get(reverse("elasticapm-http-error", args=(500,)))
770+
except Exception:
771+
pass
772+
assert len(django_elasticapm_client.events[TRANSACTION]) == 1
773+
774+
transactions = django_elasticapm_client.events[TRANSACTION]
775+
776+
assert len(transactions) == 1
777+
transaction = transactions[0]
778+
assert transaction["duration"] > 0
779+
assert transaction["result"] == "HTTP 5xx"
780+
assert transaction["name"] == "GET tests.contrib.django.testapp.views.http_error"
781+
assert transaction["outcome"] == "failure"
760782

761783

762784
def test_transaction_metrics_debug(django_elasticapm_client, client):

tests/contrib/django/testapp/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def handler500(request):
4949
url(r"^render-user-template$", views.render_user_view, name="render-user-template"),
5050
url(r"^no-error$", views.no_error, name="elasticapm-no-error"),
5151
url(r"^no-error-slash/$", views.no_error, name="elasticapm-no-error-slash"),
52+
url(r"^http-error/(?P<status>[0-9]{3})$", views.http_error, name="elasticapm-http-error"),
5253
url(r"^logging$", views.logging_view, name="elasticapm-logging"),
5354
url(r"^ignored-exception/$", views.ignored_exception, name="elasticapm-ignored-exception"),
5455
url(r"^fake-login$", views.fake_login, name="elasticapm-fake-login"),

tests/contrib/django/testapp/views.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ def no_error(request, id=None):
5555
return resp
5656

5757

58+
def http_error(request, status=None):
59+
if status:
60+
status = int(status)
61+
resp = HttpResponse(status=status)
62+
return resp
63+
64+
5865
def fake_login(request):
5966
return HttpResponse("")
6067

tests/instrumentation/asyncio/aiohttp_client_tests.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,44 @@ async def test_http_get(instrument, event_loop, elasticapm_client, waiting_https
6262
assert span["subtype"] == "http"
6363
assert span["sync"] is False
6464
assert span["context"]["http"]["url"] == waiting_httpserver.url
65+
assert span["context"]["http"]["status_code"] == 200
6566
assert spans[0]["context"]["destination"]["service"] == {
6667
"name": "http://127.0.0.1:%d" % waiting_httpserver.server_address[1],
6768
"resource": "127.0.0.1:%d" % waiting_httpserver.server_address[1],
6869
"type": "external",
6970
}
71+
assert spans[0]["outcome"] == "success"
72+
73+
74+
@pytest.mark.parametrize("status_code", [400, 500])
75+
async def test_http_get_error(instrument, event_loop, elasticapm_client, waiting_httpserver, status_code):
76+
assert event_loop.is_running()
77+
elasticapm_client.begin_transaction("test")
78+
waiting_httpserver.serve_content("", code=status_code)
79+
url = waiting_httpserver.url
80+
81+
async with aiohttp.ClientSession() as session:
82+
async with session.get(waiting_httpserver.url) as resp:
83+
status = resp.status
84+
text = await resp.text()
85+
86+
elasticapm_client.end_transaction()
87+
transaction = elasticapm_client.events[constants.TRANSACTION][0]
88+
spans = elasticapm_client.spans_for_transaction(transaction)
89+
assert len(spans) == 1
90+
span = spans[0]
91+
assert span["name"] == "GET %s:%s" % waiting_httpserver.server_address
92+
assert span["type"] == "external"
93+
assert span["subtype"] == "http"
94+
assert span["sync"] is False
95+
assert span["context"]["http"]["url"] == waiting_httpserver.url
96+
assert span["context"]["http"]["status_code"] == status_code
97+
assert spans[0]["context"]["destination"]["service"] == {
98+
"name": "http://127.0.0.1:%d" % waiting_httpserver.server_address[1],
99+
"resource": "127.0.0.1:%d" % waiting_httpserver.server_address[1],
100+
"type": "external",
101+
}
102+
assert spans[0]["outcome"] == "failure"
70103

71104

72105
@pytest.mark.parametrize(

tests/instrumentation/base_tests.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939
import elasticapm
4040
from elasticapm.conf import constants
41-
from elasticapm.conf.constants import SPAN
41+
from elasticapm.conf.constants import SPAN, TRANSACTION
4242
from elasticapm.instrumentation.packages.base import AbstractInstrumentedModule
4343
from elasticapm.utils import compat, wrapt
4444

@@ -193,3 +193,19 @@ def test_end_nonexisting_span(caplog, elasticapm_client):
193193
elasticapm_client.end_transaction("test", "")
194194
record = caplog.records[0]
195195
assert record.args == ("test_name", "test_type")
196+
197+
198+
def test_outcome_by_span_exception(elasticapm_client):
199+
elasticapm_client.begin_transaction("test")
200+
try:
201+
with elasticapm.capture_span("fail", "test_type"):
202+
assert False
203+
except AssertionError:
204+
pass
205+
with elasticapm.capture_span("success", "test_type"):
206+
pass
207+
elasticapm_client.end_transaction("test")
208+
transactions = elasticapm_client.events[TRANSACTION]
209+
spans = elasticapm_client.spans_for_transaction(transactions[0])
210+
assert spans[0]["name"] == "fail" and spans[0]["outcome"] == "failure"
211+
assert spans[1]["name"] == "success" and spans[1]["outcome"] == "success"

0 commit comments

Comments
 (0)