Skip to content

Commit 140e5f9

Browse files
authored
RequestsInstrumentor: Add support for prepared requests (#1040)
in addition to Session.request instrument Session.send to also create spans for prepared requests.
1 parent c435600 commit 140e5f9

File tree

4 files changed

+185
-126
lines changed

4 files changed

+185
-126
lines changed

docs/getting_started/tests/test_flask.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,6 @@ def test_flask(self):
3838
server.terminate()
3939

4040
output = str(server.stdout.read())
41-
self.assertIn('"name": "HTTP get"', output)
41+
self.assertIn('"name": "HTTP GET"', output)
4242
self.assertIn('"name": "example-request"', output)
4343
self.assertIn('"name": "hello"', output)

instrumentation/opentelemetry-instrumentation-requests/CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Add support for instrumenting prepared requests
6+
([#1040](https://github.com/open-telemetry/opentelemetry-python/pull/1040))
7+
58
## Version 0.12b0
69

710
Released 2020-08-14

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

+76-34
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,17 @@
2929
opentelemetry.instrumentation.requests.RequestsInstrumentor().instrument()
3030
response = requests.get(url="https://www.example.org/")
3131
32-
Limitations
33-
-----------
34-
35-
Note that calls that do not use the higher-level APIs but use
36-
:code:`requests.sessions.Session.send` (or an alias thereof) directly, are
37-
currently not traced. If you find any other way to trigger an untraced HTTP
38-
request, please report it via a GitHub issue with :code:`[requests: untraced
39-
API]` in the title.
40-
4132
API
4233
---
4334
"""
4435

4536
import functools
4637
import types
47-
from urllib.parse import urlparse
4838

4939
from requests import Timeout, URLRequired
5040
from requests.exceptions import InvalidSchema, InvalidURL, MissingSchema
5141
from requests.sessions import Session
42+
from requests.structures import CaseInsensitiveDict
5243

5344
from opentelemetry import context, propagators
5445
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
@@ -57,6 +48,10 @@
5748
from opentelemetry.trace import SpanKind, get_tracer
5849
from opentelemetry.trace.status import Status, StatusCanonicalCode
5950

51+
# A key to a context variable to avoid creating duplicate spans when instrumenting
52+
# both, Session.request and Session.send, since Session.request calls into Session.send
53+
_SUPPRESS_REQUESTS_INSTRUMENTATION_KEY = "suppress_requests_instrumentation"
54+
6055

6156
# pylint: disable=unused-argument
6257
def _instrument(tracer_provider=None, span_callback=None):
@@ -71,15 +66,54 @@ def _instrument(tracer_provider=None, span_callback=None):
7166
# before v1.0.0, Dec 17, 2012, see
7267
# https://github.com/psf/requests/commit/4e5c4a6ab7bb0195dececdd19bb8505b872fe120)
7368

74-
wrapped = Session.request
69+
wrapped_request = Session.request
70+
wrapped_send = Session.send
7571

76-
@functools.wraps(wrapped)
72+
@functools.wraps(wrapped_request)
7773
def instrumented_request(self, method, url, *args, **kwargs):
78-
if context.get_value("suppress_instrumentation"):
79-
return wrapped(self, method, url, *args, **kwargs)
74+
def get_or_create_headers():
75+
headers = kwargs.get("headers")
76+
if headers is None:
77+
headers = {}
78+
kwargs["headers"] = headers
79+
80+
return headers
81+
82+
def call_wrapped():
83+
return wrapped_request(self, method, url, *args, **kwargs)
84+
85+
return _instrumented_requests_call(
86+
method, url, call_wrapped, get_or_create_headers
87+
)
88+
89+
@functools.wraps(wrapped_send)
90+
def instrumented_send(self, request, **kwargs):
91+
def get_or_create_headers():
92+
request.headers = (
93+
request.headers
94+
if request.headers is not None
95+
else CaseInsensitiveDict()
96+
)
97+
return request.headers
98+
99+
def call_wrapped():
100+
return wrapped_send(self, request, **kwargs)
101+
102+
return _instrumented_requests_call(
103+
request.method, request.url, call_wrapped, get_or_create_headers
104+
)
105+
106+
def _instrumented_requests_call(
107+
method: str, url: str, call_wrapped, get_or_create_headers
108+
):
109+
if context.get_value("suppress_instrumentation") or context.get_value(
110+
_SUPPRESS_REQUESTS_INSTRUMENTATION_KEY
111+
):
112+
return call_wrapped()
80113

81114
# See
82115
# https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/semantic_conventions/http.md#http-client
116+
method = method.upper()
83117
span_name = "HTTP {}".format(method)
84118

85119
exception = None
@@ -91,17 +125,19 @@ def instrumented_request(self, method, url, *args, **kwargs):
91125
span.set_attribute("http.method", method.upper())
92126
span.set_attribute("http.url", url)
93127

94-
headers = kwargs.get("headers", {}) or {}
128+
headers = get_or_create_headers()
95129
propagators.inject(type(headers).__setitem__, headers)
96-
kwargs["headers"] = headers
97130

131+
token = context.attach(
132+
context.set_value(_SUPPRESS_REQUESTS_INSTRUMENTATION_KEY, True)
133+
)
98134
try:
99-
result = wrapped(
100-
self, method, url, *args, **kwargs
101-
) # *** PROCEED
135+
result = call_wrapped() # *** PROCEED
102136
except Exception as exc: # pylint: disable=W0703
103137
exception = exc
104138
result = getattr(exc, "response", None)
139+
finally:
140+
context.detach(token)
105141

106142
if exception is not None:
107143
span.set_status(
@@ -124,24 +160,34 @@ def instrumented_request(self, method, url, *args, **kwargs):
124160

125161
return result
126162

127-
instrumented_request.opentelemetry_ext_requests_applied = True
128-
163+
instrumented_request.opentelemetry_instrumentation_requests_applied = True
129164
Session.request = instrumented_request
130165

131-
# TODO: We should also instrument requests.sessions.Session.send
132-
# but to avoid doubled spans, we would need some context-local
133-
# state (i.e., only create a Span if the current context's URL is
134-
# different, then push the current URL, pop it afterwards)
166+
instrumented_send.opentelemetry_instrumentation_requests_applied = True
167+
Session.send = instrumented_send
135168

136169

137170
def _uninstrument():
138-
# pylint: disable=global-statement
139171
"""Disables instrumentation of :code:`requests` through this module.
140172
141173
Note that this only works if no other module also patches requests."""
142-
if getattr(Session.request, "opentelemetry_ext_requests_applied", False):
143-
original = Session.request.__wrapped__ # pylint:disable=no-member
144-
Session.request = original
174+
_uninstrument_from(Session)
175+
176+
177+
def _uninstrument_from(instr_root, restore_as_bound_func=False):
178+
for instr_func_name in ("request", "send"):
179+
instr_func = getattr(instr_root, instr_func_name)
180+
if not getattr(
181+
instr_func,
182+
"opentelemetry_instrumentation_requests_applied",
183+
False,
184+
):
185+
continue
186+
187+
original = instr_func.__wrapped__ # pylint:disable=no-member
188+
if restore_as_bound_func:
189+
original = types.MethodType(original, instr_root)
190+
setattr(instr_root, instr_func_name, original)
145191

146192

147193
def _exception_to_canonical_code(exc: Exception) -> StatusCanonicalCode:
@@ -179,8 +225,4 @@ def _uninstrument(self, **kwargs):
179225
@staticmethod
180226
def uninstrument_session(session):
181227
"""Disables instrumentation on the session object."""
182-
if getattr(
183-
session.request, "opentelemetry_ext_requests_applied", False
184-
):
185-
original = session.request.__wrapped__ # pylint:disable=no-member
186-
session.request = types.MethodType(original, session)
228+
_uninstrument_from(session, restore_as_bound_func=True)

0 commit comments

Comments
 (0)