Skip to content

Commit beccc3b

Browse files
authored
Adding metric collection as part of instrumentations - Django (#1230)
1 parent 6f514a3 commit beccc3b

File tree

10 files changed

+210
-41
lines changed

10 files changed

+210
-41
lines changed

docs/examples/django/manage.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@
2121

2222

2323
def main():
24+
os.environ.setdefault(
25+
"DJANGO_SETTINGS_MODULE", "instrumentation_example.settings"
26+
)
2427

2528
# This call is what makes the Django application be instrumented
2629
DjangoInstrumentor().instrument()
2730

28-
os.environ.setdefault(
29-
"DJANGO_SETTINGS_MODULE", "instrumentation_example.settings"
30-
)
3131
try:
3232
from django.core.management import execute_from_command_line
3333
except ImportError as exc:

instrumentation/opentelemetry-instrumentation-django/CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Released 2020-10-13
1111
- Changed span name extraction from request to comply semantic convention ([#992](https://github.com/open-telemetry/opentelemetry-python/pull/992))
1212
- Added support for `OTEL_PYTHON_DJANGO_TRACED_REQUEST_ATTRS` ([#1154](https://github.com/open-telemetry/opentelemetry-python/pull/1154))
1313
- Added capture of http.route ([#1226](https://github.com/open-telemetry/opentelemetry-python/issues/1226))
14+
- Add support for tracking http metrics
15+
([#1230](https://github.com/open-telemetry/opentelemetry-python/pull/1230))
1416

1517
## Version 0.13b0
1618

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

+12-1
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,18 @@
1818

1919
from opentelemetry.configuration import Configuration
2020
from opentelemetry.instrumentation.django.middleware import _DjangoMiddleware
21+
from opentelemetry.instrumentation.django.version import __version__
2122
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
23+
from opentelemetry.instrumentation.metric import (
24+
HTTPMetricRecorder,
25+
HTTPMetricType,
26+
MetricMixin,
27+
)
2228

2329
_logger = getLogger(__name__)
2430

2531

26-
class DjangoInstrumentor(BaseInstrumentor):
32+
class DjangoInstrumentor(BaseInstrumentor, MetricMixin):
2733
"""An instrumentor for Django
2834
2935
See `BaseInstrumentor`
@@ -57,6 +63,11 @@ def _instrument(self, **kwargs):
5763
settings_middleware = list(settings_middleware)
5864

5965
settings_middleware.insert(0, self._opentelemetry_middleware)
66+
self.init_metrics(
67+
__name__, __version__,
68+
)
69+
metric_recorder = HTTPMetricRecorder(self.meter, HTTPMetricType.SERVER)
70+
setattr(settings, "OTEL_METRIC_RECORDER", metric_recorder)
6071
setattr(settings, "MIDDLEWARE", settings_middleware)
6172

6273
def _uninstrument(self, **kwargs):

instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware.py

+48-3
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+
import time
1516
from logging import getLogger
1617

18+
from django.conf import settings
19+
1720
from opentelemetry.configuration import Configuration
1821
from opentelemetry.context import attach, detach
1922
from opentelemetry.instrumentation.django.version import __version__
@@ -41,11 +44,16 @@
4144
MiddlewareMixin = object
4245

4346
_logger = getLogger(__name__)
47+
_attributes_by_preference = [
48+
["http.scheme", "http.host", "http.target"],
49+
["http.scheme", "http.server_name", "net.host.port", "http.target"],
50+
["http.scheme", "net.host.name", "net.host.port", "http.target"],
51+
["http.url"],
52+
]
4453

4554

4655
class _DjangoMiddleware(MiddlewareMixin):
47-
"""Django Middleware for OpenTelemetry
48-
"""
56+
"""Django Middleware for OpenTelemetry"""
4957

5058
_environ_activation_key = (
5159
"opentelemetry-instrumentor-django.activation_key"
@@ -88,6 +96,21 @@ def _get_span_name(request):
8896
except Resolver404:
8997
return "HTTP {}".format(request.method)
9098

99+
@staticmethod
100+
def _get_metric_labels_from_attributes(attributes):
101+
labels = {}
102+
labels["http.method"] = attributes.get("http.method", "")
103+
for attrs in _attributes_by_preference:
104+
labels_from_attributes = {
105+
attr: attributes.get(attr, None) for attr in attrs
106+
}
107+
if set(attrs).issubset(attributes.keys()):
108+
labels.update(labels_from_attributes)
109+
break
110+
if attributes.get("http.flavor"):
111+
labels["http.flavor"] = attributes.get("http.flavor")
112+
return labels
113+
91114
def process_request(self, request):
92115
# request.META is a dictionary containing all available HTTP headers
93116
# Read more about request.META here:
@@ -96,6 +119,9 @@ def process_request(self, request):
96119
if self._excluded_urls.url_disabled(request.build_absolute_uri("?")):
97120
return
98121

122+
# pylint:disable=W0212
123+
request._otel_start_time = time.time()
124+
99125
environ = request.META
100126

101127
token = attach(extract(get_header_from_environ, environ))
@@ -110,8 +136,13 @@ def process_request(self, request):
110136
),
111137
)
112138

139+
attributes = collect_request_attributes(environ)
140+
# pylint:disable=W0212
141+
request._otel_labels = self._get_metric_labels_from_attributes(
142+
attributes
143+
)
144+
113145
if span.is_recording():
114-
attributes = collect_request_attributes(environ)
115146
attributes = extract_attributes_from_object(
116147
request, self._traced_request_attrs, attributes
117148
)
@@ -176,6 +207,10 @@ def process_response(self, request, response):
176207
"{} {}".format(response.status_code, response.reason_phrase),
177208
response,
178209
)
210+
# pylint:disable=W0212
211+
request._otel_labels["http.status_code"] = str(
212+
response.status_code
213+
)
179214
request.META.pop(self._environ_span_key)
180215

181216
request.META[self._environ_activation_key].__exit__(
@@ -187,4 +222,14 @@ def process_response(self, request, response):
187222
detach(request.environ.get(self._environ_token))
188223
request.META.pop(self._environ_token)
189224

225+
try:
226+
metric_recorder = getattr(settings, "OTEL_METRIC_RECORDER", None)
227+
if metric_recorder is not None:
228+
# pylint:disable=W0212
229+
metric_recorder.record_server_duration_range(
230+
request._otel_start_time, time.time(), request._otel_labels
231+
)
232+
except Exception as ex: # pylint: disable=W0703
233+
_logger.warning("Error recording duration metrics: %s", ex)
234+
190235
return response

instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py

+40-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
from opentelemetry.configuration import Configuration
2525
from opentelemetry.instrumentation.django import DjangoInstrumentor
26+
from opentelemetry.sdk.util import get_dict_as_key
27+
from opentelemetry.test.test_base import TestBase
2628
from opentelemetry.test.wsgitestutil import WsgiTestBase
2729
from opentelemetry.trace import SpanKind
2830
from opentelemetry.trace.status import StatusCanonicalCode
@@ -53,7 +55,7 @@
5355
_django_instrumentor = DjangoInstrumentor()
5456

5557

56-
class TestMiddleware(WsgiTestBase):
58+
class TestMiddleware(TestBase, WsgiTestBase):
5759
@classmethod
5860
def setUpClass(cls):
5961
super().setUpClass()
@@ -121,6 +123,26 @@ def test_traced_get(self):
121123
self.assertEqual(span.attributes["http.status_code"], 200)
122124
self.assertEqual(span.attributes["http.status_text"], "OK")
123125

126+
self.assertIsNotNone(_django_instrumentor.meter)
127+
self.assertEqual(len(_django_instrumentor.meter.metrics), 1)
128+
recorder = _django_instrumentor.meter.metrics.pop()
129+
match_key = get_dict_as_key(
130+
{
131+
"http.flavor": "1.1",
132+
"http.method": "GET",
133+
"http.status_code": "200",
134+
"http.url": "http://testserver/traced/",
135+
}
136+
)
137+
for key in recorder.bound_instruments.keys():
138+
self.assertEqual(key, match_key)
139+
# pylint: disable=protected-access
140+
bound = recorder.bound_instruments.get(key)
141+
for view_data in bound.view_datas:
142+
self.assertEqual(view_data.labels, key)
143+
self.assertEqual(view_data.aggregator.current.count, 1)
144+
self.assertGreaterEqual(view_data.aggregator.current.sum, 0)
145+
124146
def test_not_recording(self):
125147
mock_tracer = Mock()
126148
mock_span = Mock()
@@ -180,6 +202,23 @@ def test_error(self):
180202
)
181203
self.assertEqual(span.attributes["http.route"], "^error/")
182204
self.assertEqual(span.attributes["http.scheme"], "http")
205+
self.assertIsNotNone(_django_instrumentor.meter)
206+
self.assertEqual(len(_django_instrumentor.meter.metrics), 1)
207+
recorder = _django_instrumentor.meter.metrics.pop()
208+
match_key = get_dict_as_key(
209+
{
210+
"http.flavor": "1.1",
211+
"http.method": "GET",
212+
"http.url": "http://testserver/error/",
213+
}
214+
)
215+
for key in recorder.bound_instruments.keys():
216+
self.assertEqual(key, match_key)
217+
# pylint: disable=protected-access
218+
bound = recorder.bound_instruments.get(key)
219+
for view_data in bound.view_datas:
220+
self.assertEqual(view_data.labels, key)
221+
self.assertEqual(view_data.aggregator.current.count, 1)
183222

184223
@patch(
185224
"opentelemetry.instrumentation.django.middleware._DjangoMiddleware._excluded_urls",

instrumentation/opentelemetry-instrumentation-requests/CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Released 2020-09-17
1010
([#1040](https://github.com/open-telemetry/opentelemetry-python/pull/1040))
1111
- Drop support for Python 3.4
1212
([#1099](https://github.com/open-telemetry/opentelemetry-python/pull/1099))
13-
- Add support for http metrics
13+
- Add support for tracking http metrics
1414
([#1116](https://github.com/open-telemetry/opentelemetry-python/pull/1116))
1515

1616
## Version 0.12b0

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

+5-3
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
4646
from opentelemetry.instrumentation.metric import (
4747
HTTPMetricRecorder,
48+
HTTPMetricType,
4849
MetricMixin,
4950
)
5051
from opentelemetry.instrumentation.requests.version import __version__
@@ -135,7 +136,7 @@ def _instrumented_requests_call(
135136
__name__, __version__, tracer_provider
136137
).start_as_current_span(span_name, kind=SpanKind.CLIENT) as span:
137138
exception = None
138-
with recorder.record_duration(labels):
139+
with recorder.record_client_duration(labels):
139140
if span.is_recording():
140141
span.set_attribute("component", "http")
141142
span.set_attribute("http.method", method)
@@ -176,7 +177,6 @@ def _instrumented_requests_call(
176177
)
177178
)
178179
labels["http.status_code"] = str(result.status_code)
179-
labels["http.status_text"] = result.reason
180180
if result.raw and result.raw.version:
181181
labels["http.flavor"] = (
182182
str(result.raw.version)[:1]
@@ -253,7 +253,9 @@ def _instrument(self, **kwargs):
253253
__name__, __version__,
254254
)
255255
# pylint: disable=W0201
256-
self.metric_recorder = HTTPMetricRecorder(self.meter, SpanKind.CLIENT)
256+
self.metric_recorder = HTTPMetricRecorder(
257+
self.meter, HTTPMetricType.CLIENT
258+
)
257259

258260
def _uninstrument(self, **kwargs):
259261
_uninstrument()

instrumentation/opentelemetry-instrumentation-requests/tests/test_requests_integration.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,6 @@ def test_basic(self):
9797
"http.flavor": "1.1",
9898
"http.method": "GET",
9999
"http.status_code": "200",
100-
"http.status_text": "OK",
101100
"http.url": "http://httpbin.org/status/200",
102101
}
103102
)
@@ -108,7 +107,7 @@ def test_basic(self):
108107
for view_data in bound.view_datas:
109108
self.assertEqual(view_data.labels, key)
110109
self.assertEqual(view_data.aggregator.current.count, 1)
111-
self.assertGreater(view_data.aggregator.current.sum, 0)
110+
self.assertGreaterEqual(view_data.aggregator.current.sum, 0)
112111

113112
def test_not_foundbasic(self):
114113
url_404 = "http://httpbin.org/status/404"
@@ -318,7 +317,6 @@ def test_requests_exception_with_response(self, *_, **__):
318317
{
319318
"http.method": "GET",
320319
"http.status_code": "500",
321-
"http.status_text": "Internal Server Error",
322320
"http.url": "http://httpbin.org/status/200",
323321
}
324322
)

opentelemetry-instrumentation/src/opentelemetry/instrumentation/metric.py

+44-19
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
class HTTPMetricType(enum.Enum):
2929
CLIENT = 0
3030
SERVER = 1
31-
# TODO: Add both
31+
BOTH = 2
3232

3333

3434
class MetricMixin:
@@ -57,29 +57,54 @@ def __init__(
5757
):
5858
super().__init__(meter)
5959
self._http_type = http_type
60-
if self._meter:
61-
self._duration = self._meter.create_metric(
62-
name="{}.{}.duration".format(
63-
"http", self._http_type.name.lower()
64-
),
65-
description="measures the duration of the {} HTTP request".format(
66-
"inbound"
67-
if self._http_type is HTTPMetricType.SERVER
68-
else "outbound"
69-
),
70-
unit="ms",
71-
value_type=float,
72-
metric_type=ValueRecorder,
73-
)
60+
self._client_duration = None
61+
self._server_duration = None
62+
if self._meter is not None:
63+
if http_type in (HTTPMetricType.CLIENT, HTTPMetricType.BOTH):
64+
self._client_duration = self._meter.create_metric(
65+
name="{}.{}.duration".format("http", "client"),
66+
description="measures the duration of the outbound HTTP request",
67+
unit="ms",
68+
value_type=float,
69+
metric_type=ValueRecorder,
70+
)
71+
if http_type is not HTTPMetricType.CLIENT:
72+
self._server_duration = self._meter.create_metric(
73+
name="{}.{}.duration".format("http", "server"),
74+
description="measures the duration of the inbound HTTP request",
75+
unit="ms",
76+
value_type=float,
77+
metric_type=ValueRecorder,
78+
)
7479

7580
# Conventions for recording duration can be found at:
7681
# https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/metrics/semantic_conventions/http-metrics.md
7782
@contextmanager
78-
def record_duration(self, labels: Dict[str, str]):
83+
def record_client_duration(self, labels: Dict[str, str]):
7984
start_time = time()
8085
try:
8186
yield start_time
8287
finally:
83-
if self._meter:
84-
elapsed_time = (time() - start_time) * 1000
85-
self._duration.record(elapsed_time, labels)
88+
self.record_client_duration_range(start_time, time(), labels)
89+
90+
def record_client_duration_range(
91+
self, start_time, end_time, labels: Dict[str, str]
92+
):
93+
if self._client_duration is not None:
94+
elapsed_time = (end_time - start_time) * 1000
95+
self._client_duration.record(elapsed_time, labels)
96+
97+
@contextmanager
98+
def record_server_duration(self, labels: Dict[str, str]):
99+
start_time = time()
100+
try:
101+
yield start_time
102+
finally:
103+
self.record_server_duration_range(start_time, time(), labels)
104+
105+
def record_server_duration_range(
106+
self, start_time, end_time, labels: Dict[str, str]
107+
):
108+
if self._server_duration is not None:
109+
elapsed_time = (end_time - start_time) * 1000
110+
self._server_duration.record(elapsed_time, labels)

0 commit comments

Comments
 (0)