# Recipe — Cloud-native logging (stdout-only deployment)

> **Audience:** Web/API consumers deploying via container
> orchestrator (Kubernetes, Docker Compose, Heroku, Cloud Run,
> Fly.io, Infomaniak Public Cloud, etc.) where the platform
> captures stdout/stderr and forwards to an aggregator
> (Grafana, Datadog, CloudWatch, Promtail+Loki, etc.).

This recipe pairs with **LG-06**'s cloud-native branch. Closes
FEEDBACK LOG-01: rotating-file logging is the wrong shape for
ephemeral container filesystems; the platform IS the rotating
file sink.

---

## When to use this recipe

Choose the cloud-native branch when:

- The deployment runtime captures stdout/stderr and forwards
  it to an aggregator. Standard for every container
  orchestrator and all the major PaaS platforms.
- The filesystem the process writes to is **ephemeral** (a
  container restart wipes any rotated log files).
- The aggregator query UI is the operator's primary tool for
  reading logs.

Choose the rotating-file branch (the original LG-06 shape) when:

- Desktop / single-server deploy with no platform aggregator.
- Persistent local disk + an operator who SSHes in to tail
  files.
- Air-gapped / fully-offline deployments.

A consumer MAY mix branches — cloud-native pods writing to
stdout AND opt-in OTLP for the long-tail ERROR records. Both
sinks reach an aggregator; the rule is satisfied either way.

---

## The shape — `MgfSettings` for cloud-native

```python
from mgf.common import bootstrap
from mgf.common.settings import MgfSettings, LoggingSettings

with bootstrap(
    settings=MgfSettings.production(
        logging=LoggingSettings(
            level="INFO",
            format="json",      # always JSON in containers — aggregator parses
            file=None,          # NO rotating file — stdout is the sink
            journald=False,     # leave to the orchestrator
        ),
    ),
) as ctx:
    ...
```

Three things matter:

1. **`format="json"`** — every log line is a JSON object on
   stdout. The aggregator parses; humans `grep` via the
   aggregator UI.
2. **`file=None`** — no rotating file handler. Don't pollute
   the container's ephemeral filesystem.
3. **`journald=False`** — even on systemd-based runtimes, let
   the orchestrator route stdout. Adding journald creates two
   log paths and wastes the runtime's capture infrastructure.

---

## What stdout JSON lines look like

```json
{"ts": "2026-05-06T04:12:33.456Z", "level": "INFO", "logger": "myapp.api.handlers", "message": "request handled", "request_id": "a1b2c3", "trace_id": "0afa...", "span_id": "9b3f...", "duration_ms": 41}
```

Every record carries:

- `ts` — RFC 3339 timestamp.
- `level` — string, not int (matches what aggregators expect).
- `logger` — hierarchical name (`myapp.api.handlers`).
- `message` — human-readable text. Lazy %-formatted at the
  call site; the formatter inlines.
- `trace_id` / `span_id` — present when an OTel span is active.
- Custom `extra={...}` keys — promoted to top-level fields by
  `JsonFormatter`.

This is what the aggregator indexes. The shape is stable; LG-09
(`extra={...}` not message-concatenation) is what makes it work.

---

## Deployment-time check — "the platform IS my rotating file sink"

Before deploying:

1. **Run a smoke locally.** `python -m yourapp 2>&1 | head -5`
   should produce JSON lines, one per record, no rotating-file
   creation under `~/.local/state/yourapp/logs/`.
2. **Verify in the orchestrator.** After the first deploy:
   - Kubernetes: `kubectl logs <pod>` shows JSON.
   - Docker Compose: `docker compose logs <service>` shows JSON.
   - Cloud Run / Fly.io: their console shows the JSON.
   - Heroku: `heroku logs --tail` shows the JSON.
3. **Verify in the aggregator.** Search for a known log line in
   Grafana / Datadog / CloudWatch. Confirm the structured fields
   (level, logger, request_id) are queryable.

If any of those three steps fail, the runtime isn't routing
stdout to the aggregator the way you expected. The fix is
deploy-side, not config-side — confirm the orchestrator's
log driver is configured (`docker run --log-driver=...`) or
the platform's stdout-capture is enabled (most are by default).

---

## What you give up vs. the rotating-file branch

- **No fallback when the aggregator is down.** Stdout still
  works (the runtime buffers it), but operator-side `journalctl`-
  style local tailing isn't available unless you run a sidecar.
  For most cloud-native deploys this is acceptable; the
  aggregator's uptime is part of the platform's SLA.
- **No log-viewer dialog.** LG-07's GUI dialog rule is
  `applies_to: ["gui-app"]` — N/A for web/API consumers
  anyway (closed by FEEDBACK LOG-02).
- **Crash reports still write to disk.** `mgf.common.observability.report_now`
  writes JSON files to `<state>/crashes/`. For ephemeral
  containers, configure `MgfSettings.crash.sinks=["http"]`
  or `["otlp"]` to ship the crash to the aggregator instead.
  See `docs/public/06_crash_reporting.md` for the OTel /
  Sentry / HTTP webhook patterns.

---

## Worked example — Django + Daphne + Promtail + Grafana

`inthewords` deploys on Infomaniak Public Cloud (3 VMs behind a
load balancer):

1. **Application processes** — Django ASGI via Daphne, Celery
   workers — log JSON to stdout via the standard `JsonFormatter`.
2. **systemd journal** — captures stdout from every process
   under the service unit.
3. **Promtail agents** — read `/var/log/journal/...` and ship
   to Grafana Cloud's Loki instance.
4. **Grafana** — operator queries via LogQL; alerts fire on
   ERROR-level patterns + structured-field thresholds.

Zero file sinks under `<state>/logs/`. No rotation configured.
LG-06 is satisfied via the *stdout-aggregator* branch.

The `MgfSettings` for this deploy:

```python
# config/settings/prod.py
from mgf.common.settings import MgfSettings, LoggingSettings

MGF_COMMON_SETTINGS = MgfSettings.production(
    logging=LoggingSettings(
        level="INFO",
        format="json",
        file=None,            # journald + Promtail handle aggregation
        journald=False,       # systemd captures stdout already
    ),
)
```

`apps/core/apps.py::CoreConfig.ready()` calls
`mgf.common.bootstrap(settings=MGF_COMMON_SETTINGS)` once per
process; every `LOG.info(...)` call lands as JSON on stdout
within ~50µs (LG-12) and is queryable in Grafana within ~3s.

---

## Cross-references

- [`docs/standards/LOGGING.md`](../../standards/LOGGING.md)
  — the LG-* rules. LG-06 is the rule this recipe satisfies.
- [`docs/public/05_observability.md`](../05_observability.md)
  — the full logging + OTel + Redactor walkthrough.
- [`docs/public/06_crash_reporting.md`](../06_crash_reporting.md)
  — crash-report shipping (the disk-write path that survives
  cloud-native deploys via remote sinks).
- [`docs/cutover/v0.18.0.md`](../../cutover/v0.18.0.md) §LOG-01
  — the closure record for the FEEDBACK entry that motivated
  this recipe.
