Skip to content

Commit 6007e0c

Browse files
Merge pull request from GHSA-5rv5-6h4r-h22v
* Fix unbound cardinality for label http_method in wsgi based middlewares * cr: rename file * cr: change label UNKNOWN to NONSTANDARD * Update instrumentation/opentelemetry-instrumentation-wsgi/src/opentelemetry/instrumentation/wsgi/__init__.py --------- Co-authored-by: Diego Hurtado <[email protected]>
1 parent 1beab82 commit 6007e0c

File tree

5 files changed

+162
-19
lines changed

5 files changed

+162
-19
lines changed

instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
4141
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
4242
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
43+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
44+
OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS,
4345
get_excluded_urls,
4446
)
4547

@@ -326,6 +328,25 @@ def test_flask_metric_values(self):
326328
if isinstance(point, NumberDataPoint):
327329
self.assertEqual(point.value, 0)
328330

331+
def _assert_basic_metric(self, expected_duration_attributes, expected_requests_count_attributes):
332+
metrics_list = self.memory_metrics_reader.get_metrics_data()
333+
for resource_metric in metrics_list.resource_metrics:
334+
for scope_metrics in resource_metric.scope_metrics:
335+
for metric in scope_metrics.metrics:
336+
for point in list(metric.data.data_points):
337+
if isinstance(point, HistogramDataPoint):
338+
self.assertDictEqual(
339+
expected_duration_attributes,
340+
dict(point.attributes),
341+
)
342+
self.assertEqual(point.count, 1)
343+
elif isinstance(point, NumberDataPoint):
344+
self.assertDictEqual(
345+
expected_requests_count_attributes,
346+
dict(point.attributes),
347+
)
348+
self.assertEqual(point.value, 0)
349+
329350
def test_basic_metric_success(self):
330351
self.client.get("/hello/756")
331352
expected_duration_attributes = {
@@ -344,23 +365,62 @@ def test_basic_metric_success(self):
344365
"http.flavor": "1.1",
345366
"http.server_name": "localhost",
346367
}
347-
metrics_list = self.memory_metrics_reader.get_metrics_data()
348-
for resource_metric in metrics_list.resource_metrics:
349-
for scope_metrics in resource_metric.scope_metrics:
350-
for metric in scope_metrics.metrics:
351-
for point in list(metric.data.data_points):
352-
if isinstance(point, HistogramDataPoint):
353-
self.assertDictEqual(
354-
expected_duration_attributes,
355-
dict(point.attributes),
356-
)
357-
self.assertEqual(point.count, 1)
358-
elif isinstance(point, NumberDataPoint):
359-
self.assertDictEqual(
360-
expected_requests_count_attributes,
361-
dict(point.attributes),
362-
)
363-
self.assertEqual(point.value, 0)
368+
self._assert_basic_metric(
369+
expected_duration_attributes,
370+
expected_requests_count_attributes,
371+
)
372+
373+
def test_basic_metric_nonstandard_http_method_success(self):
374+
self.client.open("/hello/756", method="NONSTANDARD")
375+
expected_duration_attributes = {
376+
"http.method": "UNKNOWN",
377+
"http.host": "localhost",
378+
"http.scheme": "http",
379+
"http.flavor": "1.1",
380+
"http.server_name": "localhost",
381+
"net.host.port": 80,
382+
"http.status_code": 405,
383+
}
384+
expected_requests_count_attributes = {
385+
"http.method": "UNKNOWN",
386+
"http.host": "localhost",
387+
"http.scheme": "http",
388+
"http.flavor": "1.1",
389+
"http.server_name": "localhost",
390+
}
391+
self._assert_basic_metric(
392+
expected_duration_attributes,
393+
expected_requests_count_attributes,
394+
)
395+
396+
@patch.dict(
397+
"os.environ",
398+
{
399+
OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS: "1",
400+
},
401+
)
402+
def test_basic_metric_nonstandard_http_method_allowed_success(self):
403+
self.client.open("/hello/756", method="NONSTANDARD")
404+
expected_duration_attributes = {
405+
"http.method": "NONSTANDARD",
406+
"http.host": "localhost",
407+
"http.scheme": "http",
408+
"http.flavor": "1.1",
409+
"http.server_name": "localhost",
410+
"net.host.port": 80,
411+
"http.status_code": 405,
412+
}
413+
expected_requests_count_attributes = {
414+
"http.method": "NONSTANDARD",
415+
"http.host": "localhost",
416+
"http.scheme": "http",
417+
"http.flavor": "1.1",
418+
"http.server_name": "localhost",
419+
}
420+
self._assert_basic_metric(
421+
expected_duration_attributes,
422+
expected_requests_count_attributes,
423+
)
364424

