# Recipe — Django app with `mgf.common.django`

> **Audience:** consumers running Django (4.2+) services that want
> structured observability, typed errors, per-request id propagation,
> and a `mgf-common explain`-compatible settings view — without
> rebuilding equivalent infrastructure.
> **Optional extra:** `pip install 'mgf-common[django]'`.

## Why this exists

Every Django shop builds parallel infrastructure: a custom JSON
formatter, a request-id middleware, a settings-bridge to read
config, and a startup hook to wire it all up. The shapes converge —
every shop ends up with roughly the same five pieces. `mgf-common`
ships those five pieces as `mgf.common.django`.

## At a glance

```python
# myapp/settings.py — additions to your existing Django settings

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

MIDDLEWARE = [
    "mgf.common.django.MgfRequestIdMiddleware",
    "mgf.common.django.MgfContextMiddleware",
    # ... your other middleware (security, sessions, csrf, ...) ...
]

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "json": {"()": "mgf.common.django.JsonFormatter"},
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "json",
        },
    },
    "root": {"handlers": ["console"], "level": "INFO"},
}

MGF_COMMON_APP_NAME = "myapp"
MGF_COMMON_APP_VERSION = "1.0.0"
```

That's the entire integration. What you get for free:

- `bootstrap()` runs exactly once when Django boots; identity,
  structured JSON logging, OTel (when configured), crash hooks all
  wired transactionally.
- Every request gets `X-Request-Id` (auto-generated UUID4 if not
  provided; forwarded if it is). Echoed in the response.
- The `request_id` contextvar is the SAME one
  `mgf.common.fastapi.RequestIdMiddleware` uses — a Django app
  calling a FastAPI service via internal RPC sees end-to-end
  correlation.
- Log records carry the redacted JSON shape `mgf-common` ships:
  no secret leaks, OTel `trace_id`/`span_id` when a span is active.
- `mgf-common explain` works: point it at a Django settings module
  via `DjangoSettingsBridge.from_django()` and get a typed,
  validated read of the live config.

## The five pieces

### 1. `MgfCommonConfig` — Django AppConfig

```python
# settings.py
INSTALLED_APPS = [
    # ... your apps ...
    "mgf.common.django",
]

MGF_COMMON_APP_NAME = "myapp" # optional; auto-derived if omitted
MGF_COMMON_APP_VERSION = "1.0.0" # optional; auto-derived if omitted
```

Django's `apps.populate()` discovers the AppConfig in
`mgf.common.django.apps` and calls `ready()` exactly once when the
app registry builds. The handler:

1. Reads `MGF_COMMON_APP_NAME` / `MGF_COMMON_APP_VERSION` from
   Django settings.
2. Calls `mgf.common.bootstrap()` and holds the context manager
   open on a module-level `ExitStack`.
3. Registers an `atexit` hook so the stack unwinds cleanly when
   the process exits (OTel flushes, log handlers detach, crash
   hooks reset).

The `ready()` is **idempotent** via a module-level guard. Django's
runserver `--noreload` and test fixtures that re-import the app
config will not double-bootstrap.

### 2. `MgfRequestIdMiddleware` — request-id propagation

```python
MIDDLEWARE = [
    "mgf.common.django.MgfRequestIdMiddleware",   # earliest
    "mgf.common.django.MgfContextMiddleware",
    "django.middleware.security.SecurityMiddleware",
    # ... your other middleware ...
]
```

Effect on every request:

- Inbound `X-Request-Id` (any case variant) → reused.
- Otherwise → generated as `str(uuid.uuid4())`.
- Set on `request.mgf_request_id`.
- Set on a contextvar — `mgf.common.fastapi.current_request_id()`
  returns it from any code path inside the request handler. Same
  contextvar used by FastAPI; mixed-framework processes get
  end-to-end correlation.
- Echoed in the response's `X-Request-Id` header.

Pair with the JSON formatter — emit `request_id` automatically in
every log line (the formatter pulls the contextvar via the
`%(request_id)s` injection that ships).

### 3. `MgfContextMiddleware` — `AppContext` per request

```python
MIDDLEWARE = [
    "mgf.common.django.MgfRequestIdMiddleware",
    "mgf.common.django.MgfContextMiddleware",
    # ... your other middleware ...
]
```

Reads the active `AppContext` (set by `MgfCommonConfig.ready()`)
and stashes it on `request.mgf_context`. Views can ask:

```python
def my_view(request):
    ctx = request.mgf_context
    if ctx is None:
        # Should not happen in production — bootstrap ran in ready().
        # Defensive fallback for tests.
        return HttpResponse(status=503)
    log.info("handling", extra={"app": ctx.app_name})
    # ...
```

If `MgfCommonConfig` hasn't run (e.g., a unit test that doesn't
load apps), `request.mgf_context` is set to `None` rather than
raising — so this middleware is safe in test contexts.

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

