Skip to content

feat: add Django Ninja exception recording to instrument_django()#1746

Open
Br1an67 wants to merge 1 commit intopydantic:mainfrom
Br1an67:fix/django-ninja-traceback
Open

feat: add Django Ninja exception recording to instrument_django()#1746
Br1an67 wants to merge 1 commit intopydantic:mainfrom
Br1an67:fix/django-ninja-traceback

Conversation

@Br1an67
Copy link

@Br1an67 Br1an67 commented Mar 1, 2026

Summary

Integrates Django Ninja exception recording into instrument_django() via a new instrument_ninja=True parameter (enabled by default).

Django Ninja catches exceptions before they propagate to Django's middleware, preventing OpenTelemetry's Django instrumentation from recording them. This patch hooks NinjaAPI.on_exception at the class level to record exceptions on the current span.

Closes #1742

Changes

  • logfire/_internal/integrations/django.py: Added _instrument_django_ninja() which patches NinjaAPI.on_exception on the class. Silently skipped if django-ninja is not installed. Uses _LOGFIRE_INSTRUMENTED constant for double-instrumentation guard.
  • logfire/_internal/main.py: Added instrument_ninja: bool = True parameter to instrument_django(). Removed separate instrument_django_ninja() method.
  • Type stubs: Updated instrument_django signature with instrument_ninja parameter.
  • Tests: 7 tests covering good route, handled errors, unhandled errors, double-instrumentation guard, and head_sample_rate=0 branches.
  • Removed django-ninja optional extra (users instrumenting Django Ninja are expected to have it installed).

Key Design Decisions

  • Patches at the class level so it works without arguments (for logfire run)
  • Correctly sets escaped=True when Django Ninja re-raises unhandled exceptions, escaped=False when handled
  • No separate API method — integrated into the existing instrument_django() flow

devin-ai-integration[bot]

This comment was marked as resolved.

@Br1an67 Br1an67 force-pushed the fix/django-ninja-traceback branch from 0f27bc5 to 6fb28cd Compare March 1, 2026 07:35
@codecov
Copy link

codecov bot commented Mar 1, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!


from opentelemetry.trace import get_current_span

original_on_exception = api.on_exception
Copy link
Contributor

Choose a reason for hiding this comment

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

  1. We need this to work without requiring passing arguments, for the logfire run command. If you think it's useful to be able to specify api instead of instrumenting everything globally, then that can be an option, but it's not essential. In this case, that means patching NinjaAPI.on_exception on the class.
  2. Please make this part of instrument_django instead of adding another method and requiring users to call both.
  3. Please mention this in the django docs.

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

from opentelemetry.trace import get_current_span
Copy link
Contributor

Choose a reason for hiding this comment

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

move to top

span.record_exception(exc, escaped=False)
return response

patched_on_exception._logfire_instrumented = True # type: ignore[attr-defined]
Copy link
Contributor

Choose a reason for hiding this comment

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

use constant for consistency

try:
response = original_on_exception(request, exc)
except Exception:
if span.is_recording(): # pragma: no branch
Copy link
Contributor

Choose a reason for hiding this comment

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

test these branches by setting the head sample rate to 0

pyproject.toml Outdated
aiohttp-server = ["opentelemetry-instrumentation-aiohttp-server >= 0.55b0"]
celery = ["opentelemetry-instrumentation-celery >= 0.42b0"]
django = ["opentelemetry-instrumentation-django >= 0.42b0", "opentelemetry-instrumentation-asgi >= 0.42b0"]
django-ninja = ["django-ninja >= 1.0.0"]
Copy link
Contributor

Choose a reason for hiding this comment

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

this extra isn't needed, we assume that users instrumenting a package have that package. same goes for catching the import error.

…go()

Restructure the Django Ninja integration per review feedback:

- Move ninja patching into instrument_django() with instrument_ninja=True default
- Patch NinjaAPI.on_exception at the class level (not per-instance) so it
  works without arguments for `logfire run`
- Add double-instrumentation guard using _LOGFIRE_INSTRUMENTED constant
- Correctly set escaped=True/False based on whether exception is re-raised
- Silently skip if django-ninja is not installed
- Remove separate instrument_django_ninja() method, public API, type stubs
- Remove django-ninja optional extra (users are expected to have it)
- Add tests with head_sample_rate=0 to verify is_recording() branches
@Br1an67 Br1an67 force-pushed the fix/django-ninja-traceback branch from 8989dc6 to 9072f0c Compare March 3, 2026 15:07
@Br1an67 Br1an67 changed the title feat: add Django Ninja integration to capture exceptions on spans feat: add Django Ninja exception recording to instrument_django() Mar 3, 2026
@Br1an67
Copy link
Author

Br1an67 commented Mar 3, 2026

Thanks for the detailed review! I've restructured the PR completely based on your feedback:

  1. No separate method — ninja patching is now part of instrument_django(instrument_ninja=True) (enabled by default)
  2. Class-level patching — patches NinjaAPI.on_exception on the class, works without arguments for logfire run
  3. Imports at top levelget_current_span imported at module top
  4. _LOGFIRE_INSTRUMENTED constant used for double-instrumentation guard
  5. head_sample_rate=0 tests added to verify is_recording() branches
  6. Removed django-ninja extra and ImportError catch — silently skips if not installed

Deleted the separate instrument_django_ninja() method, its API stubs, and public exports entirely.

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 8 additional findings in Devin Review.

Open in Devin Review

Comment on lines +68 to +70
except Exception:
if span.is_recording():
span.record_exception(exc, escaped=True)
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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants