Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
parse_excluded_urls,
sanitize_method,
)
from opentelemetry.instrumentation.fastapi.utils import get_excluded_spans

_excluded_urls_from_env = get_excluded_urls("FASTAPI")
_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -278,6 +279,10 @@ def instrument_app(
excluded_urls = _excluded_urls_from_env
else:
excluded_urls = parse_excluded_urls(excluded_urls)

if exclude_spans is None:
exclude_spans = get_excluded_spans()

tracer = get_tracer(
__name__,
__version__,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 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.

"""
Exclude HTTP `send` and/or `receive` spans from the trace.
"""

OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS = "OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS"
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 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 os

from typing import Literal, Union

from opentelemetry.instrumentation.fastapi.environment_variables import (
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS,
)

SpanType = Literal["receive", "send"]


def get_excluded_spans() -> Union[list[SpanType], None]:
raw = os.getenv(OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS)

if not raw:
return None

values = [v.strip() for v in raw.split(",") if v.strip()]

allowed: set[str] = {"receive", "send"}
result: list[SpanType] = []

for value in values:
if value not in allowed:
raise ValueError(
f"Invalid excluded span: '{value}'. Allowed values are: {allowed}"
)
result.append(value) # type: ignore[arg-type]

return result
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
from starlette.types import Receive, Scope, Send

import opentelemetry.instrumentation.fastapi as otel_fastapi
from opentelemetry.instrumentation.fastapi.environment_variables import (
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS,
)
from opentelemetry import trace
from opentelemetry.instrumentation._semconv import (
OTEL_SEMCONV_STABILITY_OPT_IN,
Expand Down Expand Up @@ -2478,3 +2481,192 @@ def test_fastapi_unhandled_exception_both_semconv(self):
assert server_span is not None

self.assertEqual(server_span.name, "GET /error")


class TestExcludedSpansEnvVar(TestBaseManualFastAPI):
"""Tests for the OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS environment variable."""

def _create_app_with_excluded_spans(self):
app = self._create_app()

@app.get("/foobar")
async def _():
return {"message": "hello world"}

otel_fastapi.FastAPIInstrumentor().instrument_app(app)
return app

def test_excluded_spans_send_via_env(self):
"""Setting OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS=send should exclude send spans."""
with patch.dict(
"os.environ",
{
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS: "send",
OTEL_SEMCONV_STABILITY_OPT_IN: "default",
},
):
_OpenTelemetrySemanticConventionStability._initialized = False
app = self._create_app_with_excluded_spans()
client = TestClient(app)

client.get("/foobar")
spans = self.memory_exporter.get_finished_spans()

# Expect: only the server span (no send span)
self.assertEqual(len(spans), 1)

span_name = spans[0].name
self.assertIn("GET /foobar", span_name)
self.assertNotIn("http send", span_name)

otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)

def test_excluded_spans_both_receive_and_send_via_env(self):
"""Setting OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS=receive,send should exclude both."""
with patch.dict(
"os.environ",
{
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS: "receive,send",
OTEL_SEMCONV_STABILITY_OPT_IN: "default",
},
):
_OpenTelemetrySemanticConventionStability._initialized = False
app = self._create_app_with_excluded_spans()
client = TestClient(app)

client.get("/foobar")
spans = self.memory_exporter.get_finished_spans()

# Expect: only the server span (no receive or send spans)
self.assertEqual(len(spans), 1)

span_name = spans[0].name
self.assertIn("GET /foobar", span_name)
self.assertNotIn("http receive", span_name)
self.assertNotIn("http send", span_name)

otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)

def test_excluded_spans_invalid_value_raises_error(self):
"""Invalid values in OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS should raise ValueError."""
with patch.dict(
"os.environ",
{
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS: "invalid",
OTEL_SEMCONV_STABILITY_OPT_IN: "default",
},
):
_OpenTelemetrySemanticConventionStability._initialized = False
app = fastapi.FastAPI()
with self.assertRaises(ValueError) as context:
otel_fastapi.FastAPIInstrumentor().instrument_app(app)

error_msg = str(context.exception)
self.assertIn("Invalid excluded span", error_msg)
self.assertIn("invalid", error_msg)

def test_exclude_spans_takes_priority(self):
"""`exclude_spans` passed to the instrumenter must take priority over the environment variable"""
with patch.dict(
"os.environ",
{
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS: "send",
OTEL_SEMCONV_STABILITY_OPT_IN: "default",
},
):
_OpenTelemetrySemanticConventionStability._initialized = False
app = self._create_websocket_app()

# Pass exclude_spans parameter that differs from env var
otel_fastapi.FastAPIInstrumentor().instrument_app(
app, exclude_spans=["receive"]
)
client = TestClient(app)

with client.websocket_connect("/ws") as websocket:
data = websocket.receive_json()
self.assertEqual(data, {"message": "hello"})

spans = self.memory_exporter.get_finished_spans()
span_names = [span.name for span in spans]

# Receive spans should NOT exist (parameter takes priority)
self.assertFalse(
any("receive" in name.lower() for name in span_names)
)

# Send spans should exist (env var should be ignored)
self.assertTrue(any("send" in name.lower() for name in span_names))

otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)

@staticmethod
def _create_websocket_app():
"""Create a FastAPI app with a WebSocket endpoint."""
app = fastapi.FastAPI()

@app.websocket("/ws")
async def websocket_endpoint(websocket: fastapi.WebSocket):
await websocket.accept()
await websocket.send_json({"message": "hello"})
await websocket.close()

return app

def test_websocket_excluded_spans_receive_via_env(self):
"""Setting OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS=receive should exclude receive spans for WebSocket."""
with patch.dict(
"os.environ",
{
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS: "receive",
OTEL_SEMCONV_STABILITY_OPT_IN: "default",
},
):
_OpenTelemetrySemanticConventionStability._initialized = False
app = self._create_websocket_app()

otel_fastapi.FastAPIInstrumentor().instrument_app(app)
client = TestClient(app)

with client.websocket_connect("/ws") as websocket:
data = websocket.receive_json()
self.assertEqual(data, {"message": "hello"})

spans = self.memory_exporter.get_finished_spans()
span_names = [span.name for span in spans]

# Receive spans should NOT exist
self.assertFalse(
any("receive" in name.lower() for name in span_names)
)

otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)

def test_websocket_receive_spans_present_by_default(self):
"""Without OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS, receive spans should be present for WebSocket."""
with patch.dict(
"os.environ",
{
OTEL_SEMCONV_STABILITY_OPT_IN: "default",
},
clear=True,
):
_OpenTelemetrySemanticConventionStability._initialized = False
app = self._create_websocket_app()

otel_fastapi.FastAPIInstrumentor().instrument_app(app)
client = TestClient(app)

with client.websocket_connect("/ws") as websocket:
data = websocket.receive_json()
self.assertEqual(data, {"message": "hello"})

spans = self.memory_exporter.get_finished_spans()
span_names = [span.name for span in spans]

# Receive spans should exist
self.assertTrue(
any("receive" in name.lower() for name in span_names)
)

otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)