fix(opentelemetry-instrumentation-asgi): Correct http.url attribute generation (#2477)

This commit is contained in:
dhofstetter
2024-07-03 19:28:54 +02:00
committed by GitHub
parent b16394b202
commit ef4bc9f9b4
6 changed files with 907 additions and 424 deletions

View File

@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#2612](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2612))
- `opentelemetry-instrumentation-system-metrics` Permit to use psutil 6.0+.
([#2630](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2630))
- `opentelemetry-instrumentation-asgi` Fix generation of `http.target` and `http.url` attributes for ASGI apps
using sub apps
([#2477](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2477))
### Added

View File

@ -367,7 +367,13 @@ def get_host_port_url_tuple(scope):
server = scope.get("server") or ["0.0.0.0", 80]
port = server[1]
server_host = server[0] + (":" + str(port) if str(port) != "80" else "")
full_path = scope.get("root_path", "") + scope.get("path", "")
# using the scope path is enough, see:
# - https://asgi.readthedocs.io/en/latest/specs/www.html#http-connection-scope (see: root_path and path)
# - https://asgi.readthedocs.io/en/latest/specs/www.html#wsgi-compatibility (see: PATH_INFO)
# PATH_INFO can be derived by stripping root_path from path
# -> that means that the path should contain the root_path already, so prefixing it again is not necessary
# - https://wsgi.readthedocs.io/en/latest/definitions.html#envvar-PATH_INFO
full_path = scope.get("path", "")
http_url = scope.get("scheme", "http") + "://" + server_host + full_path
return server_host, port, http_url

View File

@ -12,18 +12,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import unittest
from collections.abc import Mapping
from timeit import default_timer
from typing import Tuple
from unittest.mock import patch
import fastapi
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
import opentelemetry.instrumentation.fastapi as otel_fastapi
from opentelemetry import trace
from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware
from opentelemetry.sdk.metrics.export import (
HistogramDataPoint,
@ -31,12 +27,8 @@ from opentelemetry.sdk.metrics.export import (
)
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.test.globals_test import reset_trace_globals
from opentelemetry.test.test_base import TestBase
from opentelemetry.util.http import (
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
_active_requests_count_attrs,
_duration_attrs,
get_excluded_urls,
@ -62,7 +54,7 @@ _recommended_attrs = {
}
class TestFastAPIManualInstrumentation(TestBase):
class TestBaseFastAPI(TestBase):
def _create_app(self):
app = self._create_fastapi_app()
self._instrumentor.instrument_app(
@ -85,6 +77,15 @@ class TestFastAPIManualInstrumentation(TestBase):
)
return app
@classmethod
def setUpClass(cls):
if cls is TestBaseFastAPI:
raise unittest.SkipTest(
f"{cls.__name__} is an abstract base class"
)
super(TestBaseFastAPI, cls).setUpClass()
def setUp(self):
super().setUp()
self.env_patch = patch.dict(
@ -110,6 +111,155 @@ class TestFastAPIManualInstrumentation(TestBase):
self._instrumentor.uninstrument()
self._instrumentor.uninstrument_app(self._app)
@staticmethod
def _create_fastapi_app():
app = fastapi.FastAPI()
sub_app = fastapi.FastAPI()
@sub_app.get("/home")
async def _():
return {"message": "sub hi"}
@app.get("/foobar")
async def _():
return {"message": "hello world"}
@app.get("/user/{username}")
async def _(username: str):
return {"message": username}
@app.get("/exclude/{param}")
async def _(param: str):
return {"message": param}
@app.get("/healthzz")
async def _():
return {"message": "ok"}
app.mount("/sub", app=sub_app)
return app
class TestBaseManualFastAPI(TestBaseFastAPI):
@classmethod
def setUpClass(cls):
if cls is TestBaseManualFastAPI:
raise unittest.SkipTest(
f"{cls.__name__} is an abstract base class"
)
super(TestBaseManualFastAPI, cls).setUpClass()
def test_sub_app_fastapi_call(self):
"""
This test is to ensure that a span in case of a sub app targeted contains the correct server url
As this test case covers manual instrumentation, we won't see any additional spans for the sub app.
In this case all generated spans might suffice the requirements for the attributes already
(as the testcase is not setting a root_path for the outer app here)
"""
self._client.get("/sub/home")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 3)
for span in spans:
# As we are only looking to the "outer" app, we would see only the "GET /sub" spans
self.assertIn("GET /sub", span.name)
# We now want to specifically test all spans including the
# - HTTP_TARGET
# - HTTP_URL
# attributes to be populated with the expected values
spans_with_http_attributes = [
span
for span in spans
if (
SpanAttributes.HTTP_URL in span.attributes
or SpanAttributes.HTTP_TARGET in span.attributes
)
]
# We expect only one span to have the HTTP attributes set (the SERVER span from the app itself)
# the sub app is not instrumented with manual instrumentation tests.
self.assertEqual(1, len(spans_with_http_attributes))
for span in spans_with_http_attributes:
self.assertEqual(
"/sub/home", span.attributes[SpanAttributes.HTTP_TARGET]
)
self.assertEqual(
"https://testserver:443/sub/home",
span.attributes[SpanAttributes.HTTP_URL],
)
class TestBaseAutoFastAPI(TestBaseFastAPI):
@classmethod
def setUpClass(cls):
if cls is TestBaseAutoFastAPI:
raise unittest.SkipTest(
f"{cls.__name__} is an abstract base class"
)
super(TestBaseAutoFastAPI, cls).setUpClass()
def test_sub_app_fastapi_call(self):
"""
This test is to ensure that a span in case of a sub app targeted contains the correct server url
As this test case covers auto instrumentation, we will see additional spans for the sub app.
In this case all generated spans might suffice the requirements for the attributes already
(as the testcase is not setting a root_path for the outer app here)
"""
self._client.get("/sub/home")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 6)
for span in spans:
# As we are only looking to the "outer" app, we would see only the "GET /sub" spans
# -> the outer app is not aware of the sub_apps internal routes
sub_in = "GET /sub" in span.name
# The sub app spans are named GET /home as from the sub app perspective the request targets /home
# -> the sub app is technically not aware of the /sub prefix
home_in = "GET /home" in span.name
# We expect the spans to be either from the outer app or the sub app
self.assertTrue(
sub_in or home_in,
f"Span {span.name} does not have /sub or /home in its name",
)
# We now want to specifically test all spans including the
# - HTTP_TARGET
# - HTTP_URL
# attributes to be populated with the expected values
spans_with_http_attributes = [
span
for span in spans
if (
SpanAttributes.HTTP_URL in span.attributes
or SpanAttributes.HTTP_TARGET in span.attributes
)
]
# We now expect spans with attributes from both the app and its sub app
self.assertEqual(2, len(spans_with_http_attributes))
for span in spans_with_http_attributes:
self.assertEqual(
"/sub/home", span.attributes[SpanAttributes.HTTP_TARGET]
)
self.assertEqual(
"https://testserver:443/sub/home",
span.attributes[SpanAttributes.HTTP_URL],
)
class TestFastAPIManualInstrumentation(TestBaseManualFastAPI):
def test_instrument_app_with_instrument(self):
if not isinstance(self, TestAutoInstrumentation):
self._instrumentor.instrument()
@ -322,6 +472,11 @@ class TestFastAPIManualInstrumentation(TestBase):
@staticmethod
def _create_fastapi_app():
app = fastapi.FastAPI()
sub_app = fastapi.FastAPI()
@sub_app.get("/home")
async def _():
return {"message": "sub hi"}
@app.get("/foobar")
async def _():
@ -339,10 +494,12 @@ class TestFastAPIManualInstrumentation(TestBase):
async def _():
return {"message": "ok"}
app.mount("/sub", app=sub_app)
return app
class TestFastAPIManualInstrumentationHooks(TestFastAPIManualInstrumentation):
class TestFastAPIManualInstrumentationHooks(TestBaseManualFastAPI):
_server_request_hook = None
_client_request_hook = None
_client_response_hook = None
@ -392,7 +549,7 @@ class TestFastAPIManualInstrumentationHooks(TestFastAPIManualInstrumentation):
)
class TestAutoInstrumentation(TestFastAPIManualInstrumentation):
class TestAutoInstrumentation(TestBaseAutoFastAPI):
"""Test the auto-instrumented variant
Extending the manual instrumentation as most test cases apply
@ -453,8 +610,60 @@ class TestAutoInstrumentation(TestFastAPIManualInstrumentation):
self._instrumentor.uninstrument()
super().tearDown()
def test_sub_app_fastapi_call(self):
"""
!!! Attention: we need to override this testcase for the auto-instrumented variant
The reason is, that with auto instrumentation, the sub app is instrumented as well
and therefore we would see the spans for the sub app as well
class TestAutoInstrumentationHooks(TestFastAPIManualInstrumentationHooks):
This test is to ensure that a span in case of a sub app targeted contains the correct server url
"""
self._client.get("/sub/home")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 6)
for span in spans:
# As we are only looking to the "outer" app, we would see only the "GET /sub" spans
# -> the outer app is not aware of the sub_apps internal routes
sub_in = "GET /sub" in span.name
# The sub app spans are named GET /home as from the sub app perspective the request targets /home
# -> the sub app is technically not aware of the /sub prefix
home_in = "GET /home" in span.name
# We expect the spans to be either from the outer app or the sub app
self.assertTrue(
sub_in or home_in,
f"Span {span.name} does not have /sub or /home in its name",
)
# We now want to specifically test all spans including the
# - HTTP_TARGET
# - HTTP_URL
# attributes to be populated with the expected values
spans_with_http_attributes = [
span
for span in spans
if (
SpanAttributes.HTTP_URL in span.attributes
or SpanAttributes.HTTP_TARGET in span.attributes
)
]
# We now expect spans with attributes from both the app and its sub app
self.assertEqual(2, len(spans_with_http_attributes))
for span in spans_with_http_attributes:
self.assertEqual(
"/sub/home", span.attributes[SpanAttributes.HTTP_TARGET]
)
self.assertEqual(
"https://testserver:443/sub/home",
span.attributes[SpanAttributes.HTTP_URL],
)
class TestAutoInstrumentationHooks(TestBaseAutoFastAPI):
"""
Test the auto-instrumented variant for request and response hooks
@ -494,6 +703,58 @@ class TestAutoInstrumentationHooks(TestFastAPIManualInstrumentationHooks):
self._instrumentor.uninstrument()
super().tearDown()
def test_sub_app_fastapi_call(self):
"""
!!! Attention: we need to override this testcase for the auto-instrumented variant
The reason is, that with auto instrumentation, the sub app is instrumented as well
and therefore we would see the spans for the sub app as well
This test is to ensure that a span in case of a sub app targeted contains the correct server url
"""
self._client.get("/sub/home")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 6)
for span in spans:
# As we are only looking to the "outer" app, we would see only the "GET /sub" spans
# -> the outer app is not aware of the sub_apps internal routes
sub_in = "GET /sub" in span.name
# The sub app spans are named GET /home as from the sub app perspective the request targets /home
# -> the sub app is technically not aware of the /sub prefix
home_in = "GET /home" in span.name
# We expect the spans to be either from the outer app or the sub app
self.assertTrue(
sub_in or home_in,
f"Span {span.name} does not have /sub or /home in its name",
)
# We now want to specifically test all spans including the
# - HTTP_TARGET
# - HTTP_URL
# attributes to be populated with the expected values
spans_with_http_attributes = [
span
for span in spans
if (
SpanAttributes.HTTP_URL in span.attributes
or SpanAttributes.HTTP_TARGET in span.attributes
)
]
# We now expect spans with attributes from both the app and its sub app
self.assertEqual(2, len(spans_with_http_attributes))
for span in spans_with_http_attributes:
self.assertEqual(
"/sub/home", span.attributes[SpanAttributes.HTTP_TARGET]
)
self.assertEqual(
"https://testserver:443/sub/home",
span.attributes[SpanAttributes.HTTP_URL],
)
class TestAutoInstrumentationLogic(unittest.TestCase):
def test_instrumentation(self):
@ -511,412 +772,3 @@ class TestAutoInstrumentationLogic(unittest.TestCase):
should_be_original = fastapi.FastAPI
self.assertIs(original, should_be_original)
class TestWrappedApplication(TestBase):
def setUp(self):
super().setUp()
self.app = fastapi.FastAPI()
@self.app.get("/foobar")
async def _():
return {"message": "hello world"}
otel_fastapi.FastAPIInstrumentor().instrument_app(self.app)
self.client = TestClient(self.app)
self.tracer = self.tracer_provider.get_tracer(__name__)
def tearDown(self) -> None:
super().tearDown()
with self.disable_logging():
otel_fastapi.FastAPIInstrumentor().uninstrument_app(self.app)
def test_mark_span_internal_in_presence_of_span_from_other_framework(self):
with self.tracer.start_as_current_span(
"test", kind=trace.SpanKind.SERVER
) as parent_span:
resp = self.client.get("/foobar")
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
for span in span_list:
print(str(span.__class__) + ": " + str(span.__dict__))
# there should be 4 spans - single SERVER "test" and three INTERNAL "FastAPI"
self.assertEqual(trace.SpanKind.INTERNAL, span_list[0].kind)
self.assertEqual(trace.SpanKind.INTERNAL, span_list[1].kind)
# main INTERNAL span - child of test
self.assertEqual(trace.SpanKind.INTERNAL, span_list[2].kind)
self.assertEqual(
parent_span.context.span_id, span_list[2].parent.span_id
)
# SERVER "test"
self.assertEqual(trace.SpanKind.SERVER, span_list[3].kind)
self.assertEqual(
parent_span.context.span_id, span_list[3].context.span_id
)
class MultiMapping(Mapping):
def __init__(self, *items: Tuple[str, str]):
self._items = items
def __len__(self):
return len(self._items)
def __getitem__(self, __key):
raise NotImplementedError("use .items() instead")
def __iter__(self):
raise NotImplementedError("use .items() instead")
def items(self):
return self._items
@patch.dict(
"os.environ",
{
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: ".*my-secret.*",
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,Regex-Test-Header-.*,Regex-Invalid-Test-Header-.*,.*my-secret.*",
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,my-custom-regex-header-.*,invalid-regex-header-.*,.*my-secret.*",
},
)
class TestHTTPAppWithCustomHeaders(TestBase):
def setUp(self):
super().setUp()
self.app = self._create_app()
otel_fastapi.FastAPIInstrumentor().instrument_app(self.app)
self.client = TestClient(self.app)
def tearDown(self) -> None:
super().tearDown()
with self.disable_logging():
otel_fastapi.FastAPIInstrumentor().uninstrument_app(self.app)
@staticmethod
def _create_app():
app = fastapi.FastAPI()
@app.get("/foobar")
async def _():
headers = MultiMapping(
("custom-test-header-1", "test-header-value-1"),
("custom-test-header-2", "test-header-value-2"),
("my-custom-regex-header-1", "my-custom-regex-value-1"),
("my-custom-regex-header-1", "my-custom-regex-value-2"),
("My-Custom-Regex-Header-2", "my-custom-regex-value-3"),
("My-Custom-Regex-Header-2", "my-custom-regex-value-4"),
("My-Secret-Header", "My Secret Value"),
)
content = {"message": "hello world"}
return JSONResponse(content=content, headers=headers)
return app
def test_http_custom_request_headers_in_span_attributes(self):
expected = {
"http.request.header.custom_test_header_1": (
"test-header-value-1",
),
"http.request.header.custom_test_header_2": (
"test-header-value-2",
),
"http.request.header.regex_test_header_1": ("Regex Test Value 1",),
"http.request.header.regex_test_header_2": (
"RegexTestValue2,RegexTestValue3",
),
"http.request.header.my_secret_header": ("[REDACTED]",),
}
resp = self.client.get(
"/foobar",
headers={
"custom-test-header-1": "test-header-value-1",
"custom-test-header-2": "test-header-value-2",
"Regex-Test-Header-1": "Regex Test Value 1",
"regex-test-header-2": "RegexTestValue2,RegexTestValue3",
"My-Secret-Header": "My Secret Value",
},
)
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 3)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
self.assertSpanHasAttributes(server_span, expected)
def test_http_custom_request_headers_not_in_span_attributes(self):
not_expected = {
"http.request.header.custom_test_header_3": (
"test-header-value-3",
),
}
resp = self.client.get(
"/foobar",
headers={
"custom-test-header-1": "test-header-value-1",
"custom-test-header-2": "test-header-value-2",
"Regex-Test-Header-1": "Regex Test Value 1",
"regex-test-header-2": "RegexTestValue2,RegexTestValue3",
"My-Secret-Header": "My Secret Value",
},
)
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 3)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
for key, _ in not_expected.items():
self.assertNotIn(key, server_span.attributes)
def test_http_custom_response_headers_in_span_attributes(self):
expected = {
"http.response.header.custom_test_header_1": (
"test-header-value-1",
),
"http.response.header.custom_test_header_2": (
"test-header-value-2",
),
"http.response.header.my_custom_regex_header_1": (
"my-custom-regex-value-1",
"my-custom-regex-value-2",
),
"http.response.header.my_custom_regex_header_2": (
"my-custom-regex-value-3",
"my-custom-regex-value-4",
),
"http.response.header.my_secret_header": ("[REDACTED]",),
}
resp = self.client.get("/foobar")
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 3)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
self.assertSpanHasAttributes(server_span, expected)
def test_http_custom_response_headers_not_in_span_attributes(self):
not_expected = {
"http.response.header.custom_test_header_3": (
"test-header-value-3",
),
}
resp = self.client.get("/foobar")
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 3)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
for key, _ in not_expected.items():
self.assertNotIn(key, server_span.attributes)
@patch.dict(
"os.environ",
{
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: ".*my-secret.*",
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,Regex-Test-Header-.*,Regex-Invalid-Test-Header-.*,.*my-secret.*",
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,my-custom-regex-header-.*,invalid-regex-header-.*,.*my-secret.*",
},
)
class TestWebSocketAppWithCustomHeaders(TestBase):
def setUp(self):
super().setUp()
self.app = self._create_app()
otel_fastapi.FastAPIInstrumentor().instrument_app(self.app)
self.client = TestClient(self.app)
def tearDown(self) -> None:
super().tearDown()
with self.disable_logging():
otel_fastapi.FastAPIInstrumentor().uninstrument_app(self.app)
@staticmethod
def _create_app():
app = fastapi.FastAPI()
@app.websocket("/foobar_web")
async def _(websocket: fastapi.WebSocket):
message = await websocket.receive()
if message.get("type") == "websocket.connect":
await websocket.send(
{
"type": "websocket.accept",
"headers": [
(b"custom-test-header-1", b"test-header-value-1"),
(b"custom-test-header-2", b"test-header-value-2"),
(b"Regex-Test-Header-1", b"Regex Test Value 1"),
(
b"regex-test-header-2",
b"RegexTestValue2,RegexTestValue3",
),
(b"My-Secret-Header", b"My Secret Value"),
],
}
)
await websocket.send_json({"message": "hello world"})
await websocket.close()
if message.get("type") == "websocket.disconnect":
pass
return app
def test_web_socket_custom_request_headers_in_span_attributes(self):
expected = {
"http.request.header.custom_test_header_1": (
"test-header-value-1",
),
"http.request.header.custom_test_header_2": (
"test-header-value-2",
),
}
with self.client.websocket_connect(
"/foobar_web",
headers={
"custom-test-header-1": "test-header-value-1",
"custom-test-header-2": "test-header-value-2",
},
) as websocket:
data = websocket.receive_json()
self.assertEqual(data, {"message": "hello world"})
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 5)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
self.assertSpanHasAttributes(server_span, expected)
@patch.dict(
"os.environ",
{
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: ".*my-secret.*",
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,Regex-Test-Header-.*,Regex-Invalid-Test-Header-.*,.*my-secret.*",
},
)
def test_web_socket_custom_request_headers_not_in_span_attributes(self):
not_expected = {
"http.request.header.custom_test_header_3": (
"test-header-value-3",
),
}
with self.client.websocket_connect(
"/foobar_web",
headers={
"custom-test-header-1": "test-header-value-1",
"custom-test-header-2": "test-header-value-2",
},
) as websocket:
data = websocket.receive_json()
self.assertEqual(data, {"message": "hello world"})
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 5)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
for key, _ in not_expected.items():
self.assertNotIn(key, server_span.attributes)
def test_web_socket_custom_response_headers_in_span_attributes(self):
expected = {
"http.response.header.custom_test_header_1": (
"test-header-value-1",
),
"http.response.header.custom_test_header_2": (
"test-header-value-2",
),
}
with self.client.websocket_connect("/foobar_web") as websocket:
data = websocket.receive_json()
self.assertEqual(data, {"message": "hello world"})
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 5)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
self.assertSpanHasAttributes(server_span, expected)
def test_web_socket_custom_response_headers_not_in_span_attributes(self):
not_expected = {
"http.response.header.custom_test_header_3": (
"test-header-value-3",
),
}
with self.client.websocket_connect("/foobar_web") as websocket:
data = websocket.receive_json()
self.assertEqual(data, {"message": "hello world"})
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 5)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
for key, _ in not_expected.items():
self.assertNotIn(key, server_span.attributes)
@patch.dict(
"os.environ",
{
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3",
},
)
class TestNonRecordingSpanWithCustomHeaders(TestBase):
def setUp(self):
super().setUp()
self.app = fastapi.FastAPI()
@self.app.get("/foobar")
async def _():
return {"message": "hello world"}
reset_trace_globals()
tracer_provider = trace.NoOpTracerProvider()
trace.set_tracer_provider(tracer_provider=tracer_provider)
self._instrumentor = otel_fastapi.FastAPIInstrumentor()
self._instrumentor.instrument_app(self.app)
self.client = TestClient(self.app)
def tearDown(self) -> None:
super().tearDown()
with self.disable_logging():
self._instrumentor.uninstrument_app(self.app)
def test_custom_header_not_present_in_non_recording_span(self):
resp = self.client.get(
"/foobar",
headers={
"custom-test-header-1": "test-header-value-1",
},
)
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 0)

