|
33 | 33 | from starlette.types import Receive, Scope, Send |
34 | 34 |
|
35 | 35 | import opentelemetry.instrumentation.fastapi as otel_fastapi |
| 36 | +from opentelemetry.instrumentation.fastapi.environment_variables import ( |
| 37 | + OTEL_PYTHON_FASTAPI_EXCLUDE_SPANS, |
| 38 | +) |
36 | 39 | from opentelemetry import trace |
37 | 40 | from opentelemetry.instrumentation._semconv import ( |
38 | 41 | OTEL_SEMCONV_STABILITY_OPT_IN, |
@@ -2478,3 +2481,192 @@ def test_fastapi_unhandled_exception_both_semconv(self): |
2478 | 2481 | assert server_span is not None |
2479 | 2482 |
|
2480 | 2483 | 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