Skip to content

Commit cbf005b

Browse files
authored
Metric instrumentation asgi (#1197)
1 parent ebe6d18 commit cbf005b

File tree

4 files changed

+189
-4
lines changed

4 files changed

+189
-4
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- `opentelemetry-instrumentation-redis` add support to instrument RedisCluster clients
2020
([#1177](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1177))
2121
- `opentelemetry-instrumentation-sqlalchemy` Added span for the connection phase ([#1133](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/1133))
22+
- Add metric instrumentation in asgi
23+
([#1197](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1197))
2224
- Add metric instumentation for flask
2325
([#1186](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1186))
2426

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

+38-4
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ def client_response_hook(span: Span, message: dict):
149149
import typing
150150
import urllib
151151
from functools import wraps
152+
from timeit import default_timer
152153
from typing import Tuple
153154

154155
from asgiref.compatibility import guarantee_single_callable
@@ -162,13 +163,16 @@ def client_response_hook(span: Span, message: dict):
162163
_start_internal_or_server_span,
163164
http_status_to_status_code,
164165
)
166+
from opentelemetry.metrics import get_meter
165167
from opentelemetry.propagators.textmap import Getter, Setter
166168
from opentelemetry.semconv.trace import SpanAttributes
167169
from opentelemetry.trace import Span, set_span_in_context
168170
from opentelemetry.trace.status import Status, StatusCode
169171
from opentelemetry.util.http import (
170172
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
171173
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
174+
_parse_active_request_count_attrs,
175+
_parse_duration_attrs,
172176
get_custom_headers,
173177
normalise_request_header_name,
174178
normalise_response_header_name,
@@ -391,9 +395,21 @@ def __init__(
391395
client_request_hook: _ClientRequestHookT = None,
392396
client_response_hook: _ClientResponseHookT = None,
393397
tracer_provider=None,
398+
meter_provider=None,
394399
):
395400
self.app = guarantee_single_callable(app)
396401
self.tracer = trace.get_tracer(__name__, __version__, tracer_provider)
402+
self.meter = get_meter(__name__, __version__, meter_provider)
403+
self.duration_histogram = self.meter.create_histogram(
404+
name="http.server.duration",
405+
unit="ms",
406+
description="measures the duration of the inbound HTTP request",
407+
)
408+
self.active_requests_counter = self.meter.create_up_down_counter(
409+
name="http.server.active_requests",
410+
unit="requests",
411+
description="measures the number of concurrent HTTP requests that are currently in-flight",
412+
)
397413
self.excluded_urls = excluded_urls
398414
self.default_span_details = (
399415
default_span_details or get_default_span_details
@@ -426,12 +442,17 @@ async def __call__(self, scope, receive, send):
426442
context_carrier=scope,
427443
context_getter=asgi_getter,
428444
)
429-
445+
attributes = collect_request_attributes(scope)
446+
attributes.update(additional_attributes)
447+
active_requests_count_attrs = _parse_active_request_count_attrs(
448+
attributes
449+
)
450+
duration_attrs = _parse_duration_attrs(attributes)
451+
if scope["type"] == "http":
452+
self.active_requests_counter.add(1, active_requests_count_attrs)
430453
try:
431454
with trace.use_span(span, end_on_exit=True) as current_span:
432455
if current_span.is_recording():
433-
attributes = collect_request_attributes(scope)
434-
attributes.update(additional_attributes)
435456
for key, value in attributes.items():
436457
current_span.set_attribute(key, value)
437458

@@ -454,10 +475,18 @@ async def __call__(self, scope, receive, send):
454475
span_name,
455476
scope,
456477
send,
478+
duration_attrs,
457479
)
480+
start = default_timer()
458481

459482
await self.app(scope, otel_receive, otel_send)
460483
finally:
484+
if scope["type"] == "http":
485+
duration = max(round((default_timer() - start) * 1000), 0)
486+
self.duration_histogram.record(duration, duration_attrs)
487+
self.active_requests_counter.add(
488+
-1, active_requests_count_attrs
489+
)
461490
if token:
462491
context.detach(token)
463492

@@ -478,7 +507,9 @@ async def otel_receive():
478507

479508
return otel_receive
480509

481-
def _get_otel_send(self, server_span, server_span_name, scope, send):
510+
def _get_otel_send(
511+
self, server_span, server_span_name, scope, send, duration_attrs
512+
):
482513
@wraps(send)
483514
async def otel_send(message):
484515
with self.tracer.start_as_current_span(
@@ -489,6 +520,9 @@ async def otel_send(message):
489520
if send_span.is_recording():
490521
if message["type"] == "http.response.start":
491522
status_code = message["status"]
523+
duration_attrs[
524+
SpanAttributes.HTTP_STATUS_CODE
525+
] = status_code
492526
set_status_code(server_span, status_code)
493527
set_status_code(send_span, status_code)
494528
elif message["type"] == "websocket.send":

instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_middleware.py

+111
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import sys
1616
import unittest
17+
from timeit import default_timer
1718
from unittest import mock
1819

1920
import opentelemetry.instrumentation.asgi as otel_asgi
@@ -24,6 +25,10 @@
2425
set_global_response_propagator,
2526
)
2627
from opentelemetry.sdk import resources
28+
from opentelemetry.sdk.metrics.export import (
29+
HistogramDataPoint,
30+
NumberDataPoint,
31+
)
2732
from opentelemetry.semconv.trace import SpanAttributes
2833
from opentelemetry.test.asgitestutil import (
2934
AsgiTestBase,
@@ -34,8 +39,19 @@
3439
from opentelemetry.util.http import (
3540
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
3641
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
42+
_active_requests_count_attrs,
43+
_duration_attrs,
3744
)
3845

46+
_expected_metric_names = [
47+
"http.server.active_requests",
48+
"http.server.duration",
49+
]
50+
_recommended_attrs = {
51+
"http.server.active_requests": _active_requests_count_attrs,
52+
"http.server.duration": _duration_attrs,
53+
}
54+
3955

4056
async def http_app(scope, receive, send):
4157
message = await receive()
@@ -523,6 +539,101 @@ def update_expected_hook_results(expected):
523539
outputs, modifiers=[update_expected_hook_results]
524540
)
525541

542+
def test_asgi_metrics(self):
543+
app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
544+
self.seed_app(app)
545+
self.send_default_request()
546+
self.seed_app(app)
547+
self.send_default_request()
548+
self.seed_app(app)
549+
self.send_default_request()
550+
metrics_list = self.memory_metrics_reader.get_metrics_data()
551+
number_data_point_seen = False
552+
histogram_data_point_seen = False
553+
self.assertTrue(len(metrics_list.resource_metrics) != 0)
554+
for resource_metric in metrics_list.resource_metrics:
555+
self.assertTrue(len(resource_metric.scope_metrics) != 0)
556+
for scope_metric in resource_metric.scope_metrics:
557+
self.assertTrue(len(scope_metric.metrics) != 0)
558+
for metric in scope_metric.metrics:
559+
self.assertIn(metric.name, _expected_metric_names)
560+
data_points = list(metric.data.data_points)
561+
self.assertEqual(len(data_points), 1)
562+
for point in data_points:
563+
if isinstance(point, HistogramDataPoint):
564+
self.assertEqual(point.count, 3)
565+
histogram_data_point_seen = True
566+
if isinstance(point, NumberDataPoint):
567+
number_data_point_seen = True
568+
for attr in point.attributes:
569+
self.assertIn(
570+
attr, _recommended_attrs[metric.name]
571+
)
572+
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
573+
574+
def test_basic_metric_success(self):
575+
app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
576+
self.seed_app(app)
577+
start = default_timer()
578+
self.send_default_request()
579+
duration = max(round((default_timer() - start) * 1000), 0)
580+
expected_duration_attributes = {
581+
"http.method": "GET",
582+
"http.host": "127.0.0.1",
583+
"http.scheme": "http",
584+
"http.flavor": "1.0",
585+
"net.host.port": 80,
586+
"http.status_code": 200,
587+
}
588+
expected_requests_count_attributes = {
589+
"http.method": "GET",
590+
"http.host": "127.0.0.1",
591+
"http.scheme": "http",
592+
"http.flavor": "1.0",
593+
}
594+
metrics_list = self.memory_metrics_reader.get_metrics_data()
595+
for resource_metric in metrics_list.resource_metrics:
596+
for scope_metrics in resource_metric.scope_metrics:
597+
for metric in scope_metrics.metrics:
598+
for point in list(metric.data.data_points):
599+
if isinstance(point, HistogramDataPoint):
600+
self.assertDictEqual(
601+
expected_duration_attributes,
602+
dict(point.attributes),
603+
)
604+
self.assertEqual(point.count, 1)
605+
self.assertAlmostEqual(
606+
duration, point.sum, delta=5
607+
)
608+
elif isinstance(point, NumberDataPoint):
609+
self.assertDictEqual(
610+
expected_requests_count_attributes,
611+
dict(point.attributes),
612+
)
613+
self.assertEqual(point.value, 0)
614+
615+
def test_no_metric_for_websockets(self):
616+
self.scope = {
617+
"type": "websocket",
618+
"http_version": "1.1",
619+
"scheme": "ws",
620+
"path": "/",
621+
"query_string": b"",
622+
"headers": [],
623+
"client": ("127.0.0.1", 32767),
624+
"server": ("127.0.0.1", 80),
625+
}
626+
app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
627+
self.seed_app(app)
628+
self.send_input({"type": "websocket.connect"})
629+
self.send_input({"type": "websocket.receive", "text": "ping"})
630+
self.send_input({"type": "websocket.disconnect"})
631+
self.get_all_output()
632+
metrics_list = self.memory_metrics_reader.get_metrics_data()
633+
self.assertEqual(
634+
len(metrics_list.resource_metrics[0].scope_metrics), 0
635+
)
636+
526637

527638
class TestAsgiAttributes(unittest.TestCase):
528639
def setUp(self):

util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py

+38
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,35 @@
1818
from typing import Iterable, List
1919
from urllib.parse import urlparse, urlunparse
2020

21+
from opentelemetry.semconv.trace import SpanAttributes
22+
2123
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST = (
2224
"OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST"
2325
)
2426
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE = (
2527
"OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE"
2628
)
2729

30+
# List of recommended metrics attributes
31+
_duration_attrs = [
32+
SpanAttributes.HTTP_METHOD,
33+
SpanAttributes.HTTP_HOST,
34+
SpanAttributes.HTTP_SCHEME,
35+
SpanAttributes.HTTP_STATUS_CODE,
36+
SpanAttributes.HTTP_FLAVOR,
37+
SpanAttributes.HTTP_SERVER_NAME,
38+
SpanAttributes.NET_HOST_NAME,
39+
SpanAttributes.NET_HOST_PORT,
40+
]
41+
42+
_active_requests_count_attrs = [
43+
SpanAttributes.HTTP_METHOD,
44+
SpanAttributes.HTTP_HOST,
45+
SpanAttributes.HTTP_SCHEME,
46+
SpanAttributes.HTTP_FLAVOR,
47+
SpanAttributes.HTTP_SERVER_NAME,
48+
]
49+
2850

2951
class ExcludeList:
3052
"""Class to exclude certain paths (given as a list of regexes) from tracing requests"""
@@ -125,3 +147,19 @@ def get_custom_headers(env_var: str) -> List[str]:
125147
for custom_headers in custom_headers.split(",")
126148
]
127149
return custom_headers
150+
151+
152+
def _parse_active_request_count_attrs(req_attrs):
153+
active_requests_count_attrs = {}
154+
for attr_key in _active_requests_count_attrs:
155+
if req_attrs.get(attr_key) is not None:
156+
active_requests_count_attrs[attr_key] = req_attrs[attr_key]
157+
return active_requests_count_attrs
158+
159+
160+
def _parse_duration_attrs(req_attrs):
161+
duration_attrs = {}
162+
for attr_key in _duration_attrs:
163+
if req_attrs.get(attr_key) is not None:
164+
duration_attrs[attr_key] = req_attrs[attr_key]
165+
return duration_attrs

0 commit comments

Comments
 (0)