From b3bd9591d2d41dd75e17e177d287abe3e10a2635 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Wed, 12 Feb 2025 13:51:23 +0200 Subject: [PATCH] Set log level to warning instead of error for 4xx HTTPExceptions from FastAPI/Starlette (#858) --- logfire/_internal/tracer.py | 14 +++++++++++++- tests/otel_integrations/test_fastapi.py | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/logfire/_internal/tracer.py b/logfire/_internal/tracer.py index 552d3613..d1c93b7f 100644 --- a/logfire/_internal/tracer.py +++ b/logfire/_internal/tracer.py @@ -356,11 +356,14 @@ def record_exception( escaped: bool = False, ) -> None: """Similar to the OTEL SDK Span.record_exception method, with our own additions.""" + if is_starlette_http_exception_400(exception): + span.set_attributes(log_level_attributes('warn')) + # From https://opentelemetry.io/docs/specs/semconv/attributes-registry/exception/ # `escaped=True` means that the exception is escaping the scope of the span. # This means we know that the exception hasn't been handled, # so we can set the OTEL status and the log level to error. - if escaped: + elif escaped: set_exception_status(span, exception) span.set_attributes(log_level_attributes('error')) @@ -392,3 +395,12 @@ def set_exception_status(span: trace_api.Span, exception: BaseException): description=f'{exception.__class__.__name__}: {exception}', ) ) + + +def is_starlette_http_exception_400(exception: BaseException) -> bool: + if 'starlette.exceptions' not in sys.modules: # pragma: no cover + return False + + from starlette.exceptions import HTTPException + + return isinstance(exception, HTTPException) and 400 <= exception.status_code < 500 diff --git a/tests/otel_integrations/test_fastapi.py b/tests/otel_integrations/test_fastapi.py index 3c3c88bd..00ced5ea 100644 --- a/tests/otel_integrations/test_fastapi.py +++ b/tests/otel_integrations/test_fastapi.py @@ -8,7 +8,7 @@ import pytest from dirty_equals import IsJson from fastapi import BackgroundTasks, FastAPI, Response, WebSocket -from fastapi.exceptions import RequestValidationError +from fastapi.exceptions import HTTPException, RequestValidationError from fastapi.params import Header from fastapi.security import SecurityScopes from fastapi.staticfiles import StaticFiles @@ -23,6 +23,7 @@ import logfire._internal import logfire._internal.integrations import logfire._internal.integrations.fastapi +from logfire._internal.constants import LEVEL_NUMBERS from logfire._internal.main import set_user_attributes_on_raw_span from logfire.testing import TestExporter @@ -74,6 +75,10 @@ async def echo_body(request: Request): return await request.body() +async def bad_request_error(): + raise HTTPException(400) + + async def websocket_endpoint(websocket: WebSocket, name: str): logfire.info('websocket_endpoint: {name}', name=name) await websocket.accept() @@ -97,6 +102,7 @@ def app(): app.get('/other', name='other_route_name', operation_id='other_route_operation_id')(other_route) app.get('/exception')(exception) app.get('/validation_error')(validation_error) + app.get('/bad_request_error')(bad_request_error) app.get('/with_path_param/{param}')(with_path_param) app.get('/secret/{path_param}', name='secret')(get_secret) app.websocket('/ws/{name}')(websocket_endpoint) @@ -192,6 +198,14 @@ def test_404(client: TestClient, exporter: TestExporter) -> None: ) +def test_400(client: TestClient, exporter: TestExporter) -> None: + response = client.get('/bad_request_error') + assert response.status_code == 400 + + [span] = [span for span in exporter.exported_spans if span.events] + assert span.attributes and span.attributes['logfire.level_num'] == LEVEL_NUMBERS['warn'] + + def test_path_param(client: TestClient, exporter: TestExporter) -> None: response = client.get('/with_path_param/param_val') assert response.status_code == 200