Skip to content

Commit f672f37

Browse files
feat: Add support for excluding HTTP receive/send spans via environment variable.
1 parent 51dde66 commit f672f37

File tree

4 files changed

+260
-0
lines changed

4 files changed

+260
-0
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
218218
parse_excluded_urls,
219219
sanitize_method,
220220
)
221+
from opentelemetry.instrumentation.fastapi.utils import get_excluded_spans
221222

222223
_excluded_urls_from_env = get_excluded_urls("FASTAPI")
223224
_logger = logging.getLogger(__name__)
@@ -278,6 +279,10 @@ def instrument_app(
278279
excluded_urls = _excluded_urls_from_env
279280
else:
280281
excluded_urls = parse_excluded_urls(excluded_urls)
282+
283+
if exclude_spans is None:
284+
exclude_spans = get_excluded_spans()
285+
281286
tracer = get_tracer(
282287
__name__,
283288
__version__,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
"""
16+
Exclude HTTP `send` and/or `receive` spans from the trace.
17+
"""
18+
19+
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS = "OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS"
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 os
16+
17+
from typing import Literal, Union
18+
19+
from opentelemetry.instrumentation.fastapi.environment_variables import (
20+
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS,
21+
)
22+
23+
SpanType = Literal["receive", "send"]
24+
25+
26+
def get_excluded_spans() -> Union[list[SpanType], None]:
27+
raw = os.getenv(OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS)
28+
29+
if not raw:
30+
return None
31+
32+
values = [v.strip() for v in raw.split(",") if v.strip()]
33+
34+
allowed: set[str] = {"receive", "send"}
35+
result: list[SpanType] = []
36+
37+
for value in values:
38+
if value not in allowed:
39+
raise ValueError(
40+
f"Invalid excluded span: '{value}'. Allowed values are: {allowed}"
41+
)
42+
result.append(value) # type: ignore[arg-type]
43+
44+
return result

instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
from starlette.types import Receive, Scope, Send
3434

3535
import opentelemetry.instrumentation.fastapi as otel_fastapi
36+
from opentelemetry.instrumentation.fastapi.environment_variables import (
37+
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS,
38+
)
3639
from opentelemetry import trace
3740
from opentelemetry.instrumentation._semconv import (
3841
OTEL_SEMCONV_STABILITY_OPT_IN,
@@ -2478,3 +2481,192 @@ def test_fastapi_unhandled_exception_both_semconv(self):
24782481
assert server_span is not None
24792482

24802483
self.assertEqual(server_span.name, "GET /error")
2484+
2485+
2486+
class TestExcludedSpansEnvVar(TestBaseManualFastAPI):
2487+
"""Tests for the OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS environment variable."""
2488+
2489+
def _create_app_with_excluded_spans(self):
2490+
app = self._create_app()
2491+
2492+
@app.get("/foobar")
2493+
async def _():
2494+
return {"message": "hello world"}
2495+
2496+
otel_fastapi.FastAPIInstrumentor().instrument_app(app)
2497+
return app
2498+
2499+
def test_excluded_spans_send_via_env(self):
2500+
"""Setting OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS=send should exclude send spans."""
2501+
with patch.dict(
2502+
"os.environ",
2503+
{
2504+
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS: "send",
2505+
OTEL_SEMCONV_STABILITY_OPT_IN: "default",
2506+
},
2507+
):
2508+
_OpenTelemetrySemanticConventionStability._initialized = False
2509+
app = self._create_app_with_excluded_spans()
2510+
client = TestClient(app)
2511+
2512+
client.get("/foobar")
2513+
spans = self.memory_exporter.get_finished_spans()
2514+
2515+
# Expect: only the server span (no send span)
2516+
self.assertEqual(len(spans), 1)
2517+
2518+
span_name = spans[0].name
2519+
self.assertIn("GET /foobar", span_name)
2520+
self.assertNotIn("http send", span_name)
2521+
2522+
otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)
2523+
2524+
def test_excluded_spans_both_receive_and_send_via_env(self):
2525+
"""Setting OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS=receive,send should exclude both."""
2526+
with patch.dict(
2527+
"os.environ",
2528+
{
2529+
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS: "receive,send",
2530+
OTEL_SEMCONV_STABILITY_OPT_IN: "default",
2531+
},
2532+
):
2533+
_OpenTelemetrySemanticConventionStability._initialized = False
2534+
app = self._create_app_with_excluded_spans()
2535+
client = TestClient(app)
2536+
2537+
client.get("/foobar")
2538+
spans = self.memory_exporter.get_finished_spans()
2539+
2540+
# Expect: only the server span (no receive or send spans)
2541+
self.assertEqual(len(spans), 1)
2542+
2543+
span_name = spans[0].name
2544+
self.assertIn("GET /foobar", span_name)
2545+
self.assertNotIn("http receive", span_name)
2546+
self.assertNotIn("http send", span_name)
2547+
2548+
otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)
2549+
2550+
def test_excluded_spans_invalid_value_raises_error(self):
2551+
"""Invalid values in OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS should raise ValueError."""
2552+
with patch.dict(
2553+
"os.environ",
2554+
{
2555+
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS: "invalid",
2556+
OTEL_SEMCONV_STABILITY_OPT_IN: "default",
2557+
},
2558+
):
2559+
_OpenTelemetrySemanticConventionStability._initialized = False
2560+
app = fastapi.FastAPI()
2561+
with self.assertRaises(ValueError) as context:
2562+
otel_fastapi.FastAPIInstrumentor().instrument_app(app)
2563+
2564+
error_msg = str(context.exception)
2565+
self.assertIn("Invalid excluded span", error_msg)
2566+
self.assertIn("invalid", error_msg)
2567+
2568+
def test_exclude_spans_takes_priority(self):
2569+
"""`exclude_spans` passed to the instrumenter must take priority over the environment variable"""
2570+
with patch.dict(
2571+
"os.environ",
2572+
{
2573+
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS: "send",
2574+
OTEL_SEMCONV_STABILITY_OPT_IN: "default",
2575+
},
2576+
):
2577+
_OpenTelemetrySemanticConventionStability._initialized = False
2578+
app = self._create_websocket_app()
2579+
2580+
# Pass exclude_spans parameter that differs from env var
2581+
otel_fastapi.FastAPIInstrumentor().instrument_app(
2582+
app, exclude_spans=["receive"]
2583+
)
2584+
client = TestClient(app)
2585+
2586+
with client.websocket_connect("/ws") as websocket:
2587+
data = websocket.receive_json()
2588+
self.assertEqual(data, {"message": "hello"})
2589+
2590+
spans = self.memory_exporter.get_finished_spans()
2591+
span_names = [span.name for span in spans]
2592+
2593+
# Receive spans should NOT exist (parameter takes priority)
2594+
self.assertFalse(
2595+
any("receive" in name.lower() for name in span_names)
2596+
)
2597+
2598+
# Send spans should exist (env var should be ignored)
2599+
self.assertTrue(any("send" in name.lower() for name in span_names))
2600+
2601+
otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)
2602+
2603+
@staticmethod
2604+
def _create_websocket_app():
2605+
"""Create a FastAPI app with a WebSocket endpoint."""
2606+
app = fastapi.FastAPI()
2607+
2608+
@app.websocket("/ws")
2609+
async def websocket_endpoint(websocket: fastapi.WebSocket):
2610+
await websocket.accept()
2611+
await websocket.send_json({"message": "hello"})
2612+
await websocket.close()
2613+
2614+
return app
2615+
2616+
def test_websocket_excluded_spans_receive_via_env(self):
2617+
"""Setting OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS=receive should exclude receive spans for WebSocket."""
2618+
with patch.dict(
2619+
"os.environ",
2620+
{
2621+
OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS: "receive",
2622+
OTEL_SEMCONV_STABILITY_OPT_IN: "default",
2623+
},
2624+
):
2625+
_OpenTelemetrySemanticConventionStability._initialized = False
2626+
app = self._create_websocket_app()
2627+
2628+
otel_fastapi.FastAPIInstrumentor().instrument_app(app)
2629+
client = TestClient(app)
2630+
2631+
with client.websocket_connect("/ws") as websocket:
2632+
data = websocket.receive_json()
2633+
self.assertEqual(data, {"message": "hello"})
2634+
2635+
spans = self.memory_exporter.get_finished_spans()
2636+
span_names = [span.name for span in spans]
2637+
2638+
# Receive spans should NOT exist
2639+
self.assertFalse(
2640+
any("receive" in name.lower() for name in span_names)
2641+
)
2642+
2643+
otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)
2644+
2645+
def test_websocket_receive_spans_present_by_default(self):
2646+
"""Without OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS, receive spans should be present for WebSocket."""
2647+
with patch.dict(
2648+
"os.environ",
2649+
{
2650+
OTEL_SEMCONV_STABILITY_OPT_IN: "default",
2651+
},
2652+
clear=True,
2653+
):
2654+
_OpenTelemetrySemanticConventionStability._initialized = False
2655+
app = self._create_websocket_app()
2656+
2657+
otel_fastapi.FastAPIInstrumentor().instrument_app(app)
2658+
client = TestClient(app)
2659+
2660+
with client.websocket_connect("/ws") as websocket:
2661+
data = websocket.receive_json()
2662+
self.assertEqual(data, {"message": "hello"})
2663+
2664+
spans = self.memory_exporter.get_finished_spans()
2665+
span_names = [span.name for span in spans]
2666+
2667+
# Receive spans should exist
2668+
self.assertTrue(
2669+
any("receive" in name.lower() for name in span_names)
2670+
)
2671+
2672+
otel_fastapi.FastAPIInstrumentor().uninstrument_app(app)

0 commit comments

Comments
 (0)