Ensure clean http url (#538)

This commit is contained in:
Ryo Kather
2021-06-11 09:01:52 -07:00
committed by GitHub
parent e347fa7541
commit 865837f757
21 changed files with 155 additions and 9 deletions

View File

@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#530](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/530)) ([#530](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/530))
- Fix weak reference error for pyodbc cursor in SQLAlchemy instrumentation. - Fix weak reference error for pyodbc cursor in SQLAlchemy instrumentation.
([#469](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/469)) ([#469](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/469))
- Implemented specification that HTTP span attributes must not contain username and password.
([#538](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/538))
### Added ### Added
- `opentelemetry-instrumentation-httpx` Add `httpx` instrumentation - `opentelemetry-instrumentation-httpx` Add `httpx` instrumentation

View File

@ -41,6 +41,7 @@ install_requires =
opentelemetry-api == 1.4.0.dev0 opentelemetry-api == 1.4.0.dev0
opentelemetry-semantic-conventions == 0.23.dev0 opentelemetry-semantic-conventions == 0.23.dev0
opentelemetry-instrumentation == 0.23.dev0 opentelemetry-instrumentation == 0.23.dev0
opentelemetry-util-http == 0.23.dev0
wrapt >= 1.0.0, < 2.0.0 wrapt >= 1.0.0, < 2.0.0
[options.packages.find] [options.packages.find]

View File

@ -82,6 +82,7 @@ from opentelemetry.propagate import inject
from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace import SpanKind, TracerProvider, get_tracer from opentelemetry.trace import SpanKind, TracerProvider, get_tracer
from opentelemetry.trace.status import Status, StatusCode from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials
_UrlFilterT = typing.Optional[typing.Callable[[str], str]] _UrlFilterT = typing.Optional[typing.Callable[[str], str]]
_SpanNameT = typing.Optional[ _SpanNameT = typing.Optional[
@ -173,11 +174,11 @@ def create_trace_config(
if trace_config_ctx.span.is_recording(): if trace_config_ctx.span.is_recording():
attributes = { attributes = {
SpanAttributes.HTTP_METHOD: http_method, SpanAttributes.HTTP_METHOD: http_method,
SpanAttributes.HTTP_URL: trace_config_ctx.url_filter( SpanAttributes.HTTP_URL: remove_url_credentials(
params.url trace_config_ctx.url_filter(params.url)
) )
if callable(trace_config_ctx.url_filter) if callable(trace_config_ctx.url_filter)
else str(params.url), else remove_url_credentials(str(params.url)),
} }
for key, value in attributes.items(): for key, value in attributes.items():
trace_config_ctx.span.set_attribute(key, value) trace_config_ctx.span.set_attribute(key, value)

View File

@ -321,6 +321,37 @@ class TestAioHttpIntegration(TestBase):
] ]
) )
def test_credential_removal(self):
trace_configs = [aiohttp_client.create_trace_config()]
url = "http://username:password@httpbin.org/status/200"
with self.subTest(url=url):
async def do_request(url):
async with aiohttp.ClientSession(
trace_configs=trace_configs,
) as session:
async with session.get(url):
pass
loop = asyncio.get_event_loop()
loop.run_until_complete(do_request(url))
self.assert_spans(
[
(
"HTTP GET",
(StatusCode.UNSET, None),
{
SpanAttributes.HTTP_METHOD: "GET",
SpanAttributes.HTTP_URL: "http://httpbin.org/status/200",
SpanAttributes.HTTP_STATUS_CODE: int(HTTPStatus.OK),
},
)
]
)
self.memory_exporter.clear()
class TestAioHttpClientInstrumentor(TestBase): class TestAioHttpClientInstrumentor(TestBase):
URL = "/test-path" URL = "/test-path"

View File

@ -41,6 +41,7 @@ install_requires =
opentelemetry-api == 1.4.0.dev0 opentelemetry-api == 1.4.0.dev0
opentelemetry-semantic-conventions == 0.23.dev0 opentelemetry-semantic-conventions == 0.23.dev0
opentelemetry-instrumentation == 0.23.dev0 opentelemetry-instrumentation == 0.23.dev0
opentelemetry-util-http == 0.23.dev0
[options.extras_require] [options.extras_require]
test = test =

View File

@ -32,6 +32,7 @@ from opentelemetry.propagate import extract
from opentelemetry.propagators.textmap import Getter from opentelemetry.propagators.textmap import Getter
from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace.status import Status, StatusCode from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials
class ASGIGetter(Getter): class ASGIGetter(Getter):
@ -86,7 +87,7 @@ def collect_request_attributes(scope):
SpanAttributes.NET_HOST_PORT: port, SpanAttributes.NET_HOST_PORT: port,
SpanAttributes.HTTP_FLAVOR: scope.get("http_version"), SpanAttributes.HTTP_FLAVOR: scope.get("http_version"),
SpanAttributes.HTTP_TARGET: scope.get("path"), SpanAttributes.HTTP_TARGET: scope.get("path"),
SpanAttributes.HTTP_URL: http_url, SpanAttributes.HTTP_URL: remove_url_credentials(http_url),
} }
http_method = scope.get("method") http_method = scope.get("method")
if http_method: if http_method:

View File

@ -430,6 +430,14 @@ class TestAsgiAttributes(unittest.TestCase):
otel_asgi.set_status_code(self.span, "Invalid Status Code") otel_asgi.set_status_code(self.span, "Invalid Status Code")
self.assertEqual(self.span.set_status.call_count, 1) self.assertEqual(self.span.set_status.call_count, 1)
def test_credential_removal(self):
self.scope["server"] = ("username:password@httpbin.org", 80)
self.scope["path"] = "/status/200"
attrs = otel_asgi.collect_request_attributes(self.scope)
self.assertEqual(
attrs[SpanAttributes.HTTP_URL], "http://httpbin.org/status/200"
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -41,6 +41,7 @@ install_requires =
opentelemetry-api == 1.4.0.dev0 opentelemetry-api == 1.4.0.dev0
opentelemetry-semantic-conventions == 0.23.dev0 opentelemetry-semantic-conventions == 0.23.dev0
opentelemetry-instrumentation == 0.23.dev0 opentelemetry-instrumentation == 0.23.dev0
opentelemetry-util-http == 0.23.dev0
[options.extras_require] [options.extras_require]
test = test =

View File

@ -50,6 +50,7 @@ from opentelemetry.propagate import inject
from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace import SpanKind, get_tracer from opentelemetry.trace import SpanKind, get_tracer
from opentelemetry.trace.status import Status from opentelemetry.trace.status import Status
from opentelemetry.util.http import remove_url_credentials
# A key to a context variable to avoid creating duplicate spans when instrumenting # A key to a context variable to avoid creating duplicate spans when instrumenting
# both, Session.request and Session.send, since Session.request calls into Session.send # both, Session.request and Session.send, since Session.request calls into Session.send
@ -124,6 +125,8 @@ def _instrument(tracer, span_callback=None, name_callback=None):
if not span_name or not isinstance(span_name, str): if not span_name or not isinstance(span_name, str):
span_name = get_default_span_name(method) span_name = get_default_span_name(method)
url = remove_url_credentials(url)
labels = {} labels = {}
labels[SpanAttributes.HTTP_METHOD] = method labels[SpanAttributes.HTTP_METHOD] = method
labels[SpanAttributes.HTTP_URL] = url labels[SpanAttributes.HTTP_URL] = url

View File

@ -357,6 +357,13 @@ class TestRequestsIntegration(RequestsIntegrationTestBase, TestBase):
) )
self.assertEqual(span.status.status_code, StatusCode.ERROR) self.assertEqual(span.status.status_code, StatusCode.ERROR)
def test_credential_removal(self):
new_url = "http://username:password@httpbin.org/status/200"
self.perform_request(new_url)
span = self.assert_span()
self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL)
def test_if_headers_equals_none(self): def test_if_headers_equals_none(self):
result = requests.get(self.URL, headers=None) result = requests.get(self.URL, headers=None)
self.assertEqual(result.text, "Hello!") self.assertEqual(result.text, "Hello!")

