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
2 changes: 1 addition & 1 deletion logfire-api/logfire_api/_internal/main.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,7 @@ class Logfire:
Args:
**kwargs: Additional keyword arguments to pass to the OpenTelemetry `instrument` method, for future compatibility.
"""
def instrument_django(self, capture_headers: bool = False, is_sql_commentor_enabled: bool | None = None, request_hook: Callable[[trace_api.Span, HttpRequest], None] | None = None, response_hook: Callable[[trace_api.Span, HttpRequest, HttpResponse], None] | None = None, excluded_urls: str | None = None, **kwargs: Any) -> None:
def instrument_django(self, capture_headers: bool = False, is_sql_commentor_enabled: bool | None = None, request_hook: Callable[[trace_api.Span, HttpRequest], None] | None = None, response_hook: Callable[[trace_api.Span, HttpRequest, HttpResponse], None] | None = None, excluded_urls: str | None = None, instrument_ninja: bool = True, **kwargs: Any) -> None:
"""Instrument `django` so that spans are automatically created for each web request.

Uses the
Expand Down
39 changes: 38 additions & 1 deletion logfire/_internal/integrations/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Any, Callable

from django.http import HttpRequest, HttpResponse
from opentelemetry.trace import Span
from opentelemetry.trace import Span, get_current_span

from logfire._internal.utils import maybe_capture_server_headers

Expand All @@ -16,6 +16,8 @@
" pip install 'logfire[django]'"
)

_LOGFIRE_INSTRUMENTED = '_logfire_instrumented'


