Skip to content

Commit 89df093

Browse files
author
sroda
committed
Add metric instrumentation for urllib
1 parent 41438ba commit 89df093

File tree

4 files changed

+312
-2
lines changed

4 files changed

+312
-2
lines changed

.github/component_owners.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,8 @@ components:
4141
instrumentation/opentelemetry-instrumentation-tornado:
4242
- shalevr
4343

44+
instrumentation/opentelemetry-instrumentation-urllib:
45+
- shalevr
46+
4447
instrumentation/opentelemetry-instrumentation-urllib3:
4548
- shalevr

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

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,9 @@ def response_hook(span, request_obj, response)
6464
import types
6565
import typing
6666

67-
# from urllib import response
6867
from http import client
69-
from typing import Collection
68+
from timeit import default_timer
69+
from typing import Collection, Dict
7070
from urllib.request import ( # pylint: disable=no-name-in-module,import-error
7171
OpenerDirector,
7272
Request,
@@ -83,6 +83,8 @@ def response_hook(span, request_obj, response)
8383
_SUPPRESS_INSTRUMENTATION_KEY,
8484
http_status_to_status_code,
8585
)
86+
from opentelemetry.metrics import Histogram, get_meter
87+
from opentelemetry.semconv.metrics import MetricInstruments
8688
from opentelemetry.propagate import inject
8789
from opentelemetry.semconv.trace import SpanAttributes
8890
from opentelemetry.trace import Span, SpanKind, get_tracer
@@ -114,8 +116,15 @@ def _instrument(self, **kwargs):
114116
"""
115117
tracer_provider = kwargs.get("tracer_provider")
116118
tracer = get_tracer(__name__, __version__, tracer_provider)
119+
120+
meter_provider = kwargs.get("meter_provider")
121+
meter = get_meter(__name__, __version__, meter_provider)
122+
123+
histograms = _create_client_histograms(meter)
124+
117125
_instrument(
118126
tracer,
127+
histograms,
119128
request_hook=kwargs.get("request_hook"),
120129
response_hook=kwargs.get("response_hook"),
121130
)
@@ -132,6 +141,7 @@ def uninstrument_opener(
132141

133142
def _instrument(
134143
tracer,
144+
histograms: Dict[str, Histogram],
135145
request_hook: _RequestHookT = None,
136146
response_hook: _ResponseHookT = None,
137147
):
@@ -192,11 +202,13 @@ def _instrumented_open_call(
192202
context.set_value(_SUPPRESS_HTTP_INSTRUMENTATION_KEY, True)
193203
)
194204
try:
205+
start_time = default_timer()
195206
result = call_wrapped() # *** PROCEED
196207
except Exception as exc: # pylint: disable=W0703
197208
exception = exc
198209
result = getattr(exc, "file", None)
199210
finally:
211+
elapsed_time = round((default_timer() - start_time) * 1000)
200212
context.detach(token)
201213

202214
if result is not None:
@@ -214,6 +226,10 @@ def _instrumented_open_call(
214226
SpanAttributes.HTTP_FLAVOR
215227
] = f"{ver_[:1]}.{ver_[:-1]}"
216228

229+
_record_histograms(
230+
histograms, labels, request, result, elapsed_time
231+
)
232+
217233
if callable(response_hook):
218234
response_hook(span, request, result)
219235

@@ -248,3 +264,44 @@ def _uninstrument_from(instr_root, restore_as_bound_func=False):
248264
if restore_as_bound_func:
249265
original = types.MethodType(original, instr_root)
250266
setattr(instr_root, instr_func_name, original)
267+
268+
269+
def _create_client_histograms(meter) -> Dict[str, Histogram]:
270+
histograms = {
271+
MetricInstruments.HTTP_CLIENT_DURATION: meter.create_histogram(
272+
name=MetricInstruments.HTTP_CLIENT_DURATION,
273+
unit="ms",
274+
description="measures the duration outbound HTTP requests",
275+
),
276+
MetricInstruments.HTTP_CLIENT_REQUEST_SIZE: meter.create_histogram(
277+
name=MetricInstruments.HTTP_CLIENT_REQUEST_SIZE,
278+
unit="By",
279+
description="measures the size of HTTP request messages (compressed)",
280+
),
281+
MetricInstruments.HTTP_CLIENT_RESPONSE_SIZE: meter.create_histogram(
282+
name=MetricInstruments.HTTP_CLIENT_RESPONSE_SIZE,
283+
unit="By",
284+
description="measures the size of HTTP response messages (compressed)",
285+
),
286+
}
287+
288+
return histograms
289+
290+
291+
def _record_histograms(
292+
histograms, metric_attributes, request, response, elapsed_time
293+
):
294+
histograms[MetricInstruments.HTTP_CLIENT_DURATION].record(
295+
elapsed_time, attributes=metric_attributes
296+
)
297+
298+
data = getattr(request, "data", None)
299+
request_size = 0 if data is None else len(data)
300+
histograms[MetricInstruments.HTTP_CLIENT_REQUEST_SIZE].record(
301+
request_size, attributes=metric_attributes
302+
)
303+
304+
response_size = int(response.headers.get("Content-Length", 0))
305+
histograms[MetricInstruments.HTTP_CLIENT_RESPONSE_SIZE].record(
306+
response_size, attributes=metric_attributes
307+
)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@
1414

1515

1616
_instruments = tuple()
17+
18+
_supports_metrics = True
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
import urllib
17+
from urllib import request
18+
from urllib.parse import urlencode
19+
from timeit import default_timer
20+
from typing import Optional, Union
21+
22+
import httpretty
23+
from opentelemetry.semconv.metrics import MetricInstruments
24+
25+
from opentelemetry.instrumentation.urllib import ( # pylint: disable=no-name-in-module,import-error
26+
URLLibInstrumentor,
27+
)
28+
from opentelemetry.sdk.metrics._internal.point import Metric
29+
from opentelemetry.sdk.metrics.export import (
30+
HistogramDataPoint,
31+
NumberDataPoint,
32+
)
33+
from opentelemetry.test.test_base import TestBase
34+
35+
36+
class TestRequestsIntegration(TestBase):
37+
URL = "http://httpbin.org/status/200"
38+
URL_POST = "http://httpbin.org/post"
39+
40+
def setUp(self):
41+
super().setUp()
42+
URLLibInstrumentor().instrument()
43+
httpretty.enable()
44+
httpretty.register_uri(httpretty.GET, self.URL, body=b"Hello!")
45+
httpretty.register_uri(
46+
httpretty.POST, self.URL_POST, body=b"Hello World!"
47+
)
48+
49+
def tearDown(self):
50+
super().tearDown()
51+
URLLibInstrumentor().uninstrument()
52+
httpretty.disable()
53+
54+
def get_sorted_metrics(self):
55+
resource_metrics = (
56+
self.memory_metrics_reader.get_metrics_data().resource_metrics
57+
)
58+
59+
all_metrics = []
60+
for metrics in resource_metrics:
61+
for scope_metrics in metrics.scope_metrics:
62+
all_metrics.extend(scope_metrics.metrics)
63+
64+
return self.sorted_metrics(all_metrics)
65+
66+
@staticmethod
67+
def sorted_metrics(metrics):
68+
"""
69+
Sorts metrics by metric name.
70+
"""
71+
return sorted(
72+
metrics,
73+
key=lambda m: m.name,
74+
)
75+
76+
def assert_metric_expected(
77+
self,
78+
metric: Metric,
79+
expected_value: Union[int, float],
80+
expected_attributes: dict,
81+
est_delta: Optional[float] = None,
82+
):
83+
data_point = next(iter(metric.data.data_points))
84+
85+
if isinstance(data_point, HistogramDataPoint):
86+
self.assertEqual(
87+
data_point.count,
88+
1,
89+
)
90+
if est_delta is None:
91+
self.assertEqual(
92+
data_point.sum,
93+
expected_value,
94+
)
95+
else:
96+
self.assertAlmostEqual(
97+
data_point.sum,
98+
expected_value,
99+
delta=est_delta,
100+
)
101+
elif isinstance(data_point, NumberDataPoint):
102+
self.assertEqual(
103+
data_point.value,
104+
expected_value,
105+
)
106+
107+
self.assertDictEqual(
108+
expected_attributes,
109+
dict(data_point.attributes),
110+
)
111+
112+
def test_basic_metric(self):
113+
start_time = default_timer()
114+
result = urllib.request.urlopen(self.URL)
115+
client_duration_estimated = (default_timer() - start_time) * 1000
116+
117+
metrics = self.get_sorted_metrics()
118+
self.assertEqual(len(metrics), 3)
119+
120+
(
121+
client_duration,
122+
client_request_size,
123+
client_response_size,
124+
) = metrics[:3]
125+
126+
self.assertEqual(
127+
client_duration.name, MetricInstruments.HTTP_CLIENT_DURATION
128+
)
129+
self.assert_metric_expected(
130+
client_duration,
131+
client_duration_estimated,
132+
{
133+
"http.status_code": str(result.code),
134+
"http.method": "GET",
135+
"http.url": str(result.url),
136+
"http.flavor": "1.1",
137+
},
138+
est_delta=200,
139+
)
140+
141+
# net.peer.name
142+
143+
self.assertEqual(
144+
client_request_size.name,
145+
MetricInstruments.HTTP_CLIENT_REQUEST_SIZE,
146+
)
147+
self.assert_metric_expected(
148+
client_request_size,
149+
0,
150+
{
151+
"http.status_code": str(result.code),
152+
"http.method": "GET",
153+
"http.url": str(result.url),
154+
"http.flavor": "1.1",
155+
},
156+
)
157+
158+
self.assertEqual(
159+
client_response_size.name,
160+
MetricInstruments.HTTP_CLIENT_RESPONSE_SIZE,
161+
)
162+
self.assert_metric_expected(
163+
client_response_size,
164+
result.length,
165+
{
166+
"http.status_code": str(result.code),
167+
"http.method": "GET",
168+
"http.url": str(result.url),
169+
"http.flavor": "1.1",
170+
},
171+
)
172+
173+
def test_basic_metric_request_not_empty(self):
174+
data = {"header1": "value1", "header2": "value2"}
175+
data_encoded = urllib.parse.urlencode(data).encode()
176+
177+
start_time = default_timer()
178+
result = urllib.request.urlopen(self.URL_POST, data=data_encoded)
179+
client_duration_estimated = (default_timer() - start_time) * 1000
180+
181+
metrics = self.get_sorted_metrics()
182+
self.assertEqual(len(metrics), 3)
183+
184+
(
185+
client_duration,
186+
client_request_size,
187+
client_response_size,
188+
) = metrics[:3]
189+
190+
self.assertEqual(
191+
client_duration.name, MetricInstruments.HTTP_CLIENT_DURATION
192+
)
193+
self.assert_metric_expected(
194+
client_duration,
195+
client_duration_estimated,
196+
{
197+
"http.status_code": str(result.code),
198+
"http.method": "POST",
199+
"http.url": str(result.url),
200+
"http.flavor": "1.1",
201+
},
202+
est_delta=200,
203+
)
204+
205+
self.assertEqual(
206+
client_request_size.name,
207+
MetricInstruments.HTTP_CLIENT_REQUEST_SIZE,
208+
)
209+
self.assert_metric_expected(
210+
client_request_size,
211+
len(data_encoded),
212+
{
213+
"http.status_code": str(result.code),
214+
"http.method": "POST",
215+
"http.url": str(result.url),
216+
"http.flavor": "1.1",
217+
},
218+
)
219+
220+
self.assertEqual(
221+
client_response_size.name,
222+
MetricInstruments.HTTP_CLIENT_RESPONSE_SIZE,
223+
)
224+
self.assert_metric_expected(
225+
client_response_size,
226+
result.length,
227+
{
228+
"http.status_code": str(result.code),
229+
"http.method": "POST",
230+
"http.url": str(result.url),
231+
"http.flavor": "1.1",
232+
},
233+
)
234+
235+
def test_metric_uninstrument(self):
236+
urllib.request.urlopen(self.URL)
237+
metrics = self.get_sorted_metrics()
238+
self.assertEqual(len(metrics), 3)
239+
240+
URLLibInstrumentor().uninstrument()
241+
urllib.request.urlopen(self.URL)
242+
243+
metrics = self.get_sorted_metrics()
244+
self.assertEqual(len(metrics), 3)
245+
246+
for metric in metrics:
247+
for point in list(metric.data.data_points):
248+
self.assertEqual(point.count, 1)

0 commit comments

Comments
 (0)