View File

@ -22,6 +22,7 @@ from opentelemetry.propagate import inject
from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace.status import Status from opentelemetry.trace.status import Status
from opentelemetry.util._time import _time_ns from opentelemetry.util._time import _time_ns
from opentelemetry.util.http import remove_url_credentials
def _normalize_request(args, kwargs): def _normalize_request(args, kwargs):
@ -61,7 +62,7 @@ def fetch_async(tracer, request_hook, response_hook, func, _, args, kwargs):
if span.is_recording(): if span.is_recording():
attributes = { attributes = {
SpanAttributes.HTTP_URL: request.url, SpanAttributes.HTTP_URL: remove_url_credentials(request.url),
SpanAttributes.HTTP_METHOD: request.method, SpanAttributes.HTTP_METHOD: request.method,
} }
for key, value in attributes.items(): for key, value in attributes.items():

View File

@ -455,6 +455,29 @@ class TestTornadoInstrumentation(TornadoTest):
self.memory_exporter.clear() self.memory_exporter.clear()
set_global_response_propagator(orig) set_global_response_propagator(orig)
def test_credential_removal(self):
response = self.fetch(
"http://username:password@httpbin.org/status/200"
)
self.assertEqual(response.code, 200)
spans = self.sorted_spans(self.memory_exporter.get_finished_spans())
self.assertEqual(len(spans), 1)
client = spans[0]
self.assertEqual(client.name, "GET")
self.assertEqual(client.kind, SpanKind.CLIENT)
self.assert_span_has_attributes(
client,
{
SpanAttributes.HTTP_URL: "http://httpbin.org/status/200",
SpanAttributes.HTTP_METHOD: "GET",
SpanAttributes.HTTP_STATUS_CODE: 200,
},
)
self.memory_exporter.clear()
class TornadoHookTest(TornadoTest): class TornadoHookTest(TornadoTest):
_client_request_hook = None _client_request_hook = None