View File

@ -0,0 +1,381 @@
from collections.abc import Mapping
from typing import Tuple
from unittest.mock import patch
import fastapi
from starlette.responses import JSONResponse
from starlette.testclient import TestClient
import opentelemetry.instrumentation.fastapi as otel_fastapi
from opentelemetry import trace
from opentelemetry.test.globals_test import reset_trace_globals
from opentelemetry.test.test_base import TestBase
from opentelemetry.util.http import (
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
)
class MultiMapping(Mapping):
def __init__(self, *items: Tuple[str, str]):
self._items = items
def __len__(self):
return len(self._items)
def __getitem__(self, __key):
raise NotImplementedError("use .items() instead")
def __iter__(self):
raise NotImplementedError("use .items() instead")
def items(self):
return self._items
@patch.dict(
"os.environ",
{
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: ".*my-secret.*",
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,Regex-Test-Header-.*,Regex-Invalid-Test-Header-.*,.*my-secret.*",
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,my-custom-regex-header-.*,invalid-regex-header-.*,.*my-secret.*",
},
)
class TestHTTPAppWithCustomHeaders(TestBase):
def setUp(self):
super().setUp()
self.app = self._create_app()
otel_fastapi.FastAPIInstrumentor().instrument_app(self.app)
self.client = TestClient(self.app)
def tearDown(self) -> None:
super().tearDown()
with self.disable_logging():
otel_fastapi.FastAPIInstrumentor().uninstrument_app(self.app)
@staticmethod
def _create_app():
app = fastapi.FastAPI()
@app.get("/foobar")
async def _():
headers = MultiMapping(
("custom-test-header-1", "test-header-value-1"),
("custom-test-header-2", "test-header-value-2"),
("my-custom-regex-header-1", "my-custom-regex-value-1"),
("my-custom-regex-header-1", "my-custom-regex-value-2"),
("My-Custom-Regex-Header-2", "my-custom-regex-value-3"),
("My-Custom-Regex-Header-2", "my-custom-regex-value-4"),
("My-Secret-Header", "My Secret Value"),
)
content = {"message": "hello world"}
return JSONResponse(content=content, headers=headers)
return app
def test_http_custom_request_headers_in_span_attributes(self):
expected = {
"http.request.header.custom_test_header_1": (
"test-header-value-1",
),
"http.request.header.custom_test_header_2": (
"test-header-value-2",
),
"http.request.header.regex_test_header_1": ("Regex Test Value 1",),
"http.request.header.regex_test_header_2": (
"RegexTestValue2,RegexTestValue3",
),
"http.request.header.my_secret_header": ("[REDACTED]",),
}
resp = self.client.get(
"/foobar",
headers={
"custom-test-header-1": "test-header-value-1",
"custom-test-header-2": "test-header-value-2",
"Regex-Test-Header-1": "Regex Test Value 1",
"regex-test-header-2": "RegexTestValue2,RegexTestValue3",
"My-Secret-Header": "My Secret Value",
},
)
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 3)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
self.assertSpanHasAttributes(server_span, expected)
def test_http_custom_request_headers_not_in_span_attributes(self):
not_expected = {
"http.request.header.custom_test_header_3": (
"test-header-value-3",
),
}
resp = self.client.get(
"/foobar",
headers={
"custom-test-header-1": "test-header-value-1",
"custom-test-header-2": "test-header-value-2",
"Regex-Test-Header-1": "Regex Test Value 1",
"regex-test-header-2": "RegexTestValue2,RegexTestValue3",
"My-Secret-Header": "My Secret Value",
},
)
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 3)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
for key, _ in not_expected.items():
self.assertNotIn(key, server_span.attributes)
def test_http_custom_response_headers_in_span_attributes(self):
expected = {
"http.response.header.custom_test_header_1": (
"test-header-value-1",
),
"http.response.header.custom_test_header_2": (
"test-header-value-2",
),
"http.response.header.my_custom_regex_header_1": (
"my-custom-regex-value-1",
"my-custom-regex-value-2",
),
"http.response.header.my_custom_regex_header_2": (
"my-custom-regex-value-3",
"my-custom-regex-value-4",
),
"http.response.header.my_secret_header": ("[REDACTED]",),
}
resp = self.client.get("/foobar")
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 3)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
self.assertSpanHasAttributes(server_span, expected)
def test_http_custom_response_headers_not_in_span_attributes(self):
not_expected = {
"http.response.header.custom_test_header_3": (
"test-header-value-3",
),
}
resp = self.client.get("/foobar")
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 3)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
for key, _ in not_expected.items():
self.assertNotIn(key, server_span.attributes)
@patch.dict(
"os.environ",
{
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: ".*my-secret.*",
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,Regex-Test-Header-.*,Regex-Invalid-Test-Header-.*,.*my-secret.*",
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,my-custom-regex-header-.*,invalid-regex-header-.*,.*my-secret.*",
},
)
class TestWebSocketAppWithCustomHeaders(TestBase):
def setUp(self):
super().setUp()
self.app = self._create_app()
otel_fastapi.FastAPIInstrumentor().instrument_app(self.app)
self.client = TestClient(self.app)
def tearDown(self) -> None:
super().tearDown()
with self.disable_logging():
otel_fastapi.FastAPIInstrumentor().uninstrument_app(self.app)
@staticmethod
def _create_app():
app = fastapi.FastAPI()
@app.websocket("/foobar_web")
async def _(websocket: fastapi.WebSocket):
message = await websocket.receive()
if message.get("type") == "websocket.connect":
await websocket.send(
{
"type": "websocket.accept",
"headers": [
(b"custom-test-header-1", b"test-header-value-1"),
(b"custom-test-header-2", b"test-header-value-2"),
(b"Regex-Test-Header-1", b"Regex Test Value 1"),
(
b"regex-test-header-2",
b"RegexTestValue2,RegexTestValue3",
),
(b"My-Secret-Header", b"My Secret Value"),
],
}
)
await websocket.send_json({"message": "hello world"})
await websocket.close()
if message.get("type") == "websocket.disconnect":
pass
return app
def test_web_socket_custom_request_headers_in_span_attributes(self):
expected = {
"http.request.header.custom_test_header_1": (
"test-header-value-1",
),
"http.request.header.custom_test_header_2": (
"test-header-value-2",
),
}
with self.client.websocket_connect(
"/foobar_web",
headers={
"custom-test-header-1": "test-header-value-1",
"custom-test-header-2": "test-header-value-2",
},
) as websocket:
data = websocket.receive_json()
self.assertEqual(data, {"message": "hello world"})
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 5)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
self.assertSpanHasAttributes(server_span, expected)
@patch.dict(
"os.environ",
{
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: ".*my-secret.*",
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,Regex-Test-Header-.*,Regex-Invalid-Test-Header-.*,.*my-secret.*",
},
)
def test_web_socket_custom_request_headers_not_in_span_attributes(self):
not_expected = {
"http.request.header.custom_test_header_3": (
"test-header-value-3",
),
}
with self.client.websocket_connect(
"/foobar_web",
headers={
"custom-test-header-1": "test-header-value-1",
"custom-test-header-2": "test-header-value-2",
},
) as websocket:
data = websocket.receive_json()
self.assertEqual(data, {"message": "hello world"})
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 5)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
for key, _ in not_expected.items():
self.assertNotIn(key, server_span.attributes)
def test_web_socket_custom_response_headers_in_span_attributes(self):
expected = {
"http.response.header.custom_test_header_1": (
"test-header-value-1",
),
"http.response.header.custom_test_header_2": (
"test-header-value-2",
),
}
with self.client.websocket_connect("/foobar_web") as websocket:
data = websocket.receive_json()
self.assertEqual(data, {"message": "hello world"})
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 5)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
self.assertSpanHasAttributes(server_span, expected)
def test_web_socket_custom_response_headers_not_in_span_attributes(self):
not_expected = {
"http.response.header.custom_test_header_3": (
"test-header-value-3",
),
}
with self.client.websocket_connect("/foobar_web") as websocket:
data = websocket.receive_json()
self.assertEqual(data, {"message": "hello world"})
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 5)
server_span = [
span for span in span_list if span.kind == trace.SpanKind.SERVER
][0]
for key, _ in not_expected.items():
self.assertNotIn(key, server_span.attributes)
@patch.dict(
"os.environ",
{
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3",
},
)
class TestNonRecordingSpanWithCustomHeaders(TestBase):
def setUp(self):
super().setUp()
self.app = fastapi.FastAPI()
@self.app.get("/foobar")
async def _():
return {"message": "hello world"}
reset_trace_globals()
tracer_provider = trace.NoOpTracerProvider()
trace.set_tracer_provider(tracer_provider=tracer_provider)
self._instrumentor = otel_fastapi.FastAPIInstrumentor()
self._instrumentor.instrument_app(self.app)
self.client = TestClient(self.app)
def tearDown(self) -> None:
super().tearDown()
with self.disable_logging():
self._instrumentor.uninstrument_app(self.app)
def test_custom_header_not_present_in_non_recording_span(self):
resp = self.client.get(
"/foobar",
headers={
"custom-test-header-1": "test-header-value-1",
},
)
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
self.assertEqual(len(span_list), 0)