def instrument_django(
*,
Expand All @@ -24,6 +26,7 @@ def instrument_django(
excluded_urls: str | None,
request_hook: Callable[[Span, HttpRequest], None] | None,
response_hook: Callable[[Span, HttpRequest, HttpResponse], None] | None,
instrument_ninja: bool,
**kwargs: Any,
) -> None:
"""Instrument the `django` module so that spans are automatically created for each web request.
Expand All @@ -38,3 +41,37 @@ def instrument_django(
response_hook=response_hook,
**kwargs,
)
if instrument_ninja:
_instrument_django_ninja()


def _instrument_django_ninja() -> None:
"""Patch NinjaAPI.on_exception at the class level to record exceptions on spans.

Django Ninja catches exceptions before they propagate to Django's middleware,
which prevents OpenTelemetry's Django instrumentation from recording them.
"""
try:
from ninja import NinjaAPI
except ImportError:
return

if getattr(NinjaAPI.on_exception, _LOGFIRE_INSTRUMENTED, False):
return

original_on_exception = NinjaAPI.on_exception

def patched_on_exception(self: Any, request: HttpRequest, exc: Exception) -> HttpResponse:
span = get_current_span()
try:
response = original_on_exception(self, request, exc)
except Exception:
if span.is_recording():
span.record_exception(exc, escaped=True)
Comment on lines +68 to +70
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Wrong exception recorded when a Django Ninja exception handler itself raises

In patched_on_exception, the except Exception block always records the original exc parameter, but the exception that actually escapes may be different.

Root Cause

Django Ninja's on_exception (ninja/main.py) can raise a different exception than exc when a registered exception handler itself fails:

def on_exception(self, request, exc):
    handler = self._lookup_exception_handler(exc)
    if handler is None:
        raise exc          # same as exc — OK
    return handler(request, exc)  # handler could raise something else!

In the patched version at logfire/_internal/integrations/django.py:68-70:

except Exception:
    if span.is_recording():
        span.record_exception(exc, escaped=True)   # always records exc
    raise                                           # re-raises the *actual* exception

If handler(request, exc) raises a ValueError, the code records the original exc (e.g. HttpError) on the span with escaped=True, but the raise propagates the ValueError. The trace shows the wrong exception type and message.

The fix is to capture and record the actually-raised exception:

except Exception as raised_exc:
    if span.is_recording():
        span.record_exception(raised_exc, escaped=True)
    raise

Impact: Users inspecting spans would see a misleading exception type/message that doesn't match the actual error that propagated.

Suggested change
except Exception:
if span.is_recording():
span.record_exception(exc, escaped=True)
except Exception as raised_exc:
if span.is_recording():
span.record_exception(raised_exc, escaped=True)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

raise
if span.is_recording():
span.record_exception(exc, escaped=False)
return response

patched_on_exception._logfire_instrumented = True # type: ignore[attr-defined]
NinjaAPI.on_exception = patched_on_exception # type: ignore[assignment]
7 changes: 7 additions & 0 deletions logfire/_internal/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1603,6 +1603,7 @@ def instrument_django(
request_hook: Callable[[trace_api.Span, HttpRequest], None] | None = None,
response_hook: Callable[[trace_api.Span, HttpRequest, HttpResponse], None] | None = None,
excluded_urls: str | None = None,
instrument_ninja: bool = True,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Behavioral change: instrument_ninja=True by default for all existing instrument_django() callers

The instrument_ninja parameter defaults to True at logfire/_internal/main.py:1606. This means all existing users calling logfire.instrument_django() will now automatically get Django Ninja's NinjaAPI.on_exception monkey-patched at the class level if django-ninja is installed. While the ImportError is silently caught at logfire/_internal/integrations/django.py:56, users who DO have Django Ninja installed but don't use it with Logfire may be surprised by the class-level patching. This is likely intentional (as stated in the PR description and docstring), but worth a reviewer confirming this opt-out vs opt-in decision.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

**kwargs: Any,
) -> None:
"""Instrument `django` so that spans are automatically created for each web request.
Expand Down Expand Up @@ -1631,6 +1632,11 @@ def instrument_django(

excluded_urls: A string containing a comma-delimited list of regexes used to exclude URLs from tracking.

instrument_ninja: Set to `True` (the default) to also instrument Django Ninja's
`NinjaAPI.on_exception` so that exceptions caught by Django Ninja are recorded
on OpenTelemetry spans. Requires `django-ninja` to be installed; silently
skipped if not available.

**kwargs: Additional keyword arguments to pass to the OpenTelemetry `instrument` method,
for future compatibility.

Expand All @@ -1644,6 +1650,7 @@ def instrument_django(
request_hook=request_hook,
response_hook=response_hook,
excluded_urls=excluded_urls,
instrument_ninja=instrument_ninja,
**{
'tracer_provider': self._config.get_tracer_provider(),
'meter_provider': self._config.get_meter_provider(),
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ dev = [
"fastapi != 0.124.3, != 0.124.4",
"Flask >= 3.0.3",
"django >= 4.2.16",
"django-ninja >= 1.0.0",
"dirty-equals >= 0.8.0",
"pytest >= 8.3.4",
"pytest-django >= 4.6.0",
Expand Down Expand Up @@ -312,6 +313,8 @@ filterwarnings = [
"ignore:Call to 'get_connection' function with deprecated usage of input argument/s.*:DeprecationWarning",
# OpenTelemetry event logger stuff being used by Pydantic AI
"ignore:You should use `\\w*(L|l)ogger\\w*` instead. Deprecated since version 1.39.0 and will be removed in a future release.:DeprecationWarning",
# django-ninja uses deprecated asyncio.iscoroutinefunction on Python 3.14+
"ignore:'asyncio.iscoroutinefunction' is deprecated.*:DeprecationWarning",
]
DJANGO_SETTINGS_MODULE = "tests.otel_integrations.django_test_project.django_test_site.settings"

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from django.core.exceptions import BadRequest
from django.http import HttpRequest, HttpResponse
from ninja import NinjaAPI
from ninja.errors import HttpError

ninja_api = NinjaAPI(urls_namespace='ninja')


def detail(_request: HttpRequest, item_id: int) -> HttpResponse:
Expand All @@ -8,3 +12,18 @@ def detail(_request: HttpRequest, item_id: int) -> HttpResponse:

def bad(_request: HttpRequest) -> HttpResponse:
raise BadRequest('bad request')


@ninja_api.get('/good/')
def ninja_good(request: HttpRequest) -> dict[str, str]:
return {'message': 'ok'}


@ninja_api.get('/error/')
def ninja_error(request: HttpRequest) -> dict[str, str]:
raise HttpError(400, 'ninja error')


@ninja_api.get('/unhandled/')
def ninja_unhandled(request: HttpRequest) -> dict[str, str]:
raise RuntimeError('unhandled ninja error')
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from django.contrib import admin
from django.urls import include, path # type: ignore

from tests.otel_integrations.django_test_project.django_test_app.views import ninja_api

urlpatterns = [
path('django_test_app/', include('django_test_app.urls')),
path('ninja/', ninja_api.urls),
path('admin/', admin.site.urls),
]
128 changes: 128 additions & 0 deletions tests/otel_integrations/test_django_ninja.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import pytest
from django.http import HttpResponse
from django.test import Client
from inline_snapshot import snapshot

import logfire
from logfire.testing import TestExporter
from tests.otel_integrations.django_test_project.django_test_app.views import ninja_api


@pytest.fixture(autouse=True)
def _restore_ninja_api(): # pyright: ignore[reportUnusedFunction]
"""Restore the original on_exception method after each test."""
from ninja import NinjaAPI

original = NinjaAPI.on_exception
yield
NinjaAPI.on_exception = original


def test_ninja_good_route(client: Client, exporter: TestExporter):
logfire.instrument_django()
response: HttpResponse = client.get('/ninja/good/') # type: ignore
assert response.status_code == 200

spans = exporter.exported_spans_as_dict(parse_json_attributes=True)
assert len(spans) == 1
# No exception events for successful requests
assert 'events' not in spans[0] or spans[0].get('events') == []


def test_ninja_error_route_without_instrumentation(client: Client, exporter: TestExporter):
"""Without instrument_ninja, handled exceptions are NOT recorded on spans."""
logfire.instrument_django(instrument_ninja=False)
response: HttpResponse = client.get('/ninja/error/') # type: ignore
assert response.status_code == 400

spans = exporter.exported_spans_as_dict(parse_json_attributes=True)
assert len(spans) == 1
# No exception events — Django Ninja handled the exception before OTel could see it
assert spans[0].get('events') is None or spans[0].get('events') == []


def test_ninja_error_route_with_instrumentation(client: Client, exporter: TestExporter):
"""With instrument_ninja=True (default), handled exceptions ARE recorded on spans."""
logfire.instrument_django()
response: HttpResponse = client.get('/ninja/error/') # type: ignore
assert response.status_code == 400

spans = exporter.exported_spans_as_dict(parse_json_attributes=True)
assert len(spans) == 1

# Verify the exception was recorded on the span
events = spans[0].get('events', [])
exception_events = [e for e in events if e['name'] == 'exception']
assert len(exception_events) == 1
assert exception_events[0]['attributes']['exception.type'] == 'ninja.errors.HttpError'
assert exception_events[0]['attributes']['exception.message'] == 'ninja error'
assert exception_events[0]['attributes']['exception.escaped'] == 'False'


def test_ninja_unhandled_error_with_instrumentation(client: Client, exporter: TestExporter):
"""Unhandled exceptions (RuntimeError) are also recorded on spans."""
logfire.instrument_django()
client.raise_request_exception = False
response: HttpResponse = client.get('/ninja/unhandled/') # type: ignore
assert response.status_code == 500

spans = exporter.exported_spans_as_dict(parse_json_attributes=True)
assert len(spans) == 1

events = spans[0].get('events', [])
exception_events = [e for e in events if e['name'] == 'exception']
# At least one exception event should be recorded (could be 2: our hook + OTel middleware)
assert len(exception_events) >= 1
assert any(e['attributes']['exception.type'] == 'RuntimeError' for e in exception_events)
assert any(e['attributes']['exception.message'] == 'unhandled ninja error' for e in exception_events)
# Our hook records escaped=True because Django Ninja re-raises unhandled exceptions
our_events = [
e
for e in exception_events
if e['attributes']['exception.type'] == 'RuntimeError' and e['attributes'].get('exception.escaped') == 'True'
]
assert len(our_events) >= 1


def test_double_instrumentation(client: Client, exporter: TestExporter):
"""Calling instrument_django twice should not double-wrap on_exception."""
logfire.instrument_django()
logfire.instrument_django()
response: HttpResponse = client.get('/ninja/error/') # type: ignore
assert response.status_code == 400

spans = exporter.exported_spans_as_dict(parse_json_attributes=True)
assert len(spans) == 1
events = spans[0].get('events', [])
exception_events = [e for e in events if e['name'] == 'exception']
# Should only record the exception once, not twice
assert len(exception_events) == 1


def test_ninja_error_with_head_sample_rate_zero(client: Client, exporter: TestExporter):
"""When head_sample_rate=0, spans are not recording and exceptions should not be recorded."""
from logfire.sampling import SamplingOptions

logfire.configure(sampling=SamplingOptions(head=0.0))
logfire.instrument_django()
response: HttpResponse = client.get('/ninja/error/') # type: ignore
assert response.status_code == 400

# No spans should be exported when sampling rate is 0
spans = exporter.exported_spans_as_dict(parse_json_attributes=True)
assert len(spans) == 0


def test_ninja_unhandled_error_with_head_sample_rate_zero(client: Client, exporter: TestExporter):
"""When head_sample_rate=0, unhandled exceptions should still propagate correctly."""
from logfire.sampling import SamplingOptions

logfire.configure(sampling=SamplingOptions(head=0.0))
logfire.instrument_django()
client.raise_request_exception = False
response: HttpResponse = client.get('/ninja/unhandled/') # type: ignore
assert response.status_code == 500

# No spans should be exported when sampling rate is 0
spans = exporter.exported_spans_as_dict(parse_json_attributes=True)
assert len(spans) == 0
27 changes: 24 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading