Skip to content

Commit fc98f08

Browse files
Metrics instrumentation starlette (#1327)
1 parent 65329a8 commit fc98f08

File tree

5 files changed

+163
-1
lines changed

5 files changed

+163
-1
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
([#1242](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1242))
2525
- `opentelemetry-util-http` Add support for sanitizing HTTP header values.
2626
([#1253](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1253))
27+
- Add metric instrumentation in starlette
28+
([#1327](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1327))
29+
2730

2831
### Fixed
2932

instrumentation/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
| [opentelemetry-instrumentation-sklearn](./opentelemetry-instrumentation-sklearn) | scikit-learn ~= 0.24.0 | No
3737
| [opentelemetry-instrumentation-sqlalchemy](./opentelemetry-instrumentation-sqlalchemy) | sqlalchemy | No
3838
| [opentelemetry-instrumentation-sqlite3](./opentelemetry-instrumentation-sqlite3) | sqlite3 | No
39-
| [opentelemetry-instrumentation-starlette](./opentelemetry-instrumentation-starlette) | starlette ~= 0.13.0 | No
39+
| [opentelemetry-instrumentation-starlette](./opentelemetry-instrumentation-starlette) | starlette ~= 0.13.0 | Yes
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

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

+38
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ def client_response_hook(span: Span, message: dict):
131131
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
132132
from opentelemetry.instrumentation.asgi.package import _instruments
133133
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
134+
from opentelemetry.instrumentation.starlette.version import __version__
135+
from opentelemetry.metrics import get_meter
134136
from opentelemetry.semconv.trace import SpanAttributes
135137
from opentelemetry.trace import Span
136138
from opentelemetry.util.http import get_excluded_urls
@@ -156,9 +158,11 @@ def instrument_app(
156158
server_request_hook: _ServerRequestHookT = None,
157159
client_request_hook: _ClientRequestHookT = None,
158160
client_response_hook: _ClientResponseHookT = None,
161+
meter_provider=None,
159162
tracer_provider=None,
160163
):
161164
"""Instrument an uninstrumented Starlette application."""
165+
meter = get_meter(__name__, __version__, meter_provider)
162166
if not getattr(app, "is_instrumented_by_opentelemetry", False):
163167
app.add_middleware(
164168
OpenTelemetryMiddleware,
@@ -168,9 +172,24 @@ def instrument_app(
168172
client_request_hook=client_request_hook,
169173
client_response_hook=client_response_hook,
170174
tracer_provider=tracer_provider,
175+
meter=meter,
171176
)
172177
app.is_instrumented_by_opentelemetry = True
173178

179+
# adding apps to set for uninstrumenting
180+
if app not in _InstrumentedStarlette._instrumented_starlette_apps:
181+
_InstrumentedStarlette._instrumented_starlette_apps.add(app)
182+
183+
@staticmethod
184+
def uninstrument_app(app: applications.Starlette):
185+
app.user_middleware = [
186+
x
187+
for x in app.user_middleware
188+
if x.cls is not OpenTelemetryMiddleware
189+
]
190+
app.middleware_stack = app.build_middleware_stack()
191+
app._is_instrumented_by_opentelemetry = False
192+
174193
def instrumentation_dependencies(self) -> Collection[str]:
175194
return _instruments
176195

@@ -186,20 +205,32 @@ def _instrument(self, **kwargs):
186205
_InstrumentedStarlette._client_response_hook = kwargs.get(
187206
"client_response_hook"
188207
)
208+
_InstrumentedStarlette._meter_provider = kwargs.get("_meter_provider")
209+
189210
applications.Starlette = _InstrumentedStarlette
190211

191212
def _uninstrument(self, **kwargs):
213+
214+
"""uninstrumenting all created apps by user"""
215+
for instance in _InstrumentedStarlette._instrumented_starlette_apps:
216+
self.uninstrument_app(instance)
217+
_InstrumentedStarlette._instrumented_starlette_apps.clear()
192218
applications.Starlette = self._original_starlette
193219

194220

195221
class _InstrumentedStarlette(applications.Starlette):
196222
_tracer_provider = None
223+
_meter_provider = None
197224
_server_request_hook: _ServerRequestHookT = None
198225
_client_request_hook: _ClientRequestHookT = None
199226
_client_response_hook: _ClientResponseHookT = None
227+
_instrumented_starlette_apps = set()
200228

201229
def __init__(self, *args, **kwargs):
202230
super().__init__(*args, **kwargs)
231+
meter = get_meter(
232+
__name__, __version__, _InstrumentedStarlette._meter_provider
233+
)
203234
self.add_middleware(
204235
OpenTelemetryMiddleware,
205236
excluded_urls=_excluded_urls,
@@ -208,7 +239,14 @@ def __init__(self, *args, **kwargs):
208239
client_request_hook=_InstrumentedStarlette._client_request_hook,
209240
client_response_hook=_InstrumentedStarlette._client_response_hook,
210241
tracer_provider=_InstrumentedStarlette._tracer_provider,
242+
meter=meter,
211243
)
244+
self._is_instrumented_by_opentelemetry = True
245+
# adding apps to set for uninstrumenting
246+
_InstrumentedStarlette._instrumented_starlette_apps.add(self)
247+
248+
def __del__(self):
249+
_InstrumentedStarlette._instrumented_starlette_apps.remove(self)
212250

213251

214252
def _get_route_details(scope):

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

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

1515

1616
_instruments = ("starlette ~= 0.13.0",)
17+
18+
_supports_metrics = True

instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py

+119
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
from starlette import applications
@@ -22,6 +23,10 @@
2223
from starlette.websockets import WebSocket
2324

2425
import opentelemetry.instrumentation.starlette as otel_starlette
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
@@ -35,9 +40,20 @@
3540
from opentelemetry.util.http import (
3641
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
3742
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
43+
_active_requests_count_attrs,
44+
_duration_attrs,
3845
get_excluded_urls,
3946
)
4047

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

4258
class TestStarletteManualInstrumentation(TestBase):
4359
def _create_app(self):
@@ -100,6 +116,109 @@ def test_starlette_excluded_urls(self):
100116
spans = self.memory_exporter.get_finished_spans()
101117
self.assertEqual(len(spans), 0)
102118

119+
def test_starlette_metrics(self):
120+
self._client.get("/foobar")
121+
self._client.get("/foobar")
122+
self._client.get("/foobar")
123+
metrics_list = self.memory_metrics_reader.get_metrics_data()
124+
number_data_point_seen = False
125+
histogram_data_point_seen = False
126+
self.assertTrue(len(metrics_list.resource_metrics) == 1)
127+
for resource_metric in metrics_list.resource_metrics:
128+
self.assertTrue(len(resource_metric.scope_metrics) == 1)
129+
for scope_metric in resource_metric.scope_metrics:
130+
self.assertTrue(len(scope_metric.metrics) == 2)
131+
for metric in scope_metric.metrics:
132+
self.assertIn(metric.name, _expected_metric_names)
133+
data_points = list(metric.data.data_points)
134+
self.assertEqual(len(data_points), 1)
135+
for point in data_points:
136+
if isinstance(point, HistogramDataPoint):
137+
self.assertEqual(point.count, 3)
138+
histogram_data_point_seen = True
139+
if isinstance(point, NumberDataPoint):
140+
number_data_point_seen = True
141+
for attr in point.attributes:
142+
self.assertIn(
143+
attr, _recommended_attrs[metric.name]
144+
)
145+
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
146+
147+
def test_basic_post_request_metric_success(self):
148+
start = default_timer()
149+
expected_duration_attributes = {
150+
"http.flavor": "1.1",
151+
"http.host": "testserver",
152+
"http.method": "POST",
153+
"http.scheme": "http",
154+
"http.server_name": "testserver",
155+
"http.status_code": 405,
156+
"net.host.port": 80,
157+
}
158+
expected_requests_count_attributes = {
159+
"http.flavor": "1.1",
160+
"http.host": "testserver",
161+
"http.method": "POST",
162+
"http.scheme": "http",
163+
"http.server_name": "testserver",
164+
}
165+
self._client.post("/foobar")
166+
duration = max(round((default_timer() - start) * 1000), 0)
167+
metrics_list = self.memory_metrics_reader.get_metrics_data()
168+
for metric in (
169+
metrics_list.resource_metrics[0].scope_metrics[0].metrics
170+
):
171+
for point in list(metric.data.data_points):
172+
if isinstance(point, HistogramDataPoint):
173+
self.assertEqual(point.count, 1)
174+
self.assertAlmostEqual(duration, point.sum, delta=30)
175+
self.assertDictEqual(
176+
dict(point.attributes), expected_duration_attributes
177+
)
178+
if isinstance(point, NumberDataPoint):
179+
self.assertDictEqual(
180+
expected_requests_count_attributes,
181+
dict(point.attributes),
182+
)
183+
self.assertEqual(point.value, 0)
184+
185+
def test_metric_for_uninstrment_app_method(self):
186+
self._client.get("/foobar")
187+
# uninstrumenting the existing client app
188+
self._instrumentor.uninstrument_app(self._app)
189+
self._client.get("/foobar")
190+
self._client.get("/foobar")
191+
metrics_list = self.memory_metrics_reader.get_metrics_data()
192+
for metric in (
193+
metrics_list.resource_metrics[0].scope_metrics[0].metrics
194+
):
195+
for point in list(metric.data.data_points):
196+
if isinstance(point, HistogramDataPoint):
197+
self.assertEqual(point.count, 1)
198+
if isinstance(point, NumberDataPoint):
199+
self.assertEqual(point.value, 0)
200+
201+
def test_metric_uninstrument_inherited_by_base(self):
202+
# instrumenting class and creating app to send request
203+
self._instrumentor.instrument()
204+
app = self._create_starlette_app()
205+
client = TestClient(app)
206+
client.get("/foobar")
207+
# calling uninstrument and checking for telemetry data
208+
self._instrumentor.uninstrument()
209+
client.get("/foobar")
210+
client.get("/foobar")
211+
client.get("/foobar")
212+
metrics_list = self.memory_metrics_reader.get_metrics_data()
213+
for metric in (
214+
metrics_list.resource_metrics[0].scope_metrics[0].metrics
215+
):
216+
for point in list(metric.data.data_points):
217+
if isinstance(point, HistogramDataPoint):
218+
self.assertEqual(point.count, 1)
219+
if isinstance(point, NumberDataPoint):
220+
self.assertEqual(point.value, 0)
221+
103222
@staticmethod
104223
def _create_starlette_app():
105224
def home(_):

0 commit comments

Comments
 (0)