# Recipe — Django integration with `mgf.django`

> **Audience:** consumers building Django apps that want
> `mgf-common`'s observability + bootstrap + request-id stack
> wired in with one `INSTALLED_APPS` entry, two middlewares, one
> formatter.
> **Install:** `pip install mgf-django` (pulls `mgf-common` +
> `django>=4.2`).

## At a glance — the entire integration

```python
# settings.py
import os

INSTALLED_APPS = [
    # ... your apps ...
    "mgf.django",
]

MIDDLEWARE = [
    "mgf.django.MgfRequestIdMiddleware",  # earliest
    "mgf.django.MgfContextMiddleware",
    # ... your other middleware ...
]

# Production-shaped LOGGING. The load-bearing line is
# `disable_existing_loggers: False` — Django defaults it to True
# when omitted, which silences every logger Django ships
# (django.request, django.security, django.db.backends, ...).
# Your first symptom would be "where did my request log lines go?"
_use_json = os.environ.get("LOG_FORMAT", "dev" if DEBUG else "json") == "json"

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,  # KEEP Django's own loggers
    "formatters": {
        # prod: JSON for log aggregators (Loki / Datadog / Grafana)
        "json": {"()": "mgf.django.JsonFormatter"},
        # dev: human-readable console
        "dev": {"format": "[%(asctime)s] %(levelname)-7s %(name)s %(message)s"},
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "json" if _use_json else "dev",
        },
    },
    "root": {"handlers": ["console"], "level": "INFO"},
    "loggers": {
        # Shape Django's own loggers: 404s are noise, auth failures
        # are signal, SQL echo is debug-only.
        "django.request": {"handlers": ["console"], "level": "WARNING", "propagate": False},
        "django.security": {"handlers": ["console"], "level": "INFO", "propagate": False},
        "django.db.backends": {"handlers": ["console"], "level": "WARNING", "propagate": False},
    },
}

# Optional — both default to None (auto-derived from package metadata)
MGF_COMMON_APP_NAME = "myapp"
MGF_COMMON_APP_VERSION = "1.0.0"
```

What a `JsonFormatter` record looks like on the wire (one line;
wrapped here for readability) — these are the fields your
log-parser query targets:

```json
{"ts": "2026-07-10T12:00:00.123Z", "level": "INFO", "level_no": 20,
 "logger": "myapp.views", "msg": "request handled",
 "app": "myapp", "app_version": "1.0.0", "lang": "python",
 "host": "web-1", "module": "views", "func": "my_view", "line": 42,
 "thread": "MainThread", "process": 4711, "schema_version": "1.0.0"}
```

(Request-id / OTel trace correlation fields join the record when the
middlewares + OTel wiring are active. Full field reference:
`mgf.common.observability.JsonFormatter`.)

That's the whole adoption. Your `manage.py`, your `urls.py`, your
views — unchanged. The five primitives ship the integration shape
every Django service rebuilds by hand.

## What you get

### 1. `MgfCommonConfig` — bootstrap once

The `AppConfig` calls `mgf.common.bootstrap()` exactly once during
Django's app-registry build. Identity (app name + version), structured
logging, OTel wiring, crash-reporter hooks all install before your first
view runs.

The bootstrap is idempotent — multiple `ready()` calls (test
`override_settings`, autoreload) are no-ops via a module-level
guard. An `atexit` hook unwinds the bootstrap stack on process
shutdown so OTel flushes, log handlers detach, and crash hooks
reset cleanly.

### 2. `MgfRequestIdMiddleware` — `X-Request-Id` per request

Generates a UUID4 if the request didn't carry one, forwards if
it did. The id lives on:

- `request.mgf_request_id` — for view access.
- `mgf.common.current_request_id()` — for any code that doesn't have
  the request object (e.g., a service-layer helper).
- The response header `X-Request-Id` — so clients correlate too.

The contextvar is shared with `mgf.fastapi.RequestIdMiddleware`,
so a Django process calling a FastAPI service (via internal RPC,
SQS, etc.) propagates the same request id end-to-end (assuming the
upstream call sets the header — `mgf.http.AsyncHttpClient` does
this automatically when it picks up `current_request_id()`).

### 3. `MgfContextMiddleware` — `AppContext` on every request

```python
def my_view(request):
    ctx = request.mgf_context  # AppContext
    log.info("handling", extra={"app": ctx.app_name, "user": request.user.id})
```

