Skip to content

Commit be96403

Browse files
authored
Feature/metrics instrumentation urllib3 (#1198)
1 parent cbf005b commit be96403

File tree

5 files changed

+213
-3
lines changed

5 files changed

+213
-3
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5959
([#1127](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1127))
6060
- Add metric instrumentation for WSGI
6161
([#1128](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1128))
62+
- Add metric instrumentation for Urllib3
63+
([#1198](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1198))
6264
- `opentelemetry-instrumentation-aio-pika` added RabbitMQ aio-pika module instrumentation.
6365
([#1095](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1095))
6466
- `opentelemetry-instrumentation-requests` Restoring metrics in requests

instrumentation/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,5 @@
4040
| [opentelemetry-instrumentation-system-metrics](./opentelemetry-instrumentation-system-metrics) | psutil >= 5 | No
4141
| [opentelemetry-instrumentation-tornado](./opentelemetry-instrumentation-tornado) | tornado >= 5.1.1 | No
4242
| [opentelemetry-instrumentation-urllib](./opentelemetry-instrumentation-urllib) | urllib | No
43-
| [opentelemetry-instrumentation-urllib3](./opentelemetry-instrumentation-urllib3) | urllib3 >= 1.0.0, < 2.0.0 | No
43+
| [opentelemetry-instrumentation-urllib3](./opentelemetry-instrumentation-urllib3) | urllib3 >= 1.0.0, < 2.0.0 | Yes
4444
| [opentelemetry-instrumentation-wsgi](./opentelemetry-instrumentation-wsgi) | wsgi | Yes

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

+72-2
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def response_hook(span, request, response):
6666

6767
import contextlib
6868
import typing
69+
from timeit import default_timer
6970
from typing import Collection
7071

7172
import urllib3.connectionpool
@@ -83,9 +84,10 @@ def response_hook(span, request, response):
8384
http_status_to_status_code,
8485
unwrap,
8586
)
87+
from opentelemetry.metrics import Histogram, get_meter
8688
from opentelemetry.propagate import inject
8789
from opentelemetry.semconv.trace import SpanAttributes
88-
from opentelemetry.trace import Span, SpanKind, get_tracer
90+
from opentelemetry.trace import Span, SpanKind, Tracer, get_tracer
8991
from opentelemetry.trace.status import Status
9092
from opentelemetry.util.http.httplib import set_ip_on_next_http_connection
9193

@@ -135,8 +137,31 @@ def _instrument(self, **kwargs):
135137
"""
136138
tracer_provider = kwargs.get("tracer_provider")
137139
tracer = get_tracer(__name__, __version__, tracer_provider)
140+
141+
meter_provider = kwargs.get("meter_provider")
142+
meter = get_meter(__name__, __version__, meter_provider)
143+
144+
duration_histogram = meter.create_histogram(
145+
name="http.client.duration",
146+
unit="ms",
147+
description="measures the duration outbound HTTP requests",
148+
)
149+
request_size_histogram = meter.create_histogram(
150+
name="http.client.request.size",
151+
unit="By",
152+
description="measures the size of HTTP request messages (compressed)",
153+
)
154+
response_size_histogram = meter.create_histogram(
155+
name="http.client.response.size",
156+
unit="By",
157+
description="measures the size of HTTP response messages (compressed)",
158+
)
159+
138160
_instrument(
139161
tracer,
162+
duration_histogram,
163+
request_size_histogram,
164+
response_size_histogram,
140165
request_hook=kwargs.get("request_hook"),
141166
response_hook=kwargs.get("response_hook"),
142167
url_filter=kwargs.get("url_filter"),
@@ -147,7 +172,10 @@ def _uninstrument(self, **kwargs):
147172

148173

149174
def _instrument(
150-
tracer,
175+
tracer: Tracer,
176+
duration_histogram: Histogram,
177+
request_size_histogram: Histogram,
178+
response_size_histogram: Histogram,
151179
request_hook: _RequestHookT = None,
152180
response_hook: _ResponseHookT = None,
153181
url_filter: _UrlFilterT = None,
@@ -175,11 +203,30 @@ def instrumented_urlopen(wrapped, instance, args, kwargs):
175203
inject(headers)
176204

177205
with _suppress_further_instrumentation():
206+
start_time = default_timer()
178207
response = wrapped(*args, **kwargs)
208+
elapsed_time = round((default_timer() - start_time) * 1000)
179209

180210
_apply_response(span, response)
181211
if callable(response_hook):
182212
response_hook(span, instance, response)
213+
214+
request_size = 0 if body is None else len(body)
215+
response_size = int(response.headers.get("Content-Length", 0))
216+
metric_attributes = _create_metric_attributes(
217+
instance, response, method
218+
)
219+
220+
duration_histogram.record(
221+
elapsed_time, attributes=metric_attributes
222+
)
223+
request_size_histogram.record(
224+
request_size, attributes=metric_attributes
225+
)
226+
response_size_histogram.record(
227+
response_size, attributes=metric_attributes
228+
)
229+
183230
return response
184231

185232
wrapt.wrap_function_wrapper(
@@ -254,6 +301,29 @@ def _is_instrumentation_suppressed() -> bool:
254301
)
255302

256303

304+
def _create_metric_attributes(
305+
instance: urllib3.connectionpool.HTTPConnectionPool,
306+
response: urllib3.response.HTTPResponse,
307+
method: str,
308+
) -> dict:
309+
metric_attributes = {
310+
SpanAttributes.HTTP_METHOD: method,
311+
SpanAttributes.HTTP_HOST: instance.host,
312+
SpanAttributes.HTTP_SCHEME: instance.scheme,
313+
SpanAttributes.HTTP_STATUS_CODE: response.status,
314+
SpanAttributes.NET_PEER_NAME: instance.host,
315+
SpanAttributes.NET_PEER_PORT: instance.port,
316+
}
317+
318+
version = getattr(response, "version")
319+
if version:
320+
metric_attributes[SpanAttributes.HTTP_FLAVOR] = (
321+
"1.1" if version == 11 else "1.0"
322+
)
323+
324+
return metric_attributes
325+
326+
257327
@contextlib.contextmanager
258328
def _suppress_further_instrumentation():
259329
token = context.attach(

instrumentation/opentelemetry-instrumentation-urllib3/src/opentelemetry/instrumentation/urllib3/package.py

+2
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@
1414

1515

1616
_instruments = ("urllib3 >= 1.0.0, < 2.0.0",)
17+
18+
_supports_metrics = True

instrumentation/opentelemetry-instrumentation-urllib3/tests/test_urllib3_ip_support.py

+136
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from timeit import default_timer
16+
1517
import urllib3
1618
import urllib3.exceptions
19+
from urllib3.request import encode_multipart_formdata
1720

1821
from opentelemetry import trace
1922
from opentelemetry.instrumentation.urllib3 import URLLib3Instrumentor
@@ -84,3 +87,136 @@ def assert_success_span(
8487
"net.peer.ip": self.assert_ip,
8588
}
8689
self.assertGreaterEqual(span.attributes.items(), attributes.items())
90+
91+
92+
class TestURLLib3InstrumentorMetric(HttpTestBase, TestBase):
93+
def setUp(self):
94+
super().setUp()
95+
self.assert_ip = self.server.server_address[0]
96+
self.assert_port = self.server.server_address[1]
97+
self.http_host = ":".join(map(str, self.server.server_address[:2]))
98+
self.http_url_base = "http://" + self.http_host
99+
self.http_url = self.http_url_base + "/status/200"
100+
URLLib3Instrumentor().instrument(meter_provider=self.meter_provider)
101+
102+
def tearDown(self):
103+
super().tearDown()
104+
URLLib3Instrumentor().uninstrument()
105+
106+
def test_metric_uninstrument(self):
107+
with urllib3.PoolManager() as pool:
108+
pool.request("GET", self.http_url)
109+
URLLib3Instrumentor().uninstrument()
110+
pool.request("GET", self.http_url)
111+
112+
metrics_list = self.memory_metrics_reader.get_metrics_data()
113+
for resource_metric in metrics_list.resource_metrics:
114+
for scope_metric in resource_metric.scope_metrics:
115+
for metric in scope_metric.metrics:
116+
for point in list(metric.data.data_points):
117+
self.assertEqual(point.count, 1)
118+
119+
def test_basic_metric_check_client_size_get(self):
120+
with urllib3.PoolManager() as pool:
121+
start_time = default_timer()
122+
response = pool.request("GET", self.http_url)
123+
client_duration_estimated = (default_timer() - start_time) * 1000
124+
125+
expected_attributes = {
126+
"http.status_code": 200,
127+
"http.host": self.assert_ip,
128+
"http.method": "GET",
129+
"http.flavor": "1.1",
130+
"http.scheme": "http",
131+
"net.peer.name": self.assert_ip,
132+
"net.peer.port": self.assert_port,
133+
}
134+
expected_data = {
135+
"http.client.request.size": 0,
136+
"http.client.response.size": len(response.data),
137+
}
138+
expected_metrics = [
139+
"http.client.duration",
140+
"http.client.request.size",
141+
"http.client.response.size",
142+
]
143+
144+
resource_metrics = (
145+
self.memory_metrics_reader.get_metrics_data().resource_metrics
146+
)
147+
for metrics in resource_metrics:
148+
for scope_metrics in metrics.scope_metrics:
149+
self.assertEqual(len(scope_metrics.metrics), 3)
150+
for metric in scope_metrics.metrics:
151+
for data_point in metric.data.data_points:
152+
if metric.name in expected_data:
153+
self.assertEqual(
154+
data_point.sum, expected_data[metric.name]
155+
)
156+
if metric.name == "http.client.duration":
157+
self.assertAlmostEqual(
158+
data_point.sum,
159+
client_duration_estimated,
160+
delta=1000,
161+
)
162+
self.assertIn(metric.name, expected_metrics)
163+
self.assertDictEqual(
164+
expected_attributes,
165+
dict(data_point.attributes),
166+
)
167+
self.assertEqual(data_point.count, 1)
168+
169+
def test_basic_metric_check_client_size_post(self):
170+
with urllib3.PoolManager() as pool:
171+
start_time = default_timer()
172+
data_fields = {"data": "test"}
173+
response = pool.request("POST", self.http_url, fields=data_fields)
174+
client_duration_estimated = (default_timer() - start_time) * 1000
175+
176+
expected_attributes = {
177+
"http.status_code": 501,
178+
"http.host": self.assert_ip,
179+
"http.method": "POST",
180+
"http.flavor": "1.1",
181+
"http.scheme": "http",
182+
"net.peer.name": self.assert_ip,
183+
"net.peer.port": self.assert_port,
184+
}
185+
186+
body = encode_multipart_formdata(data_fields)[0]
187+
188+
expected_data = {
189+
"http.client.request.size": len(body),
190+
"http.client.response.size": len(response.data),
191+
}
192+
expected_metrics = [
193+
"http.client.duration",
194+
"http.client.request.size",
195+
"http.client.response.size",
196+
]
197+
198+
resource_metrics = (
199+
self.memory_metrics_reader.get_metrics_data().resource_metrics
200+
)
201+
for metrics in resource_metrics:
202+
for scope_metrics in metrics.scope_metrics:
203+
self.assertEqual(len(scope_metrics.metrics), 3)
204+
for metric in scope_metrics.metrics:
205+
for data_point in metric.data.data_points:
206+
if metric.name in expected_data:
207+
self.assertEqual(
208+
data_point.sum, expected_data[metric.name]
209+
)
210+
if metric.name == "http.client.duration":
211+
self.assertAlmostEqual(
212+
data_point.sum,
213+
client_duration_estimated,
214+
delta=1000,
215+
)
216+
self.assertIn(metric.name, expected_metrics)
217+
218+
self.assertDictEqual(
219+
expected_attributes,
220+
dict(data_point.attributes),
221+
)
222+
self.assertEqual(data_point.count, 1)

0 commit comments

Comments
 (0)