Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Metrics instrumentation starlette #1327

Merged
merged 17 commits into from
Sep 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
16cc465
added metrics implemetation for starlette
rahulmukherjee68 Sep 12, 2022
c7bf4be
added testcase for basic metric check
rahulmukherjee68 Sep 13, 2022
e605f92
Merge branch 'main' into metrics-instrumentation-starlette
rahulmukherjee68 Sep 13, 2022
38df3cc
added testcases for uninstrumentation for starlette with metrics
rahulmukherjee68 Sep 13, 2022
d4dbe10
Merge branch 'main' into metrics-instrumentation-starlette
rahulmukherjee68 Sep 13, 2022
3280111
added changelog and reformarted with tox generate
rahulmukherjee68 Sep 13, 2022
fef0952
fixed merge conflict from main
rahulmukherjee68 Sep 13, 2022
5d1035e
added uninstrumentation logic for apps and made changes as per review…
rahulmukherjee68 Sep 14, 2022
d6e6bdc
Merge branch 'main' into metrics-instrumentation-starlette
srikanthccv Sep 15, 2022
9cc0d87
Merge branch 'main' into metrics-instrumentation-starlette
srikanthccv Sep 20, 2022
90d9b50
Merge branch 'main' into metrics-instrumentation-starlette
rahulmukherjee68 Sep 22, 2022
4aeb100
added changes as per review comments
rahulmukherjee68 Sep 22, 2022
a0fe57d
Merge branch 'metrics-instrumentation-starlette' of https://github.co…
rahulmukherjee68 Sep 22, 2022
de2db2f
removed re-instrumentaion of _instrument
rahulmukherjee68 Sep 22, 2022
e802135
Merge branch 'main' into metrics-instrumentation-starlette
lzchen Sep 26, 2022
5a5b7b9
Merge branch 'main' into metrics-instrumentation-starlette
rahulmukherjee68 Sep 27, 2022
b37b618
Merge branch 'main' into metrics-instrumentation-starlette
srikanthccv Sep 27, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#1242](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1242))
- `opentelemetry-util-http` Add support for sanitizing HTTP header values.
([#1253](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1253))
- Add metric instrumentation in starlette
([#1327](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1327))


### Fixed

Expand Down
2 changes: 1 addition & 1 deletion instrumentation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
| [opentelemetry-instrumentation-sklearn](./opentelemetry-instrumentation-sklearn) | scikit-learn ~= 0.24.0 | No
| [opentelemetry-instrumentation-sqlalchemy](./opentelemetry-instrumentation-sqlalchemy) | sqlalchemy | No
| [opentelemetry-instrumentation-sqlite3](./opentelemetry-instrumentation-sqlite3) | sqlite3 | No
| [opentelemetry-instrumentation-starlette](./opentelemetry-instrumentation-starlette) | starlette ~= 0.13.0 | No
| [opentelemetry-instrumentation-starlette](./opentelemetry-instrumentation-starlette) | starlette ~= 0.13.0 | Yes
| [opentelemetry-instrumentation-system-metrics](./opentelemetry-instrumentation-system-metrics) | psutil >= 5 | No
| [opentelemetry-instrumentation-tornado](./opentelemetry-instrumentation-tornado) | tornado >= 5.1.1 | No
| [opentelemetry-instrumentation-urllib](./opentelemetry-instrumentation-urllib) | urllib | No
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ def client_response_hook(span: Span, message: dict):
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
from opentelemetry.instrumentation.asgi.package import _instruments
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
from opentelemetry.instrumentation.starlette.version import __version__
from opentelemetry.metrics import get_meter
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace import Span
from opentelemetry.util.http import get_excluded_urls
Expand All @@ -156,9 +158,11 @@ def instrument_app(
server_request_hook: _ServerRequestHookT = None,
client_request_hook: _ClientRequestHookT = None,
client_response_hook: _ClientResponseHookT = None,
meter_provider=None,
tracer_provider=None,
):
"""Instrument an uninstrumented Starlette application."""
meter = get_meter(__name__, __version__, meter_provider)
if not getattr(app, "is_instrumented_by_opentelemetry", False):
app.add_middleware(
OpenTelemetryMiddleware,
Expand All @@ -168,9 +172,24 @@ def instrument_app(
client_request_hook=client_request_hook,
client_response_hook=client_response_hook,
tracer_provider=tracer_provider,
meter=meter,
)
app.is_instrumented_by_opentelemetry = True

# adding apps to set for uninstrumenting
if app not in _InstrumentedStarlette._instrumented_starlette_apps:
_InstrumentedStarlette._instrumented_starlette_apps.add(app)

@staticmethod
def uninstrument_app(app: applications.Starlette):
app.user_middleware = [
x
for x in app.user_middleware
if x.cls is not OpenTelemetryMiddleware
Copy link
Contributor

@lzchen lzchen Sep 26, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do not isinstance() here?

]
app.middleware_stack = app.build_middleware_stack()
app._is_instrumented_by_opentelemetry = False

def instrumentation_dependencies(self) -> Collection[str]:
return _instruments

Expand All @@ -186,20 +205,32 @@ def _instrument(self, **kwargs):
_InstrumentedStarlette._client_response_hook = kwargs.get(
"client_response_hook"
)
_InstrumentedStarlette._meter_provider = kwargs.get("_meter_provider")

applications.Starlette = _InstrumentedStarlette

def _uninstrument(self, **kwargs):

"""uninstrumenting all created apps by user"""
for instance in _InstrumentedStarlette._instrumented_starlette_apps:
self.uninstrument_app(instance)
_InstrumentedStarlette._instrumented_starlette_apps.clear()
applications.Starlette = self._original_starlette


class _InstrumentedStarlette(applications.Starlette):
_tracer_provider = None
_meter_provider = None
_server_request_hook: _ServerRequestHookT = None
_client_request_hook: _ClientRequestHookT = None
_client_response_hook: _ClientResponseHookT = None
_instrumented_starlette_apps = set()

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
meter = get_meter(
__name__, __version__, _InstrumentedStarlette._meter_provider
)
self.add_middleware(
OpenTelemetryMiddleware,
excluded_urls=_excluded_urls,
Expand All @@ -208,7 +239,14 @@ def __init__(self, *args, **kwargs):
client_request_hook=_InstrumentedStarlette._client_request_hook,
client_response_hook=_InstrumentedStarlette._client_response_hook,
tracer_provider=_InstrumentedStarlette._tracer_provider,
meter=meter,
)
self._is_instrumented_by_opentelemetry = True
# adding apps to set for uninstrumenting
_InstrumentedStarlette._instrumented_starlette_apps.add(self)

def __del__(self):
_InstrumentedStarlette._instrumented_starlette_apps.remove(self)


def _get_route_details(scope):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@


_instruments = ("starlette ~= 0.13.0",)

_supports_metrics = True
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# limitations under the License.

import unittest
from timeit import default_timer
from unittest.mock import patch

from starlette import applications
Expand All @@ -22,6 +23,10 @@
from starlette.websockets import WebSocket

import opentelemetry.instrumentation.starlette as otel_starlette
from opentelemetry.sdk.metrics.export import (
HistogramDataPoint,
NumberDataPoint,
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.test.globals_test import reset_trace_globals
Expand All @@ -35,9 +40,20 @@
from opentelemetry.util.http import (
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
_active_requests_count_attrs,
_duration_attrs,
get_excluded_urls,
)

_expected_metric_names = [
"http.server.active_requests",
"http.server.duration",
]
_recommended_attrs = {
"http.server.active_requests": _active_requests_count_attrs,
"http.server.duration": _duration_attrs,
}


class TestStarletteManualInstrumentation(TestBase):
def _create_app(self):
Expand Down Expand Up @@ -100,6 +116,109 @@ def test_starlette_excluded_urls(self):
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 0)

def test_starlette_metrics(self):
self._client.get("/foobar")
self._client.get("/foobar")
self._client.get("/foobar")
metrics_list = self.memory_metrics_reader.get_metrics_data()
number_data_point_seen = False
histogram_data_point_seen = False
self.assertTrue(len(metrics_list.resource_metrics) == 1)
for resource_metric in metrics_list.resource_metrics:
self.assertTrue(len(resource_metric.scope_metrics) == 1)
for scope_metric in resource_metric.scope_metrics:
self.assertTrue(len(scope_metric.metrics) == 2)
for metric in scope_metric.metrics:
self.assertIn(metric.name, _expected_metric_names)
data_points = list(metric.data.data_points)
self.assertEqual(len(data_points), 1)
for point in data_points:
if isinstance(point, HistogramDataPoint):
self.assertEqual(point.count, 3)
histogram_data_point_seen = True
if isinstance(point, NumberDataPoint):
number_data_point_seen = True
for attr in point.attributes:
self.assertIn(
attr, _recommended_attrs[metric.name]
)
self.assertTrue(number_data_point_seen and histogram_data_point_seen)

def test_basic_post_request_metric_success(self):
start = default_timer()
rahulmukherjee68 marked this conversation as resolved.
Show resolved Hide resolved
expected_duration_attributes = {
"http.flavor": "1.1",
"http.host": "testserver",
"http.method": "POST",
"http.scheme": "http",
"http.server_name": "testserver",
"http.status_code": 405,
"net.host.port": 80,
}
expected_requests_count_attributes = {
"http.flavor": "1.1",
"http.host": "testserver",
"http.method": "POST",
"http.scheme": "http",
"http.server_name": "testserver",
}
self._client.post("/foobar")
duration = max(round((default_timer() - start) * 1000), 0)
metrics_list = self.memory_metrics_reader.get_metrics_data()
for metric in (
metrics_list.resource_metrics[0].scope_metrics[0].metrics
):
for point in list(metric.data.data_points):
if isinstance(point, HistogramDataPoint):
self.assertEqual(point.count, 1)
self.assertAlmostEqual(duration, point.sum, delta=30)
self.assertDictEqual(
dict(point.attributes), expected_duration_attributes
)
if isinstance(point, NumberDataPoint):
self.assertDictEqual(
expected_requests_count_attributes,
dict(point.attributes),
)
self.assertEqual(point.value, 0)

def test_metric_for_uninstrment_app_method(self):
self._client.get("/foobar")
# uninstrumenting the existing client app
self._instrumentor.uninstrument_app(self._app)
self._client.get("/foobar")
self._client.get("/foobar")
metrics_list = self.memory_metrics_reader.get_metrics_data()
for metric in (
metrics_list.resource_metrics[0].scope_metrics[0].metrics
):
for point in list(metric.data.data_points):
if isinstance(point, HistogramDataPoint):
self.assertEqual(point.count, 1)
if isinstance(point, NumberDataPoint):
self.assertEqual(point.value, 0)

def test_metric_uninstrument_inherited_by_base(self):
# instrumenting class and creating app to send request
self._instrumentor.instrument()
app = self._create_starlette_app()
client = TestClient(app)
client.get("/foobar")
# calling uninstrument and checking for telemetry data
self._instrumentor.uninstrument()
client.get("/foobar")
client.get("/foobar")
client.get("/foobar")
metrics_list = self.memory_metrics_reader.get_metrics_data()
for metric in (
metrics_list.resource_metrics[0].scope_metrics[0].metrics
):
for point in list(metric.data.data_points):
if isinstance(point, HistogramDataPoint):
self.assertEqual(point.count, 1)
if isinstance(point, NumberDataPoint):
self.assertEqual(point.value, 0)

@staticmethod
def _create_starlette_app():
def home(_):
Expand Down