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

ext/requests: Add instrumentor #597

Merged
merged 11 commits into from
Apr 27, 2020
Merged
9 changes: 6 additions & 3 deletions docs/examples/http/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,18 @@

# The preferred tracer implementation must be set, as the opentelemetry-api
# defines the interface with a no-op implementation.
# It must be done before instrumenting any library.
Copy link
Contributor

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?

Copy link
Member Author

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 the tracer_provider parameter, it means the current configured is used. If there is not any configured, and OPENTELEMETRY_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.

  1. trace.set_tracer_provider(TracerProvider()) (We don't want to mess up with env variables in the examples)
  2. instrument the libraries
  3. configure the span processor and exporters
  4. use of the instrumented framework.

trace.set_tracer_provider(TracerProvider())
tracer_provider = trace.get_tracer_provider()

# Enable instrumentation in the requests library.
http_requests.RequestsInstrumentor().instrument()

# Configure a console span exporter.
exporter = ConsoleSpanExporter()
span_processor = BatchExportSpanProcessor(exporter)
tracer_provider.add_span_processor(span_processor)
trace.get_tracer_provider().add_span_processor(span_processor)

# Integrations are the glue that binds the OpenTelemetry API and the
# frameworks and libraries that are used together, automatically creating
# Spans and propagating context as appropriate.
http_requests.enable(tracer_provider)
response = requests.get(url="http://127.0.0.1:5000/")
15 changes: 9 additions & 6 deletions docs/examples/http/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,23 @@

# The preferred tracer implementation must be set, as the opentelemetry-api
# defines the interface with a no-op implementation.
# It must be done before instrumenting any library.
trace.set_tracer_provider(TracerProvider())
tracer = trace.get_tracer(__name__)

exporter = ConsoleSpanExporter()
span_processor = BatchExportSpanProcessor(exporter)
trace.get_tracer_provider().add_span_processor(span_processor)

# Integrations are the glue that binds the OpenTelemetry API and the
# frameworks and libraries that are used together, automatically creating
# Spans and propagating context as appropriate.
http_requests.enable(trace.get_tracer_provider())
http_requests.RequestsInstrumentor().instrument()
app = flask.Flask(__name__)
app.wsgi_app = OpenTelemetryMiddleware(app.wsgi_app)

# Configure a console span exporter.
exporter = ConsoleSpanExporter()
span_processor = BatchExportSpanProcessor(exporter)
trace.get_tracer_provider().add_span_processor(span_processor)

tracer = trace.get_tracer(__name__)


@app.route("/")
def hello():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,20 @@
SimpleExportSpanProcessor,
)

# The preferred tracer implementation must be set, as the opentelemetry-api
# defines the interface with a no-op implementation.
# It must be done before instrumenting any library
trace.set_tracer_provider(TracerProvider())
mauriciovasquezbernal marked this conversation as resolved.
Show resolved Hide resolved

opentelemetry.ext.http_requests.RequestsInstrumentor().instrument()
FlaskInstrumentor().instrument()

trace.get_tracer_provider().add_span_processor(
SimpleExportSpanProcessor(ConsoleSpanExporter())
)

FlaskInstrumentor().instrument()

app = flask.Flask(__name__)
opentelemetry.ext.http_requests.enable(trace.get_tracer_provider())


@app.route("/")
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ And let's write a small Flask application that sends an HTTP request, activating
)

app = flask.Flask(__name__)
opentelemetry.ext.http_requests.enable(trace.get_tracer_provider())
opentelemetry.ext.http_requests.RequestsInstrumentor().instrument()

@app.route("/")
def hello():
Expand Down
2 changes: 2 additions & 0 deletions ext/opentelemetry-ext-http-requests/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Implement instrumentor interface ([#597](https://github.com/open-telemetry/opentelemetry-python/pull/597))

## 0.3a0

Released 2019-10-29
Expand Down
5 changes: 5 additions & 0 deletions ext/opentelemetry-ext-http-requests/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ package_dir=
packages=find_namespace:
install_requires =
opentelemetry-api == 0.7.dev0
opentelemetry-auto-instrumentation == 0.7.dev0
requests ~= 2.0

[options.extras_require]
Expand All @@ -50,3 +51,7 @@ test =

[options.packages.find]
where = src

[options.entry_points]
opentelemetry_instrumentor =
requests = opentelemetry.ext.http_requests:RequestsInstrumentor
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand All @@ -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()
Copy link
Contributor

Choose a reason for hiding this comment

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

should this example show how to pass a TracerProvider?

Copy link
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

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

how about the example with the kwarg as none: instrument(tracer_prover=None). If I saw that comment, I would have no idea what the argument name is for passing my own TracerProvider.

Copy link
Member Author

Choose a reason for hiding this comment

The 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
-----------
Expand All @@ -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.)."""
Expand All @@ -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
Expand All @@ -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))
Copy link
Contributor

Choose a reason for hiding this comment

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

did you want to pass an argument for allow_redirect here?

Copy link
Member Author

Choose a reason for hiding this comment

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

I took the code from

def http_status_to_canonical_code(code: int, allow_redirect: bool = True):
, I'm not sure if I should set the flag here.

Copy link
Member

Choose a reason for hiding this comment

The 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
Expand All @@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

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

nice!

Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Member Author

Choose a reason for hiding this comment

The 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
Expand Up @@ -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

Expand All @@ -29,23 +30,25 @@ class TestRequestsIntegration(TestBase):

def setUp(self):
super().setUp()
opentelemetry.ext.http_requests.enable(self.tracer_provider)
http_requests.RequestsInstrumentor().instrument()
Copy link
Contributor

Choose a reason for hiding this comment

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

should this be passing in the tracer_provider?

Copy link
Member Author

Choose a reason for hiding this comment

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

TestBase is doing trace.set_tracer_provider(cls.tracer_provider), so it's the same.

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")

Expand All @@ -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
Expand All @@ -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!")
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion tests/w3c_tracecontext_validation_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
# frameworks and libraries that are used together, automatically creating
# Spans and propagating context as appropriate.
trace.set_tracer_provider(TracerProvider())
http_requests.enable(trace.get_tracer_provider())
http_requests.RequestsInstrumentor().instrument()

# SpanExporter receives the spans and send them to the target location.
span_processor = SimpleExportSpanProcessor(ConsoleSpanExporter())
Expand Down
Loading