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

Restoring metrics in requests #1110

Merged
merged 23 commits into from
Jun 25, 2022
Merged
Show file tree
Hide file tree
Changes from all 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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#1127](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1127))
- Add metric instrumentation for WSGI
([#1128](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1128))
- `opentelemetry-instrumentation-requests` Restoring metrics in requests
([#1110](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1110)


## [1.12.0rc1-0.31b0](https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.12.0rc1-0.31b0) - 2022-05-17
Expand Down
2 changes: 1 addition & 1 deletion instrumentation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
| [opentelemetry-instrumentation-pyramid](./opentelemetry-instrumentation-pyramid) | pyramid >= 1.7 | No
| [opentelemetry-instrumentation-redis](./opentelemetry-instrumentation-redis) | redis >= 2.6 | No
| [opentelemetry-instrumentation-remoulade](./opentelemetry-instrumentation-remoulade) | remoulade >= 0.50 | No
| [opentelemetry-instrumentation-requests](./opentelemetry-instrumentation-requests) | requests ~= 2.0 | No
| [opentelemetry-instrumentation-requests](./opentelemetry-instrumentation-requests) | requests ~= 2.0 | Yes
| [opentelemetry-instrumentation-sklearn](./opentelemetry-instrumentation-sklearn) | scikit-learn ~= 0.24.0 | No
| [opentelemetry-instrumentation-sqlalchemy](./opentelemetry-instrumentation-sqlalchemy) | sqlalchemy | No
| [opentelemetry-instrumentation-sqlite3](./opentelemetry-instrumentation-sqlite3) | sqlite3 | No
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@

import functools
import types
from typing import Collection
from timeit import default_timer
from typing import Callable, Collection, Iterable, Optional
from urllib.parse import urlparse

from requests.models import Response
from requests.sessions import Session
Expand All @@ -67,9 +69,11 @@
_SUPPRESS_INSTRUMENTATION_KEY,
http_status_to_status_code,
)
ashu658 marked this conversation as resolved.
Show resolved Hide resolved
from opentelemetry.metrics import Histogram, get_meter
from opentelemetry.propagate import inject
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace import SpanKind, get_tracer
from opentelemetry.trace import SpanKind, Tracer, get_tracer
from opentelemetry.trace.span import Span
from opentelemetry.trace.status import Status
from opentelemetry.util.http import (
get_excluded_urls,
Expand All @@ -84,7 +88,11 @@
# pylint: disable=unused-argument
# pylint: disable=R0915
def _instrument(
tracer, span_callback=None, name_callback=None, excluded_urls=None
tracer: Tracer,
duration_histogram: Histogram,
span_callback: Optional[Callable[[Span, Response], str]] = None,
name_callback: Optional[Callable[[str, str], str]] = None,
excluded_urls: Iterable[str] = None,
):
"""Enables tracing of all requests calls that go through
:code:`requests.session.Session.request` (this includes
Expand Down Expand Up @@ -140,6 +148,7 @@ def call_wrapped():
request.method, request.url, call_wrapped, get_or_create_headers
)

# pylint: disable-msg=too-many-locals,too-many-branches
def _instrumented_requests_call(
method: str, url: str, call_wrapped, get_or_create_headers
):
Expand All @@ -164,6 +173,23 @@ def _instrumented_requests_call(
SpanAttributes.HTTP_URL: url,
}

metric_labels = {
SpanAttributes.HTTP_METHOD: method,
}

try:
parsed_url = urlparse(url)
Copy link
Contributor

Choose a reason for hiding this comment

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

I imagine that the call to urlparse is what can cause the ValueError exception to be raised. If that happens, does it make sense to continue the instrumentation with a metrics_labels dictionary that will not have "http.host" and "http.scheme" keys?

Copy link
Member Author

@ashu658 ashu658 Jun 14, 2022

Choose a reason for hiding this comment

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

In traces we add the url even if the url is invalid (e.g. url = random123 is also added as span attribute). So, I added it here for consistency.
I think its okay to go without http.host and http.scheme keys as http.url attribute alone is one of the recommended set of attributes for client metrics (https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/semantic_conventions/http-metrics.md#attribute-alternatives)

metric_labels[SpanAttributes.HTTP_SCHEME] = parsed_url.scheme
if parsed_url.hostname:
metric_labels[SpanAttributes.HTTP_HOST] = parsed_url.hostname
metric_labels[
SpanAttributes.NET_PEER_NAME
] = parsed_url.hostname
if parsed_url.port:
metric_labels[SpanAttributes.NET_PEER_PORT] = parsed_url.port
except ValueError:
pass

with tracer.start_as_current_span(
span_name, kind=SpanKind.CLIENT, attributes=span_attributes
) as span, set_ip_on_next_http_connection(span):
Expand All @@ -175,12 +201,18 @@ def _instrumented_requests_call(
token = context.attach(
context.set_value(_SUPPRESS_HTTP_INSTRUMENTATION_KEY, True)
)

start_time = default_timer()

try:
result = call_wrapped() # *** PROCEED
except Exception as exc: # pylint: disable=W0703
exception = exc
result = getattr(exc, "response", None)
finally:
elapsed_time = max(
round((default_timer() - start_time) * 1000), 0
)
context.detach(token)

if isinstance(result, Response):
Expand All @@ -191,9 +223,23 @@ def _instrumented_requests_call(
span.set_status(
Status(http_status_to_status_code(result.status_code))
)

metric_labels[
SpanAttributes.HTTP_STATUS_CODE
] = result.status_code

if result.raw is not None:
version = getattr(result.raw, "version", None)
if version:
metric_labels[SpanAttributes.HTTP_FLAVOR] = (
"1.1" if version == 11 else "1.0"
)

if span_callback is not None:
span_callback(span, result)

duration_histogram.record(elapsed_time, attributes=metric_labels)

if exception is not None:
raise exception.with_traceback(exception.__traceback__)

Expand Down Expand Up @@ -258,8 +304,20 @@ def _instrument(self, **kwargs):
tracer_provider = kwargs.get("tracer_provider")
tracer = get_tracer(__name__, __version__, tracer_provider)
excluded_urls = kwargs.get("excluded_urls")
meter_provider = kwargs.get("meter_provider")
meter = get_meter(
__name__,
__version__,
meter_provider,
)
duration_histogram = meter.create_histogram(
name="http.client.duration",
unit="ms",
description="measures the duration of the outbound HTTP request",
)
_instrument(
tracer,
duration_histogram,
span_callback=kwargs.get("span_callback"),
name_callback=kwargs.get("name_callback"),
excluded_urls=_excluded_urls_from_env
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@


_instruments = ("requests ~= 2.0",)

_supports_metrics = True
Original file line number Diff line number Diff line change
Expand Up @@ -487,3 +487,47 @@ def perform_request(url: str, session: requests.Session = None):
request = requests.Request("GET", url)
prepared_request = session.prepare_request(request)
return session.send(prepared_request)


class TestRequestsIntergrationMetric(TestBase):
URL = "http://examplehost:8000/status/200"

def setUp(self):
super().setUp()
RequestsInstrumentor().instrument(meter_provider=self.meter_provider)

httpretty.enable()
httpretty.register_uri(httpretty.GET, self.URL, body="Hello!")

def tearDown(self):
super().tearDown()
RequestsInstrumentor().uninstrument()
httpretty.disable()

@staticmethod
def perform_request(url: str) -> requests.Response:
return requests.get(url)

def test_basic_metric_success(self):
self.perform_request(self.URL)

expected_attributes = {
"http.status_code": 200,
"http.host": "examplehost",
"net.peer.port": 8000,
"net.peer.name": "examplehost",
"http.method": "GET",
"http.flavor": "1.1",
"http.scheme": "http",
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we missing a few? net.*, http.target?

Copy link
Member Author

Choose a reason for hiding this comment

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

Added net.* attributes.
I have not added http.target as the target is not parameterised and adding raw target can lead to high cardinality.
Its similar for http.url as discussed here

}

for (
resource_metrics
) in self.memory_metrics_reader.get_metrics_data().resource_metrics:
for scope_metrics in resource_metrics.scope_metrics:
for metric in scope_metrics.metrics:
for data_point in metric.data.data_points:
self.assertDictEqual(
expected_attributes, dict(data_point.attributes)
)
self.assertEqual(data_point.count, 1)