View File

@ -0,0 +1,64 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import fastapi
from starlette.testclient import TestClient
import opentelemetry.instrumentation.fastapi as otel_fastapi
from opentelemetry import trace
from opentelemetry.test.test_base import TestBase
class TestWrappedApplication(TestBase):
def setUp(self):
super().setUp()
self.app = fastapi.FastAPI()
@self.app.get("/foobar")
async def _():
return {"message": "hello world"}
otel_fastapi.FastAPIInstrumentor().instrument_app(self.app)
self.client = TestClient(self.app)
self.tracer = self.tracer_provider.get_tracer(__name__)
def tearDown(self) -> None:
super().tearDown()
with self.disable_logging():
otel_fastapi.FastAPIInstrumentor().uninstrument_app(self.app)
def test_mark_span_internal_in_presence_of_span_from_other_framework(self):
with self.tracer.start_as_current_span(
"test", kind=trace.SpanKind.SERVER
) as parent_span:
resp = self.client.get("/foobar")
self.assertEqual(200, resp.status_code)
span_list = self.memory_exporter.get_finished_spans()
for span in span_list:
print(str(span.__class__) + ": " + str(span.__dict__))
# there should be 4 spans - single SERVER "test" and three INTERNAL "FastAPI"
self.assertEqual(trace.SpanKind.INTERNAL, span_list[0].kind)
self.assertEqual(trace.SpanKind.INTERNAL, span_list[1].kind)
# main INTERNAL span - child of test
self.assertEqual(trace.SpanKind.INTERNAL, span_list[2].kind)
self.assertEqual(
parent_span.context.span_id, span_list[2].parent.span_id
)
# SERVER "test"
self.assertEqual(trace.SpanKind.SERVER, span_list[3].kind)
self.assertEqual(
parent_span.context.span_id, span_list[3].context.span_id
)