Install AFTER `MgfRequestIdMiddleware` so the request id is bound to
the contextvar before the view runs. If the bootstrap hasn't run
yet (rare; typically only in tests that don't load the app),
`request.mgf_context` is `None` rather than raising.

### 4. `JsonFormatter` — Django LOGGING-compatible

The wrapper accepts no constructor args, so Django's
`'()': '...'` factory syntax works. Underneath it's the same
`mgf.common.observability.JsonFormatter` — same redaction, same
OTel correlation, same JSON schema.

To customise (e.g., add a project-specific secret pattern), subclass:

```python
from mgf.common.observability import Redactor
from mgf.django import JsonFormatter as BaseJsonFormatter

class MyJsonFormatter(BaseJsonFormatter):
    def __init__(self) -> None:
        super().__init__()
        self._redactor = Redactor(
            extra_keys=frozenset({"my_secret"}),
        )
```

Then point Django's `LOGGING` at `myapp.logging.MyJsonFormatter`.

### 5. `DjangoSettingsBridge` — typed view of django.conf.settings

`mgf-common explain` works for Django apps via this bridge. It
exposes a curated subset of `django.conf.settings` as a
`BaseAppSettings`-compatible object — typed, validated, and free of
the `SECRET_KEY` (only `secret_key_present: bool` is exposed; the
key itself never lands in the bridge).

```python
from mgf.django import DjangoSettingsBridge

bridge = DjangoSettingsBridge.from_django()
print(bridge.debug)
print(bridge.installed_apps)
print(bridge.secret_key_present)
```

## App label

Django app labels can't contain dots; this package uses
`"mgf_django"` as the label. **Renamed at the v0.31 sibling
extraction** from `"mgf_common"` (its label when the module lived
under `mgf.common.django` in mgf-common ≤ v0.30). Consumer code
that hardcoded the old label MUST update:

```python
from django.apps import apps

# Old (mgf-common ≤ v0.30):
config = apps.get_app_config("mgf_common")

# New (mgf-django v0.1+):
config = apps.get_app_config("mgf_django")
```

## Async views and middleware

The two middlewares ship in **sync-mode-only** form for v0.1.
That covers the common Django shape (WSGI, sync views, ASGI with
sync middleware fallback). Django's middleware framework wraps
sync middleware automatically when an async view is in play; the
performance cost is one synchronization-context-switch per
middleware per request — usually negligible compared to the view's
own work.

If a consumer hits real performance pressure on a heavily-async
deployment, file a paper and we'll evaluate adding async-mode
support (`async __call__` + `async_capable = True`).

## Multi-tenancy

For multi-tenant Django apps (one Postgres database, RLS isolation
per tenant), pair with `mgf.sqlalchemy.tenant_session` (from the
`mgf-sqlalchemy` sibling). The middleware sets up the tenant
context per request:

```python
# myapp/middleware.py
from contextvars import ContextVar
from mgf.sqlalchemy import tenant_session

class TenantMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        tenant_id = extract_tenant_from_request(request)
        # Wire into your session-per-request setup of choice.
        return self.get_response(request)
```

A full multi-tenant Django+Postgres recipe is in
`mgf-common/docs/recipes/multi_tenant_settings.md`.

## Anti-patterns

- **Don't call `mgf.common.bootstrap()` in `settings.py`.**
  Settings module loads multiple times (manage.py shells, asgi.py,
  wsgi.py, test runners). The `AppConfig.ready()` hook is the right
  place — it runs exactly once per process.
- **Don't add the middlewares mid-MIDDLEWARE.** Install
  `MgfRequestIdMiddleware` first so downstream middleware +
  Django's own SecurityMiddleware see the request id populated.
- **Don't dump `DjangoSettingsBridge` to a public log.** Even
  though `SECRET_KEY` is excluded, `INSTALLED_APPS` and other
  fields can be sensitive (third-party-app fingerprinting). Use
  the bridge for `mgf-common explain` output (operator-only) or
  diagnostic responses behind auth, not for public telemetry.
- **Don't initialize the bridge per-request.** It's a snapshot —
  construct once at module level, reuse the same instance.

## See also

- `mgf-fastapi/docs/recipes/fastapi.md` (sibling) — same
  primitives for FastAPI consumers; shares the
  `current_request_id()` contextvar with Django.
- `mgf-sqlalchemy/docs/recipes/sqlalchemy.md` (sibling) —
  `mgf.sqlalchemy` for async SQLAlchemy + Postgres RLS
  multi-tenancy.
- `mgf-common/docs/reference/observability.md` — JSON
  formatter + Redactor internals.
- Django docs (apps registry, AppConfig.ready, MIDDLEWARE order):
  https://docs.djangoproject.com/
