Skip to content

Commit fee9926

Browse files
authored
Metric instrumentation pyramid (#1242)
1 parent 318a3a3 commit fee9926

File tree

3 files changed

+134
-2
lines changed

3 files changed

+134
-2
lines changed

CHANGELOG.md

+2
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 Pyramid
14+
([#1242](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1242))
1315

1416
### Fixed
1517

instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py

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

1515
from logging import getLogger
1616
from time import time_ns
17+
from timeit import default_timer
1718

1819
from pyramid.events import BeforeTraversal
1920
from pyramid.httpexceptions import HTTPException, HTTPServerError
@@ -27,6 +28,7 @@
2728
)
2829
from opentelemetry.instrumentation.pyramid.version import __version__
2930
from opentelemetry.instrumentation.utils import _start_internal_or_server_span
31+
from opentelemetry.metrics import get_meter
3032
from opentelemetry.semconv.trace import SpanAttributes
3133
from opentelemetry.util.http import get_excluded_urls
3234

@@ -122,8 +124,20 @@ def _before_traversal(event):
122124

123125

124126
def trace_tween_factory(handler, registry):
127+
# pylint: disable=too-many-statements
125128
settings = registry.settings
126129
enabled = asbool(settings.get(SETTING_TRACE_ENABLED, True))
130+
meter = get_meter(__name__, __version__)
131+
duration_histogram = meter.create_histogram(
132+
name="http.server.duration",
133+
unit="ms",
134+
description="measures the duration of the inbound HTTP request",
135+
)
136+
active_requests_counter = meter.create_up_down_counter(
137+
name="http.server.active_requests",
138+
unit="requests",
139+
description="measures the number of concurrent HTTP requests that are currently in-flight",
140+
)
127141

128142
if not enabled:
129143
# If disabled, make a tween that signals to the
@@ -137,14 +151,23 @@ def disabled_tween(request):
137151
# make a request tracing function
138152
# pylint: disable=too-many-branches
139153
def trace_tween(request):
140-
# pylint: disable=E1101
154+
# pylint: disable=E1101, too-many-locals
141155
if _excluded_urls.url_disabled(request.url):
142156
request.environ[_ENVIRON_ENABLED_KEY] = False
143157
# short-circuit when we don't want to trace anything
144158
return handler(request)
145159

160+
attributes = otel_wsgi.collect_request_attributes(request.environ)
161+
146162
request.environ[_ENVIRON_ENABLED_KEY] = True
147163
request.environ[_ENVIRON_STARTTIME_KEY] = time_ns()
164+
active_requests_count_attrs = (
165+
otel_wsgi._parse_active_request_count_attrs(attributes)
166+
)
167+
duration_attrs = otel_wsgi._parse_duration_attrs(attributes)
168+
169+
start = default_timer()
170+
active_requests_counter.add(1, active_requests_count_attrs)
148171

149172
response = None
150173
status = None
@@ -165,6 +188,15 @@ def trace_tween(request):
165188
status = "500 InternalServerError"
166189
raise
167190
finally:
191+
duration = max(round((default_timer() - start) * 1000), 0)
192+
status = getattr(response, "status", status)
193+
status_code = otel_wsgi._parse_status_code(status)
194+
if status_code is not None:
195+
duration_attrs[
196+
SpanAttributes.HTTP_STATUS_CODE
197+
] = otel_wsgi._parse_status_code(status)
198+
duration_histogram.record(duration, duration_attrs)
199+
active_requests_counter.add(-1, active_requests_count_attrs)
168200
span = request.environ.get(_ENVIRON_SPAN_KEY)
169201
enabled = request.environ.get(_ENVIRON_ENABLED_KEY)
170202
if not span and enabled:
@@ -174,7 +206,6 @@ def trace_tween(request):
174206
"PyramidInstrumentor().instrument_config(config) is called"
175207
)
176208
elif enabled:
177-
status = getattr(response, "status", status)
178209

179210
if status is not None:
180211
otel_wsgi.add_response_attributes(

instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py

+99
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,40 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from timeit import default_timer
1516
from unittest.mock import patch
1617

1718
from pyramid.config import Configurator
1819

1920
from opentelemetry import trace
2021
from opentelemetry.instrumentation.pyramid import PyramidInstrumentor
22+
from opentelemetry.sdk.metrics.export import (
23+
HistogramDataPoint,
24+
NumberDataPoint,
25+
)
2126
from opentelemetry.test.globals_test import reset_trace_globals
2227
from opentelemetry.test.wsgitestutil import WsgiTestBase
2328
from opentelemetry.trace import SpanKind
2429
from opentelemetry.trace.status import StatusCode
2530
from opentelemetry.util.http import (
2631
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
2732
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
33+
_active_requests_count_attrs,
34+
_duration_attrs,
2835
)
2936

3037
# pylint: disable=import-error
3138
from .pyramid_base_test import InstrumentationTest
3239

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

3450
class TestAutomatic(InstrumentationTest, WsgiTestBase):
3551
def setUp(self):
@@ -156,6 +172,89 @@ def test_400s_response_is_not_an_error(self):
156172
span_list = self.memory_exporter.get_finished_spans()
157173
self.assertEqual(len(span_list), 1)
158174

175+
def test_pyramid_metric(self):
176+
self.client.get("/hello/756")
177+
self.client.get("/hello/756")
178+
self.client.get("/hello/756")
179+
metrics_list = self.memory_metrics_reader.get_metrics_data()
180+
number_data_point_seen = False
181+
histogram_data_point_seen = False
182+
self.assertTrue(len(metrics_list.resource_metrics) == 1)
183+
for resource_metric in metrics_list.resource_metrics:
184+
self.assertTrue(len(resource_metric.scope_metrics) == 1)
185+
for scope_metric in resource_metric.scope_metrics:
186+
self.assertTrue(len(scope_metric.metrics) == 2)
187+
for metric in scope_metric.metrics:
188+
self.assertIn(metric.name, _expected_metric_names)
189+
data_points = list(metric.data.data_points)
190+
self.assertEqual(len(data_points), 1)
191+
for point in data_points:
192+
if isinstance(point, HistogramDataPoint):
193+
self.assertEqual(point.count, 3)
194+
histogram_data_point_seen = True
195+
if isinstance(point, NumberDataPoint):
196+
number_data_point_seen = True
197+
for attr in point.attributes:
198+
self.assertIn(
199+
attr, _recommended_attrs[metric.name]
200+
)
201+
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
202+
203+
def test_basic_metric_success(self):
204+
start = default_timer()
205+
self.client.get("/hello/756")
206+
duration = max(round((default_timer() - start) * 1000), 0)
207+
expected_duration_attributes = {
208+
"http.method": "GET",
209+
"http.host": "localhost",
210+
"http.scheme": "http",
211+
"http.flavor": "1.1",
212+
"http.server_name": "localhost",
213+
"net.host.port": 80,
214+
"http.status_code": 200,
215+
}
216+
expected_requests_count_attributes = {
217+
"http.method": "GET",
218+
"http.host": "localhost",
219+
"http.scheme": "http",
220+
"http.flavor": "1.1",
221+
"http.server_name": "localhost",
222+
}
223+
metrics_list = self.memory_metrics_reader.get_metrics_data()
224+
for metric in (
225+
metrics_list.resource_metrics[0].scope_metrics[0].metrics
226+
):
227+
for point in list(metric.data.data_points):
228+
if isinstance(point, HistogramDataPoint):
229+
self.assertDictEqual(
230+
expected_duration_attributes,
231+
dict(point.attributes),
232+
)
233+
self.assertEqual(point.count, 1)
234+
self.assertAlmostEqual(duration, point.sum, delta=20)
235+
if isinstance(point, NumberDataPoint):
236+
self.assertDictEqual(
237+
expected_requests_count_attributes,
238+
dict(point.attributes),
239+
)
240+
self.assertEqual(point.value, 0)
241+
242+
def test_metric_uninstruemnt(self):
243+
self.client.get("/hello/756")
244+
PyramidInstrumentor().uninstrument()
245+
self.config = Configurator()
246+
self._common_initialization(self.config)
247+
self.client.get("/hello/756")
248+
metrics_list = self.memory_metrics_reader.get_metrics_data()
249+
for metric in (
250+
metrics_list.resource_metrics[0].scope_metrics[0].metrics
251+
):
252+
for point in list(metric.data.data_points):
253+
if isinstance(point, HistogramDataPoint):
254+
self.assertEqual(point.count, 1)
255+
if isinstance(point, NumberDataPoint):
256+
self.assertEqual(point.value, 0)
257+
159258

160259
class TestWrappedWithOtherFramework(InstrumentationTest, WsgiTestBase):
161260
def setUp(self):

0 commit comments

Comments
 (0)