-
Notifications
You must be signed in to change notification settings - Fork 625
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
ext/requests: Add instrumentor #597
Changes from all commits
dceb5c0
35cd548
16d9d1f
13950d0
60d53d5
584f99d
7b7c9aa
e1cb642
9f07b61
a84c687
f6383a8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -14,7 +14,7 @@ | |||
|
||||
""" | ||||
This library allows tracing HTTP requests made by the | ||||
`requests <https://requests.kennethreitz.org/en/master/>`_ library. | ||||
`requests <https://requests.readthedocs.io/en/master/>`_ library. | ||||
|
||||
Usage | ||||
----- | ||||
|
@@ -23,10 +23,10 @@ | |||
|
||||
import requests | ||||
import opentelemetry.ext.http_requests | ||||
from opentelemetry.trace import TracerProvider | ||||
|
||||
opentelemetry.ext.http_requests.enable(TracerProvider()) | ||||
response = requests.get(url='https://www.example.org/') | ||||
# You can optionally pass a custom TracerProvider to RequestInstrumentor.instrument() | ||||
opentelemetry.ext.http_requests.RequestInstrumentor.instrument() | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should this example show how to pass a TracerProvider? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think so. Passing a TracerProvider is an advanced use case, we don't expect it to be common. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. how about the example with the kwarg as none: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this documentation should be as simple as possible and the documentation about the parameters of such function should be on the generated documentation, that we don't know how to do right now: https://github.com/open-telemetry/opentelemetry-python/issues/617. |
||||
response = requests.get(url="https://www.example.org/") | ||||
|
||||
Limitations | ||||
----------- | ||||
|
@@ -47,17 +47,15 @@ | |||
|
||||
from requests.sessions import Session | ||||
|
||||
from opentelemetry import context, propagators | ||||
from opentelemetry import context, propagators, trace | ||||
from opentelemetry.auto_instrumentation.instrumentor import BaseInstrumentor | ||||
from opentelemetry.ext.http_requests.version import __version__ | ||||
from opentelemetry.trace import SpanKind | ||||
from opentelemetry.trace import SpanKind, get_tracer | ||||
from opentelemetry.trace.status import Status, StatusCanonicalCode | ||||
|
||||
|
||||
# NOTE: Currently we force passing a tracer. But in turn, this forces the user | ||||
# to configure a SDK before enabling this integration. In turn, this means that | ||||
# if the SDK/tracer is already using `requests` they may, in theory, bypass our | ||||
# instrumentation when using `import from`, etc. (currently we only instrument | ||||
# a instance method so the probability for that is very low). | ||||
def enable(tracer_provider): | ||||
# pylint: disable=unused-argument | ||||
def _instrument(tracer_provider=None): | ||||
"""Enables tracing of all requests calls that go through | ||||
:code:`requests.session.Session.request` (this includes | ||||
:code:`requests.get`, etc.).""" | ||||
|
@@ -69,20 +67,17 @@ def enable(tracer_provider): | |||
# before v1.0.0, Dec 17, 2012, see | ||||
# https://github.com/psf/requests/commit/4e5c4a6ab7bb0195dececdd19bb8505b872fe120) | ||||
|
||||
# Guard against double instrumentation | ||||
disable() | ||||
|
||||
tracer = tracer_provider.get_tracer(__name__, __version__) | ||||
|
||||
wrapped = Session.request | ||||
|
||||
tracer = trace.get_tracer(__name__, __version__, tracer_provider) | ||||
|
||||
mauriciovasquezbernal marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
@functools.wraps(wrapped) | ||||
def instrumented_request(self, method, url, *args, **kwargs): | ||||
if context.get_value("suppress_instrumentation"): | ||||
return wrapped(self, method, url, *args, **kwargs) | ||||
|
||||
# See | ||||
# https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/data-semantic-conventions.md#http-client | ||||
# https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/trace/semantic_conventions/http.md#http-client | ||||
try: | ||||
parsed_url = urlparse(url) | ||||
except ValueError as exc: # Invalid URL | ||||
|
@@ -103,12 +98,12 @@ def instrumented_request(self, method, url, *args, **kwargs): | |||
|
||||
lzchen marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
span.set_attribute("http.status_code", result.status_code) | ||||
span.set_attribute("http.status_text", result.reason) | ||||
span.set_status( | ||||
Status(_http_status_to_canonical_code(result.status_code)) | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. did you want to pass an argument for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I took the code from opentelemetry-python/ext/opentelemetry-ext-wsgi/src/opentelemetry/ext/wsgi/__init__.py Line 90 in 7cb57c7
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looking at that code, it only applies to the 399 status code, which is unassigned. @Oberon00 any insight into the rationalle for the parameter, and the 399 status code? Looks like you were one of the authors of that commit. |
||||
) | ||||
|
||||
return result | ||||
|
||||
# TODO: How to handle exceptions? Should we create events for them? Set | ||||
lzchen marked this conversation as resolved.
Show resolved
Hide resolved
|
||||
# certain attributes? | ||||
|
||||
instrumented_request.opentelemetry_ext_requests_applied = True | ||||
|
||||
Session.request = instrumented_request | ||||
|
@@ -119,18 +114,59 @@ def instrumented_request(self, method, url, *args, **kwargs): | |||
# different, then push the current URL, pop it afterwards) | ||||
|
||||
|
||||
def disable(): | ||||
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 | ||||
|
||||
|
||||
def disable_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) | ||||
def _http_status_to_canonical_code(code: int, allow_redirect: bool = True): | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we follow up and move this somewhere up the packages? this is duplicated in 4-5 places now. I hesitate to suggest the api package, which is the only common dependency. Or maybe an opentelemetry-instrumentor-utils? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should move that to a new package in the future, I'd like to avoid doing that on this PR as I want this to be in asap. By the way, I opened an issue to initiate the discussion about that #610, I think an utils package for instrumentors is the best idea. |
||||
# pylint:disable=too-many-branches,too-many-return-statements | ||||
if code < 100: | ||||
return StatusCanonicalCode.UNKNOWN | ||||
if code <= 299: | ||||
return StatusCanonicalCode.OK | ||||
if code <= 399: | ||||
if allow_redirect: | ||||
return StatusCanonicalCode.OK | ||||
return StatusCanonicalCode.DEADLINE_EXCEEDED | ||||
if code <= 499: | ||||
if code == 401: # HTTPStatus.UNAUTHORIZED: | ||||
return StatusCanonicalCode.UNAUTHENTICATED | ||||
if code == 403: # HTTPStatus.FORBIDDEN: | ||||
return StatusCanonicalCode.PERMISSION_DENIED | ||||
if code == 404: # HTTPStatus.NOT_FOUND: | ||||
return StatusCanonicalCode.NOT_FOUND | ||||
if code == 429: # HTTPStatus.TOO_MANY_REQUESTS: | ||||
return StatusCanonicalCode.RESOURCE_EXHAUSTED | ||||
return StatusCanonicalCode.INVALID_ARGUMENT | ||||
if code <= 599: | ||||
if code == 501: # HTTPStatus.NOT_IMPLEMENTED: | ||||
return StatusCanonicalCode.UNIMPLEMENTED | ||||
if code == 503: # HTTPStatus.SERVICE_UNAVAILABLE: | ||||
return StatusCanonicalCode.UNAVAILABLE | ||||
if code == 504: # HTTPStatus.GATEWAY_TIMEOUT: | ||||
return StatusCanonicalCode.DEADLINE_EXCEEDED | ||||
return StatusCanonicalCode.INTERNAL | ||||
return StatusCanonicalCode.UNKNOWN | ||||
|
||||
|
||||
class RequestsInstrumentor(BaseInstrumentor): | ||||
def _instrument(self, **kwargs): | ||||
_instrument(tracer_provider=kwargs.get("tracer_provider")) | ||||
|
||||
def _uninstrument(self, **kwargs): | ||||
_uninstrument() | ||||
|
||||
@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) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,8 +18,9 @@ | |
import requests | ||
import urllib3 | ||
|
||
import opentelemetry.ext.http_requests | ||
from opentelemetry import context, propagators, trace | ||
from opentelemetry.ext import http_requests | ||
from opentelemetry.sdk import resources | ||
from opentelemetry.test.mock_httptextformat import MockHTTPTextFormat | ||
from opentelemetry.test.test_base import TestBase | ||
|
||
|
@@ -29,23 +30,25 @@ class TestRequestsIntegration(TestBase): | |
|
||
def setUp(self): | ||
super().setUp() | ||
opentelemetry.ext.http_requests.enable(self.tracer_provider) | ||
http_requests.RequestsInstrumentor().instrument() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should this be passing in the tracer_provider? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TestBase is doing |
||
httpretty.enable() | ||
httpretty.register_uri( | ||
httpretty.GET, self.URL, body="Hello!", | ||
) | ||
|
||
def tearDown(self): | ||
super().tearDown() | ||
opentelemetry.ext.http_requests.disable() | ||
http_requests.RequestsInstrumentor().uninstrument() | ||
httpretty.disable() | ||
|
||
def test_basic(self): | ||
result = requests.get(self.URL) | ||
self.assertEqual(result.text, "Hello!") | ||
|
||
span_list = self.memory_exporter.get_finished_spans() | ||
self.assertEqual(len(span_list), 1) | ||
span = span_list[0] | ||
|
||
self.assertIs(span.kind, trace.SpanKind.CLIENT) | ||
self.assertEqual(span.name, "/status/200") | ||
|
||
|
@@ -60,6 +63,32 @@ def test_basic(self): | |
}, | ||
) | ||
|
||
self.assertIs( | ||
span.status.canonical_code, trace.status.StatusCanonicalCode.OK | ||
) | ||
|
||
self.check_span_instrumentation_info(span, http_requests) | ||
|
||
def test_not_foundbasic(self): | ||
url_404 = "http://httpbin.org/status/404" | ||
httpretty.register_uri( | ||
httpretty.GET, url_404, status=404, | ||
) | ||
result = requests.get(url_404) | ||
self.assertEqual(result.status_code, 404) | ||
|
||
span_list = self.memory_exporter.get_finished_spans() | ||
self.assertEqual(len(span_list), 1) | ||
span = span_list[0] | ||
|
||
self.assertEqual(span.attributes.get("http.status_code"), 404) | ||
self.assertEqual(span.attributes.get("http.status_text"), "Not Found") | ||
|
||
self.assertIs( | ||
span.status.canonical_code, | ||
trace.status.StatusCanonicalCode.NOT_FOUND, | ||
) | ||
|
||
def test_invalid_url(self): | ||
url = "http://[::1/nope" | ||
exception_type = requests.exceptions.InvalidURL | ||
|
@@ -81,18 +110,18 @@ def test_invalid_url(self): | |
{"component": "http", "http.method": "POST", "http.url": url}, | ||
) | ||
|
||
def test_disable(self): | ||
opentelemetry.ext.http_requests.disable() | ||
def test_uninstrument(self): | ||
http_requests.RequestsInstrumentor().uninstrument() | ||
result = requests.get(self.URL) | ||
self.assertEqual(result.text, "Hello!") | ||
span_list = self.memory_exporter.get_finished_spans() | ||
self.assertEqual(len(span_list), 0) | ||
# instrument again to avoid annoying warning message | ||
http_requests.RequestsInstrumentor().instrument() | ||
mauriciovasquezbernal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
opentelemetry.ext.http_requests.disable() | ||
|
||
def test_disable_session(self): | ||
def test_uninstrument_session(self): | ||
session1 = requests.Session() | ||
opentelemetry.ext.http_requests.disable_session(session1) | ||
http_requests.RequestsInstrumentor().uninstrument_session(session1) | ||
|
||
result = session1.get(self.URL) | ||
self.assertEqual(result.text, "Hello!") | ||
|
@@ -152,3 +181,21 @@ def test_distributed_context(self): | |
|
||
finally: | ||
propagators.set_global_httptextformat(previous_propagator) | ||
|
||
def test_custom_tracer_provider(self): | ||
resource = resources.Resource.create({}) | ||
result = self.create_tracer_provider(resource=resource) | ||
tracer_provider, exporter = result | ||
http_requests.RequestsInstrumentor().uninstrument() | ||
http_requests.RequestsInstrumentor().instrument( | ||
tracer_provider=tracer_provider | ||
) | ||
|
||
result = requests.get(self.URL) | ||
self.assertEqual(result.text, "Hello!") | ||
|
||
span_list = exporter.get_finished_spans() | ||
self.assertEqual(len(span_list), 1) | ||
span = span_list[0] | ||
|
||
self.assertIs(span.resource, resource) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will this change once the instrumentor interface supports configuration?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this particular example all the calls to
instrument()
omit thetracer_provider
parameter, it means the current configured is used. If there is not any configured, andOPENTELEMETRY_PYTHON_TRACER_PROVIDER
is not set, a default no-op will be used.I think the common approach we should suggest in the examples is.