View File

@ -18,7 +18,7 @@ from unittest.mock import patch
from starlette import applications
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
from starlette.websockets import WebSocket
@ -103,6 +103,43 @@ class TestStarletteManualInstrumentation(TestBase):
"opentelemetry.instrumentation.starlette",
)
def test_sub_app_starlette_call(self):
"""
This test is to ensure that a span in case of a sub app targeted contains the correct server url
"""
self._client.get("/sub/home")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 3)
for span in spans:
# As we are only looking to the "outer" app, we would see only the "GET /sub" spans
self.assertIn("GET /sub", span.name)
# We now want to specifically test all spans including the
# - HTTP_TARGET
# - HTTP_URL
# attributes to be populated with the expected values
spans_with_http_attributes = [
span
for span in spans
if (
SpanAttributes.HTTP_URL in span.attributes
or SpanAttributes.HTTP_TARGET in span.attributes
)
]
# expect only one span to have the attributes
self.assertEqual(1, len(spans_with_http_attributes))
for span in spans_with_http_attributes:
self.assertEqual(
"/sub/home", span.attributes[SpanAttributes.HTTP_TARGET]
)
self.assertEqual(
"http://testserver/sub/home",
span.attributes[SpanAttributes.HTTP_URL],
)
def test_starlette_route_attribute_added(self):
"""Ensure that starlette routes are used as the span name."""
self._client.get("/user/123")
@ -250,13 +287,20 @@ class TestStarletteManualInstrumentation(TestBase):
def health(_):
return PlainTextResponse("ok")
def sub_home(_):
return PlainTextResponse("sub hi")
sub_app = applications.Starlette(routes=[Route("/home", sub_home)])
app = applications.Starlette(
routes=[
Route("/foobar", home),
Route("/user/{username}", home),
Route("/healthzz", health),
]
Mount("/sub", app=sub_app),
],
)
return app
@ -354,6 +398,72 @@ class TestAutoInstrumentation(TestStarletteManualInstrumentation):
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 0)
def test_sub_app_starlette_call(self):
"""
!!! Attention: we need to override this testcase for the auto-instrumented variant
The reason is, that with auto instrumentation, the sub app is instrumented as well
and therefore we would see the spans for the sub app as well
This test is to ensure that a span in case of a sub app targeted contains the correct server url
"""
self._client.get("/sub/home")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 6)
for span in spans:
# As we are only looking to the "outer" app, we would see only the "GET /sub" spans
# -> the outer app is not aware of the sub_apps internal routes
sub_in = "GET /sub" in span.name
# The sub app spans are named GET /home as from the sub app perspective the request targets /home
# -> the sub app is technically not aware of the /sub prefix
home_in = "GET /home" in span.name
# We expect the spans to be either from the outer app or the sub app
self.assertTrue(
sub_in or home_in,
f"Span {span.name} does not have /sub or /home in its name",
)
# We now want to specifically test all spans including the
# - HTTP_TARGET
# - HTTP_URL
# attributes to be populated with the expected values
spans_with_http_attributes = [
span
for span in spans
if (
SpanAttributes.HTTP_URL in span.attributes
or SpanAttributes.HTTP_TARGET in span.attributes
)
]
# We now expect spans with attributes from both the app and its sub app
self.assertEqual(2, len(spans_with_http_attributes))
# Due to a potential bug in starlettes handling of sub app mounts, we can
# check only the server kind spans for the correct attributes
# The internal one generated by the sub app is not yet producing the correct attributes
server_span = next(
(
span
for span in spans_with_http_attributes
if span.kind == SpanKind.SERVER
),
None,
)
self.assertIsNotNone(server_span)
# As soon as the bug is fixed for starlette, we can iterate over spans_with_http_attributes here
# to verify the correctness of the attributes for the internal span as well
self.assertEqual(
"/sub/home", server_span.attributes[SpanAttributes.HTTP_TARGET]
)
self.assertEqual(
"http://testserver/sub/home",
server_span.attributes[SpanAttributes.HTTP_URL],
)
class TestAutoInstrumentationHooks(TestStarletteManualInstrumentationHooks):
"""
@ -374,6 +484,72 @@ class TestAutoInstrumentationHooks(TestStarletteManualInstrumentationHooks):
self._instrumentor.uninstrument()
super().tearDown()
def test_sub_app_starlette_call(self):
"""
!!! Attention: we need to override this testcase for the auto-instrumented variant
The reason is, that with auto instrumentation, the sub app is instrumented as well
and therefore we would see the spans for the sub app as well
This test is to ensure that a span in case of a sub app targeted contains the correct server url
"""
self._client.get("/sub/home")
spans = self.memory_exporter.get_finished_spans()
self.assertEqual(len(spans), 6)
for span in spans:
# As we are only looking to the "outer" app, we would see only the "GET /sub" spans
# -> the outer app is not aware of the sub_apps internal routes
sub_in = "GET /sub" in span.name
# The sub app spans are named GET /home as from the sub app perspective the request targets /home
# -> the sub app is technically not aware of the /sub prefix
home_in = "GET /home" in span.name
# We expect the spans to be either from the outer app or the sub app
self.assertTrue(
sub_in or home_in,
f"Span {span.name} does not have /sub or /home in its name",
)
# We now want to specifically test all spans including the
# - HTTP_TARGET
# - HTTP_URL
# attributes to be populated with the expected values
spans_with_http_attributes = [
span
for span in spans
if (
SpanAttributes.HTTP_URL in span.attributes
or SpanAttributes.HTTP_TARGET in span.attributes
)
]
# We now expect spans with attributes from both the app and its sub app
self.assertEqual(2, len(spans_with_http_attributes))
# Due to a potential bug in starlettes handling of sub app mounts, we can
# check only the server kind spans for the correct attributes
# The internal one generated by the sub app is not yet producing the correct attributes
server_span = next(
(
span
for span in spans_with_http_attributes
if span.kind == SpanKind.SERVER
),
None,
)
self.assertIsNotNone(server_span)
# As soon as the bug is fixed for starlette, we can iterate over spans_with_http_attributes here
# to verify the correctness of the attributes for the internal span as well
self.assertEqual(
"/sub/home", server_span.attributes[SpanAttributes.HTTP_TARGET]
)
self.assertEqual(
"http://testserver/sub/home",
server_span.attributes[SpanAttributes.HTTP_URL],
)
class TestAutoInstrumentationLogic(unittest.TestCase):
def test_instrumentation(self):