Skip to content

Commit 56530eb

Browse files
authored
Metric instrumentation fastapi (#1199)
1 parent fee9926 commit 56530eb

File tree

7 files changed

+168
-14
lines changed

7 files changed

+168
-14
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
- Flask sqlalchemy psycopg2 integration
1212
([#1224](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1224))
13+
- Add metric instrumentation in fastapi
14+
([#1199](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1199))
1315
- Add metric instrumentation in Pyramid
1416
([#1242](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1242))
1517

instrumentation/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
| [opentelemetry-instrumentation-django](./opentelemetry-instrumentation-django) | django >= 1.10 | Yes
1717
| [opentelemetry-instrumentation-elasticsearch](./opentelemetry-instrumentation-elasticsearch) | elasticsearch >= 2.0 | No
1818
| [opentelemetry-instrumentation-falcon](./opentelemetry-instrumentation-falcon) | falcon >= 1.4.1, < 4.0.0 | No
19-
| [opentelemetry-instrumentation-fastapi](./opentelemetry-instrumentation-fastapi) | fastapi ~= 0.58 | No
19+
| [opentelemetry-instrumentation-fastapi](./opentelemetry-instrumentation-fastapi) | fastapi ~= 0.58 | Yes
2020
| [opentelemetry-instrumentation-flask](./opentelemetry-instrumentation-flask) | flask >= 1.0, < 3.0 | Yes
2121
| [opentelemetry-instrumentation-grpc](./opentelemetry-instrumentation-grpc) | grpcio ~= 1.27 | No
2222
| [opentelemetry-instrumentation-httpx](./opentelemetry-instrumentation-httpx) | httpx >= 0.18.0 | No

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,10 +396,15 @@ def __init__(
396396
client_response_hook: _ClientResponseHookT = None,
397397
tracer_provider=None,
398398
meter_provider=None,
399+
meter=None,
399400
):
400401
self.app = guarantee_single_callable(app)
401402
self.tracer = trace.get_tracer(__name__, __version__, tracer_provider)
402-
self.meter = get_meter(__name__, __version__, meter_provider)
403+
self.meter = (
404+
get_meter(__name__, __version__, meter_provider)
405+
if meter is None
406+
else meter
407+
)
403408
self.duration_histogram = self.meter.create_histogram(
404409
name="http.server.duration",
405410
unit="ms",

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ def client_response_hook(span: Span, message: dict):
137137

138138
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
139139
from opentelemetry.instrumentation.asgi.package import _instruments
140+
from opentelemetry.instrumentation.fastapi.version import __version__
140141
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
142+
from opentelemetry.metrics import get_meter
141143
from opentelemetry.semconv.trace import SpanAttributes
142144
from opentelemetry.trace import Span
143145
from opentelemetry.util.http import get_excluded_urls, parse_excluded_urls
@@ -165,6 +167,7 @@ def instrument_app(
165167
client_request_hook: _ClientRequestHookT = None,
166168
client_response_hook: _ClientResponseHookT = None,
167169
tracer_provider=None,
170+
meter_provider=None,
168171
excluded_urls=None,
169172
):
170173
"""Instrument an uninstrumented FastAPI application."""
@@ -176,6 +179,7 @@ def instrument_app(
176179
excluded_urls = _excluded_urls_from_env
177180
else:
178181
excluded_urls = parse_excluded_urls(excluded_urls)
182+
meter = get_meter(__name__, __version__, meter_provider)
179183

180184
app.add_middleware(
181185
OpenTelemetryMiddleware,
@@ -185,6 +189,7 @@ def instrument_app(
185189
client_request_hook=client_request_hook,
186190
client_response_hook=client_response_hook,
187191
tracer_provider=tracer_provider,
192+
meter=meter,
188193
)
189194
app._is_instrumented_by_opentelemetry = True
190195
else:
@@ -223,6 +228,7 @@ def _instrument(self, **kwargs):
223228
if _excluded_urls is None
224229
else parse_excluded_urls(_excluded_urls)
225230
)
231+
_InstrumentedFastAPI._meter_provider = kwargs.get("meter_provider")
226232
fastapi.FastAPI = _InstrumentedFastAPI
227233

228234
def _uninstrument(self, **kwargs):
@@ -231,13 +237,17 @@ def _uninstrument(self, **kwargs):
231237

232238
class _InstrumentedFastAPI(fastapi.FastAPI):
233239
_tracer_provider = None
240+
_meter_provider = None
234241
_excluded_urls = None
235242
_server_request_hook: _ServerRequestHookT = None
236243
_client_request_hook: _ClientRequestHookT = None
237244
_client_response_hook: _ClientResponseHookT = None
238245

239246
def __init__(self, *args, **kwargs):
240247
super().__init__(*args, **kwargs)
248+
meter = get_meter(
249+
__name__, __version__, _InstrumentedFastAPI._meter_provider
250+
)
241251
self.add_middleware(
242252
OpenTelemetryMiddleware,
243253
excluded_urls=_InstrumentedFastAPI._excluded_urls,
@@ -246,6 +256,7 @@ def __init__(self, *args, **kwargs):
246256
client_request_hook=_InstrumentedFastAPI._client_request_hook,
247257
client_response_hook=_InstrumentedFastAPI._client_response_hook,
248258
tracer_provider=_InstrumentedFastAPI._tracer_provider,
259+
meter=meter,
249260
)
250261
self._is_instrumented_by_opentelemetry = True
251262

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

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

1515

1616
_instruments = ("fastapi ~= 0.58",)
17+
18+
_supports_metrics = True

instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
import unittest
16+
from timeit import default_timer
1617
from unittest.mock import patch
1718

1819
import fastapi
@@ -22,16 +23,31 @@
2223
import opentelemetry.instrumentation.fastapi as otel_fastapi
2324
from opentelemetry import trace
2425
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
26+
from opentelemetry.sdk.metrics.export import (
27+
HistogramDataPoint,
28+
NumberDataPoint,
29+
)
2530
from opentelemetry.sdk.resources import Resource
2631
from opentelemetry.semconv.trace import SpanAttributes
2732
from opentelemetry.test.globals_test import reset_trace_globals
2833
from opentelemetry.test.test_base import TestBase
2934
from opentelemetry.util.http import (
3035
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
3136
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
37+
_active_requests_count_attrs,
38+
_duration_attrs,
3239
get_excluded_urls,
3340
)
3441

42+
_expected_metric_names = [
43+
"http.server.active_requests",
44+
"http.server.duration",
45+
]
46+
_recommended_attrs = {
47+
"http.server.active_requests": _active_requests_count_attrs,
48+
"http.server.duration": _duration_attrs,
49+
}
50+
3551

3652
class TestFastAPIManualInstrumentation(TestBase):
3753
def _create_app(self):
@@ -161,6 +177,124 @@ def test_fastapi_excluded_urls_not_env(self):
161177
spans = self.memory_exporter.get_finished_spans()
162178
self.assertEqual(len(spans), 0)
163179

180+
def test_fastapi_metrics(self):
181+
self._client.get("/foobar")
182+
self._client.get("/foobar")
183+
self._client.get("/foobar")
184+
metrics_list = self.memory_metrics_reader.get_metrics_data()
185+
number_data_point_seen = False
186+
histogram_data_point_seen = False
187+
self.assertTrue(len(metrics_list.resource_metrics) == 1)
188+
for resource_metric in metrics_list.resource_metrics:
189+
self.assertTrue(len(resource_metric.scope_metrics) == 1)
190+
for scope_metric in resource_metric.scope_metrics:
191+
self.assertTrue(len(scope_metric.metrics) == 2)
192+
for metric in scope_metric.metrics:
193+
self.assertIn(metric.name, _expected_metric_names)
194+
data_points = list(metric.data.data_points)
195+
self.assertEqual(len(data_points), 1)
196+
for point in data_points:
197+
if isinstance(point, HistogramDataPoint):
198+
self.assertEqual(point.count, 3)
199+
histogram_data_point_seen = True
200+
if isinstance(point, NumberDataPoint):
201+
number_data_point_seen = True
202+
for attr in point.attributes:
203+
self.assertIn(
204+
attr, _recommended_attrs[metric.name]
205+
)
206+
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
207+
208+
def test_basic_metric_success(self):
209+
start = default_timer()
210+
self._client.get("/foobar")
211+
duration = max(round((default_timer() - start) * 1000), 0)
212+
expected_duration_attributes = {
213+
"http.method": "GET",
214+
"http.host": "testserver",
215+
"http.scheme": "http",
216+
"http.flavor": "1.1",
217+
"http.server_name": "testserver",
218+
"net.host.port": 80,
219+
"http.status_code": 200,
220+
}
221+
expected_requests_count_attributes = {
222+
"http.method": "GET",
223+
"http.host": "testserver",
224+
"http.scheme": "http",
225+
"http.flavor": "1.1",
226+
"http.server_name": "testserver",
227+
}
228+
metrics_list = self.memory_metrics_reader.get_metrics_data()
229+
for metric in (
230+
metrics_list.resource_metrics[0].scope_metrics[0].metrics
231+
):
232+
for point in list(metric.data.data_points):
233+
if isinstance(point, HistogramDataPoint):
234+
self.assertDictEqual(
235+
expected_duration_attributes,
236+
dict(point.attributes),
237+
)
238+
self.assertEqual(point.count, 1)
239+
self.assertAlmostEqual(duration, point.sum, delta=20)
240+
if isinstance(point, NumberDataPoint):
241+
self.assertDictEqual(
242+
expected_requests_count_attributes,
243+
dict(point.attributes),
244+
)
245+
self.assertEqual(point.value, 0)
246+
247+
def test_basic_post_request_metric_success(self):
248+
start = default_timer()
249+
self._client.post("/foobar")
250+
duration = max(round((default_timer() - start) * 1000), 0)
251+
metrics_list = self.memory_metrics_reader.get_metrics_data()
252+
for metric in (
253+
metrics_list.resource_metrics[0].scope_metrics[0].metrics
254+
):
255+
for point in list(metric.data.data_points):
256+
if isinstance(point, HistogramDataPoint):
257+
self.assertEqual(point.count, 1)
258+
self.assertAlmostEqual(duration, point.sum, delta=30)
259+
if isinstance(point, NumberDataPoint):
260+
self.assertEqual(point.value, 0)
261+
262+
def test_metric_uninstruemnt_app(self):
263+
self._client.get("/foobar")
264+
self._instrumentor.uninstrument_app(self._app)
265+
self._client.get("/foobar")
266+
metrics_list = self.memory_metrics_reader.get_metrics_data()
267+
for metric in (
268+
metrics_list.resource_metrics[0].scope_metrics[0].metrics
269+
):
270+
for point in list(metric.data.data_points):
271+
if isinstance(point, HistogramDataPoint):
272+
self.assertEqual(point.count, 1)
273+
if isinstance(point, NumberDataPoint):
274+
self.assertEqual(point.value, 0)
275+
276+
def test_metric_uninstrument(self):
277+
# instrumenting class and creating app to send request
278+
self._instrumentor.instrument()
279+
app = self._create_fastapi_app()
280+
client = TestClient(app)
281+
client.get("/foobar")
282+
# uninstrumenting class and creating the app again
283+
self._instrumentor.uninstrument()
284+
app = self._create_fastapi_app()
285+
client = TestClient(app)
286+
client.get("/foobar")
287+
288+
metrics_list = self.memory_metrics_reader.get_metrics_data()
289+
for metric in (
290+
metrics_list.resource_metrics[0].scope_metrics[0].metrics
291+
):
292+
for point in list(metric.data.data_points):
293+
if isinstance(point, HistogramDataPoint):
294+
self.assertEqual(point.count, 1)
295+
if isinstance(point, NumberDataPoint):
296+
self.assertEqual(point.value, 0)
297+
164298
@staticmethod
165299
def _create_fastapi_app():
166300
app = fastapi.FastAPI()

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

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
)
2929

3030
# List of recommended metrics attributes
31-
_duration_attrs = [
31+
_duration_attrs = {
3232
SpanAttributes.HTTP_METHOD,
3333
SpanAttributes.HTTP_HOST,
3434
SpanAttributes.HTTP_SCHEME,
@@ -37,15 +37,15 @@
3737
SpanAttributes.HTTP_SERVER_NAME,
3838
SpanAttributes.NET_HOST_NAME,
3939
SpanAttributes.NET_HOST_PORT,
40-
]
40+
}
4141

42-
_active_requests_count_attrs = [
42+
_active_requests_count_attrs = {
4343
SpanAttributes.HTTP_METHOD,
4444
SpanAttributes.HTTP_HOST,
4545
SpanAttributes.HTTP_SCHEME,
4646
SpanAttributes.HTTP_FLAVOR,
4747
SpanAttributes.HTTP_SERVER_NAME,
48-
]
48+
}
4949

5050

5151
class ExcludeList:
@@ -150,16 +150,16 @@ def get_custom_headers(env_var: str) -> List[str]:
150150

151151

152152
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]
153+
active_requests_count_attrs = {
154+
key: req_attrs[key]
155+
for key in _active_requests_count_attrs.intersection(req_attrs.keys())
156+
}
157157
return active_requests_count_attrs
158158

159159

160160
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]
161+
duration_attrs = {
162+
key: req_attrs[key]
163+
for key in _duration_attrs.intersection(req_attrs.keys())
164+
}
165165
return duration_attrs

0 commit comments

Comments
 (0)