365425
def test_metric_uninstrument(self):
366426
self.client.delete("/hello/756")

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,12 @@ def response_hook(span: Span, environ: WSGIEnvironment, status: str, response_he
197197
Note:
198198
The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change.
199199
200+
Sanitizing methods
201+
******************
202+
In order to prevent unbound cardinality for HTTP methods by default nonstandard ones are labeled as ``NONSTANDARD``.
203+
To record all of the names set the environment variable ``OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS``
204+
to a value that evaluates to true, e.g. ``1``.
205+
200206
API
201207
---
202208
"""
@@ -226,6 +232,7 @@ def response_hook(span: Span, environ: WSGIEnvironment, status: str, response_he
226232
normalise_request_header_name,
227233
normalise_response_header_name,
228234
remove_url_credentials,
235+
sanitize_method,
229236
)
230237

231238
_HTTP_VERSION_PREFIX = "HTTP/"
@@ -295,7 +302,7 @@ def collect_request_attributes(environ):
295302
"""
296303

297304
result = {
298-
SpanAttributes.HTTP_METHOD: environ.get("REQUEST_METHOD"),
305+
SpanAttributes.HTTP_METHOD: sanitize_method(environ.get("REQUEST_METHOD")),
299306
SpanAttributes.HTTP_SERVER_NAME: environ.get("SERVER_NAME"),
300307
SpanAttributes.HTTP_SCHEME: environ.get("wsgi.url_scheme"),
301308
}
@@ -450,7 +457,7 @@ def get_default_span_name(environ):
450457
Returns:
451458
The span name.
452459
"""
453-
method = environ.get("REQUEST_METHOD", "").strip()
460+
method = sanitize_method(environ.get("REQUEST_METHOD", "").strip())
454461
path = environ.get("PATH_INFO", "").strip()
455462
if method and path:
456463
return f"{method} {path}"

instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
3434
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
3535
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
36+
OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS,
3637
)
3738

3839

@@ -284,6 +285,24 @@ def test_wsgi_metrics(self):
284285
)
285286
self.assertTrue(number_data_point_seen and histogram_data_point_seen)
286287

288+
def test_nonstandard_http_method(self):
289+
self.environ["REQUEST_METHOD"]= "NONSTANDARD"
290+
app = otel_wsgi.OpenTelemetryMiddleware(simple_wsgi)
291+
response = app(self.environ, self.start_response)
292+
self.validate_response(response, span_name="HTTP UNKNOWN", http_method="UNKNOWN")
293+
294+
@mock.patch.dict(
295+
"os.environ",
296+
{
297+
OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS: "1",
298+
},
299+
)
300+
def test_nonstandard_http_method_allowed(self):
301+
self.environ["REQUEST_METHOD"]= "NONSTANDARD"
302+
app = otel_wsgi.OpenTelemetryMiddleware(simple_wsgi)
303+
response = app(self.environ, self.start_response)
304+
self.validate_response(response, span_name="HTTP NONSTANDARD", http_method="NONSTANDARD")
305+
287306
def test_default_span_name_missing_path_info(self):
288307
"""Test that default span_names with missing path info."""
289308
self.environ.pop("PATH_INFO")

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
"OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE"
3232
)
3333

34+
OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS = (
35+
"OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS"
36+
)
37+
3438
# List of recommended metrics attributes
3539
_duration_attrs = {
3640
SpanAttributes.HTTP_METHOD,
@@ -186,6 +190,15 @@ def normalise_response_header_name(header: str) -> str:
186190
key = header.lower().replace("-", "_")
187191
return f"http.response.header.{key}"
188192

193+
def sanitize_method(method: str | None) -> str | None:
194+
if method is None:
195+
return None
196+
method = method.upper()
197+
if (environ.get(OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS) or
198+
# Based on https://www.rfc-editor.org/rfc/rfc7231#section-4.1 and https://www.rfc-editor.org/rfc/rfc5789#section-2.
199+
method in ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"]):
200+
return method
201+
return "NONSTANDARD"
189202

190203
def get_custom_headers(env_var: str) -> List[str]:
191204
custom_headers = environ.get(env_var, [])
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import unittest
16+
from unittest.mock import patch
17+
18+
from opentelemetry.util.http import (
19+
OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS,
20+
sanitize_method,
21+
)
22+
23+
class TestSanitizeMethod(unittest.TestCase):
24+
def test_standard_method_uppercase(self):
25+
method = sanitize_method("GET")
26+
self.assertEqual(method, "GET")
27+
28+
def test_standard_method_lowercase(self):
29+
method = sanitize_method("get")
30+
self.assertEqual(method, "GET")
31+
32+
def test_nonstandard_method(self):
33+
method = sanitize_method("UNKNOWN")
34+
self.assertEqual(method, "NONSTANDARD")
35+
36+
@patch.dict(
37+
"os.environ",
38+
{
39+
OTEL_PYTHON_INSTRUMENTATION_HTTP_CAPTURE_ALL_METHODS: "1",
40+
},
41+
)
42+
def test_nonstandard_method_allowed(self):
43+
method = sanitize_method("UNKNOWN")
44+
self.assertEqual(method, "NONSTANDARD")

0 commit comments

Comments
 (0)