```python
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "json": {"()": "mgf.common.django.JsonFormatter"},
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "json",
        },
        "syslog": {
            "class": "logging.handlers.SysLogHandler",
            "formatter": "json",
            "address": "/dev/log",
        },
    },
    "root": {"handlers": ["console", "syslog"], "level": "INFO"},
}
```

The `'()'` syntax is Django's "callable factory" indicator —
Django constructs the formatter as
`mgf.common.django.JsonFormatter()`, no kwargs. The default
`Redactor` (mgf-common's built-in pattern groups: passwords,
tokens, API keys, etc.) is wired automatically.

The output schema is the same as `mgf.common.observability` — one
JSON object per line, with `ts` / `level` / `logger` / `msg` /
OTel `trace_id`+`span_id` when a span is active / `request_id`
from the contextvar / any `extra={...}` dict promoted to top-level
keys / `exception` when an exception was logged.

### 5. `DjangoSettingsBridge` — typed settings view

```python
from mgf.common.django import DjangoSettingsBridge

bridge = DjangoSettingsBridge.from_django()
print(bridge.debug) # bool
print(bridge.allowed_hosts) # tuple[str, ...]
print(bridge.installed_apps) # tuple[str, ...]
print(bridge.secret_key_present) # bool — never the actual key
print(bridge.mgf_app_name) # str | None
```

The bridge is a `BaseAppSettings` subclass — it works with
`mgf-common explain` and any other tooling that introspects
settings via the pydantic schema.

**Why `secret_key_present` instead of `secret_key`:** the bridge is
designed to be safe to dump (e.g., for debugging or `mgf-common
explain` output). Carrying `SECRET_KEY` would risk it leaking into
log output, a stack trace, or a support ticket. The presence
boolean answers "is it configured?" without ever exposing the value.

## Custom redaction

Want to add domain-specific redaction patterns (e.g., your tenant
ids look like API keys to the default redactor)? Subclass:

```python
# myapp/logging.py
from mgf.common.django import JsonFormatter as BaseJsonFormatter
from mgf.common.observability import Redactor

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

Then point Django's LOGGING at it:

```python
LOGGING = {
    "version": 1,
    "formatters": {
        "json": {"()": "myapp.logging.MyJsonFormatter"},
    },
    # ...
}
```

## Testing

Django has its own test client (`django.test.Client`) and test
runner. mgf-common's pytest plugin works alongside Django's
infrastructure — no special setup required for the adapter itself.

```python
# tests/test_my_view.py
from django.test import Client

def test_my_view():
    client = Client()
    response = client.get(
        "/users/",
        headers={"X-Request-Id": "test-correlation"},
    )

    assert response.status_code == 200
    assert response["X-Request-Id"] == "test-correlation"
```

For tests that need to assert the bootstrap state (e.g., capture
log records emitted during a request), use mgf-common's
`bootstrap_for_test` directly — it composes with Django's app
registry. See `docs/public/recipes/testing.md`.

## Common patterns

### Sentry alongside `bootstrap()`

If you already have Sentry initialized in `settings.py`:

```python
import sentry_sdk

sentry_sdk.init(dsn="https://...", environment="prod")
```

That's fine — `bootstrap()` doesn't replace Sentry. The two
coexist:

- Sentry handles error reporting + APM.
- `mgf-common` handles structured logging + OTel correlation +
  per-request id.

If you want `mgf-common`'s crash-sink dispatch to also forward to
Sentry, pass `crash_sinks=["sentry"]` via `MgfSettings` (see
`docs/public/06_crash_reporting.md`).

### Channels (async middleware)

The shipped middlewares are sync-only — the most common Django
shape. Django Channels and ASGI middleware support is on the
roadmap (); for now, run Channels-side request-id propagation
manually using `mgf.common.fastapi.current_request_id()` (the
contextvar is framework-agnostic).

### Multi-tenancy (`tenant_session`)

For multi-tenant Django apps (one Postgres DB, RLS isolation per
tenant), pair with `mgf.common.db.tenant_session` (from `[db]`
extra). The middleware sets up the tenant context per request:

```python
# myapp/middleware.py
from contextvars import ContextVar
from mgf.common.db 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)
```

(Full multi-tenant Django+Postgres recipe in
`docs/public/recipes/multi_tenant_settings.md`, shipping in Phase B.)

## 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

- `docs/public/recipes/db.md` — `mgf.common.db` for async
  SQLAlchemy + Postgres RLS multi-tenancy. Composes with Django
  if your service uses both ORMs.
- `docs/public/recipes/fastapi.md` — same primitives for FastAPI
  consumers; shares the `current_request_id()` contextvar with
  Django.
- `docs/public/05_observability.md` — JSON formatter + Redactor
  internals.
- Django docs (apps registry, AppConfig.ready, MIDDLEWARE order):
  https://docs.djangoproject.com/
