Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RequestsInstrumentor: Add support for prepared requests #1040

Merged
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/getting_started/tests/test_flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,6 @@ def test_flask(self):
server.terminate()

output = str(server.stdout.read())
self.assertIn('"name": "HTTP get"', output)
self.assertIn('"name": "HTTP GET"', output)
self.assertIn('"name": "example-request"', output)
self.assertIn('"name": "hello"', output)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Add support for instrumenting prepared requests
([#1040](https://github.com/open-telemetry/opentelemetry-python/pull/1040))

## Version 0.12b0

Released 2020-08-14
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,15 @@
Limitations
-----------

Note that calls that do not use the higher-level APIs but use
:code:`requests.sessions.Session.send` (or an alias thereof) directly, are
currently not traced. If you find any other way to trigger an untraced HTTP
request, please report it via a GitHub issue with :code:`[requests: untraced
API]` in the title.
If you find any way to trigger an untraced HTTP request, please report it via a
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if this comment even needs to be in the code? An uninstrumented trace feels like a self-evident issue.

GitHub issue with :code:`[requests: untraced API]` in the title.

API
---
"""

import functools
import types
from urllib.parse import urlparse

from requests import Timeout, URLRequired
from requests.exceptions import InvalidSchema, InvalidURL, MissingSchema
Expand All @@ -57,6 +53,10 @@
from opentelemetry.trace import SpanKind, get_tracer
from opentelemetry.trace.status import Status, StatusCanonicalCode

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


# pylint: disable=unused-argument
def _instrument(tracer_provider=None, span_callback=None):
Expand All @@ -71,15 +71,52 @@ def _instrument(tracer_provider=None, span_callback=None):
# before v1.0.0, Dec 17, 2012, see
# https://github.com/psf/requests/commit/4e5c4a6ab7bb0195dececdd19bb8505b872fe120)

wrapped = Session.request
wrapped_request = Session.request
wrapped_send = Session.send

@functools.wraps(wrapped)
@functools.wraps(wrapped_request)
def instrumented_request(self, method, url, *args, **kwargs):
if context.get_value("suppress_instrumentation"):
return wrapped(self, method, url, *args, **kwargs)
def get_or_create_headers():
headers = kwargs.get("headers")
if headers is None:
headers = {}
kwargs["headers"] = headers

return headers

def call_wrapped():
return wrapped_request(self, method, url, *args, **kwargs)

return _instrumented_requests_call(
method, url, call_wrapped, get_or_create_headers
)

@functools.wraps(wrapped_send)
def instrumented_send(self, request, **kwargs):
def get_or_create_headers():
request.headers = (
request.headers if request.headers is not None else {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could also be request.headers or {}, although it comes with a little less type safety.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an empty request.headers would actually change the type from CaseInsensitiveDict to a plain dict.
i guess in the else clause should also use a CaseInsensitiveDict

)
return request.headers

def call_wrapped():
return wrapped_send(self, request, **kwargs)

return _instrumented_requests_call(
request.method, request.url, call_wrapped, get_or_create_headers
)

def _instrumented_requests_call(
method: str, url: str, call_wrapped, get_or_create_headers
):
if context.get_value("suppress_instrumentation") or context.get_value(
_SUPPRESS_REQUESTS_INSTRUMENTATION_KEY
):
return call_wrapped()

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

exception = None
Expand All @@ -91,17 +128,19 @@ def instrumented_request(self, method, url, *args, **kwargs):
span.set_attribute("http.method", method.upper())
span.set_attribute("http.url", url)

headers = kwargs.get("headers", {}) or {}
headers = get_or_create_headers()
propagators.inject(type(headers).__setitem__, headers)
kwargs["headers"] = headers

token = context.attach(
context.set_value(_SUPPRESS_REQUESTS_INSTRUMENTATION_KEY, True)
)
try:
result = wrapped(
self, method, url, *args, **kwargs
) # *** PROCEED
result = call_wrapped() # *** PROCEED
except Exception as exc: # pylint: disable=W0703
exception = exc
result = getattr(exc, "response", None)
finally:
context.detach(token)

if exception is not None:
span.set_status(
Expand All @@ -124,24 +163,34 @@ def instrumented_request(self, method, url, *args, **kwargs):

return result

instrumented_request.opentelemetry_ext_requests_applied = True

instrumented_request.opentelemetry_instrumentation_requests_applied = True
Session.request = instrumented_request

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


def _uninstrument():
# pylint: disable=global-statement
"""Disables instrumentation of :code:`requests` through this module.

Note that this only works if no other module also patches requests."""
if getattr(Session.request, "opentelemetry_ext_requests_applied", False):
original = Session.request.__wrapped__ # pylint:disable=no-member
Session.request = original
_uninstrument_from(Session)


def _uninstrument_from(instr_root, restore_as_bound_func=False):
for instr_func_name in ("request", "send"):
instr_func = getattr(instr_root, instr_func_name)
if not getattr(
instr_func,
"opentelemetry_instrumentation_requests_applied",
False,
):
continue

original = instr_func.__wrapped__ # pylint:disable=no-member
if restore_as_bound_func:
original = types.MethodType(original, instr_root)
setattr(instr_root, instr_func_name, original)


def _exception_to_canonical_code(exc: Exception) -> StatusCanonicalCode:
Expand Down Expand Up @@ -179,8 +228,4 @@ def _uninstrument(self, **kwargs):
@staticmethod
def uninstrument_session(session):
"""Disables instrumentation on the session object."""
if getattr(
session.request, "opentelemetry_ext_requests_applied", False
):
original = session.request.__wrapped__ # pylint:disable=no-member
session.request = types.MethodType(original, session)
_uninstrument_from(session, restore_as_bound_func=True)
Loading