View File

@ -41,6 +41,7 @@ install_requires =
opentelemetry-api == 1.4.0.dev0 opentelemetry-api == 1.4.0.dev0
opentelemetry-semantic-conventions == 0.23.dev0 opentelemetry-semantic-conventions == 0.23.dev0
opentelemetry-instrumentation == 0.23.dev0 opentelemetry-instrumentation == 0.23.dev0
opentelemetry-util-http == 0.23.dev0
[options.extras_require] [options.extras_require]
test = test =

View File

@ -53,6 +53,7 @@ from opentelemetry.propagate import inject
from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace import SpanKind, get_tracer from opentelemetry.trace import SpanKind, get_tracer
from opentelemetry.trace.status import Status from opentelemetry.trace.status import Status
from opentelemetry.util.http import remove_url_credentials
# A key to a context variable to avoid creating duplicate spans when instrumenting # A key to a context variable to avoid creating duplicate spans when instrumenting
_SUPPRESS_HTTP_INSTRUMENTATION_KEY = "suppress_http_instrumentation" _SUPPRESS_HTTP_INSTRUMENTATION_KEY = "suppress_http_instrumentation"
@ -142,6 +143,8 @@ def _instrument(tracer, span_callback=None, name_callback=None):
if not span_name or not isinstance(span_name, str): if not span_name or not isinstance(span_name, str):
span_name = get_default_span_name(method) span_name = get_default_span_name(method)
url = remove_url_credentials(url)
labels = { labels = {
SpanAttributes.HTTP_METHOD: method, SpanAttributes.HTTP_METHOD: method,
SpanAttributes.HTTP_URL: url, SpanAttributes.HTTP_URL: url,

View File

@ -35,6 +35,8 @@ from opentelemetry.test.mock_textmap import MockTextMapPropagator
from opentelemetry.test.test_base import TestBase from opentelemetry.test.test_base import TestBase
from opentelemetry.trace import StatusCode from opentelemetry.trace import StatusCode
# pylint: disable=too-many-public-methods
class RequestsIntegrationTestBase(abc.ABC): class RequestsIntegrationTestBase(abc.ABC):
# pylint: disable=no-member # pylint: disable=no-member
@ -318,6 +320,15 @@ class RequestsIntegrationTestBase(abc.ABC):
span = self.assert_span() span = self.assert_span()
self.assertEqual(span.status.status_code, StatusCode.ERROR) self.assertEqual(span.status.status_code, StatusCode.ERROR)
def test_credential_removal(self):
url = "http://username:password@httpbin.org/status/200"
with self.assertRaises(Exception):
self.perform_request(url)
span = self.assert_span()
self.assertEqual(span.attributes[SpanAttributes.HTTP_URL], self.URL)
class TestRequestsIntegration(RequestsIntegrationTestBase, TestBase): class TestRequestsIntegration(RequestsIntegrationTestBase, TestBase):
@staticmethod @staticmethod

View File

@ -287,3 +287,9 @@ class TestURLLib3Instrumentor(TestBase):
response = self.perform_request(self.HTTP_URL + "?e=mcc") response = self.perform_request(self.HTTP_URL + "?e=mcc")
self.assert_success_span(response, self.HTTP_URL) self.assert_success_span(response, self.HTTP_URL)
def test_credential_removal(self):
url = "http://username:password@httpbin.org/status/200"
response = self.perform_request(url)
self.assert_success_span(response, self.HTTP_URL)

View File

@ -41,6 +41,7 @@ install_requires =
opentelemetry-api == 1.4.0.dev0 opentelemetry-api == 1.4.0.dev0
opentelemetry-semantic-conventions == 0.23.dev0 opentelemetry-semantic-conventions == 0.23.dev0
opentelemetry-instrumentation == 0.23.dev0 opentelemetry-instrumentation == 0.23.dev0
opentelemetry-util-http == 0.23.dev0
[options.extras_require] [options.extras_require]
test = test =

View File

@ -65,6 +65,7 @@ from opentelemetry.propagate import extract
from opentelemetry.propagators.textmap import Getter from opentelemetry.propagators.textmap import Getter
from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace.status import Status, StatusCode from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import remove_url_credentials
_HTTP_VERSION_PREFIX = "HTTP/" _HTTP_VERSION_PREFIX = "HTTP/"
_CARRIER_KEY_PREFIX = "HTTP_" _CARRIER_KEY_PREFIX = "HTTP_"
@ -128,7 +129,9 @@ def collect_request_attributes(environ):
if target is not None: if target is not None:
result[SpanAttributes.HTTP_TARGET] = target result[SpanAttributes.HTTP_TARGET] = target
else: else:
result[SpanAttributes.HTTP_URL] = wsgiref_util.request_uri(environ) result[SpanAttributes.HTTP_URL] = remove_url_credentials(
wsgiref_util.request_uri(environ)
)
remote_addr = environ.get("REMOTE_ADDR") remote_addr = environ.get("REMOTE_ADDR")
if remote_addr: if remote_addr:

View File

@ -364,6 +364,18 @@ class TestWsgiAttributes(unittest.TestCase):
self.assertEqual(self.span.set_attribute.call_count, len(expected)) self.assertEqual(self.span.set_attribute.call_count, len(expected))
self.span.set_attribute.assert_has_calls(expected, any_order=True) self.span.set_attribute.assert_has_calls(expected, any_order=True)
def test_credential_removal(self):
self.environ["HTTP_HOST"] = "username:password@httpbin.com"
self.environ["PATH_INFO"] = "/status/200"
expected = {
SpanAttributes.HTTP_URL: "http://httpbin.com/status/200",
SpanAttributes.NET_HOST_PORT: 80,
}
self.assertGreaterEqual(
otel_wsgi.collect_request_attributes(self.environ).items(),
expected.items(),
)
class TestWsgiMiddlewareWithTracerProvider(WsgiTestBase): class TestWsgiMiddlewareWithTracerProvider(WsgiTestBase):
def validate_response( def validate_response(

View File

@ -237,7 +237,7 @@ commands_pre =
grpc: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-grpc[test] grpc: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-grpc[test]
falcon,flask,django,pyramid,tornado,starlette,fastapi: pip install {toxinidir}/util/opentelemetry-util-http[test] falcon,flask,django,pyramid,tornado,starlette,fastapi,aiohttp,asgi,requests,urllib,wsgi: pip install {toxinidir}/util/opentelemetry-util-http[test]
wsgi,falcon,flask,django,pyramid: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-wsgi[test] wsgi,falcon,flask,django,pyramid: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-wsgi[test]
asgi,starlette,fastapi: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-asgi[test] asgi,starlette,fastapi: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-asgi[test]
@ -413,6 +413,7 @@ deps =
protobuf>=3.13.0 protobuf>=3.13.0
requests==2.25.0 requests==2.25.0
pyodbc~=4.0.30 pyodbc~=4.0.30
changedir = changedir =
tests/opentelemetry-docker-tests/tests tests/opentelemetry-docker-tests/tests
@ -435,13 +436,13 @@ commands_pre =
-e {toxinidir}/opentelemetry-python-core/exporter/opentelemetry-exporter-opencensus -e {toxinidir}/opentelemetry-python-core/exporter/opentelemetry-exporter-opencensus
docker-compose up -d docker-compose up -d
python check_availability.py python check_availability.py
commands = commands =
pytest {posargs} pytest {posargs}
commands_post = commands_post =
docker-compose down -v docker-compose down -v
[testenv:generate] [testenv:generate]
deps = deps =
-r {toxinidir}/gen-requirements.txt -r {toxinidir}/gen-requirements.txt

View File

@ -15,6 +15,7 @@
from os import environ from os import environ
from re import compile as re_compile from re import compile as re_compile
from re import search from re import search
from urllib.parse import urlparse, urlunparse
class ExcludeList: class ExcludeList:
@ -57,3 +58,30 @@ def get_excluded_urls(instrumentation):
] ]
return ExcludeList(excluded_urls) return ExcludeList(excluded_urls)
def remove_url_credentials(url: str) -> str:
"""Given a string url, remove the username and password only if it is a valid url"""
try:
parsed = urlparse(url)
if all([parsed.scheme, parsed.netloc]): # checks for valid url
parsed_url = urlparse(url)
netloc = (
(":".join(((parsed_url.hostname or ""), str(parsed_url.port))))
if parsed_url.port
else (parsed_url.hostname or "")
)
return urlunparse(
(
parsed_url.scheme,
netloc,
parsed_url.path,
parsed_url.params,
parsed_url.query,
parsed_url.fragment,
)
)
except ValueError: # an unparseable url was passed
pass
return url