# Tutorial — building a real app with `mgf-common`

> **Audience:** anyone evaluating `mgf-common`, or a maintainer
> picking it up for the first time. Walks zero → working app
> end-to-end. ~30 min start to finish.

This tutorial builds a small but real CLI tool —
`telemetry-exporter` — that reads structured events from a JSON
file, runs them through a redactor, and emits OpenTelemetry
spans for downstream collection. Each step introduces one
piece of the library.

The endpoints we'll touch, in order:

1. **Bootstrap** — wire identity + logging + OTel + crash
   reporter in one call.
2. **Settings** — declare typed configuration with env / dotenv
   / pyproject layering.
3. **Typed exceptions** — replace ad-hoc error strings with the
   `AppError` hierarchy.
4. **Structured logging** — `get_logger()` + `extra={...}` for
   emitting to JSON sinks.
5. **OpenTelemetry tracing** — `operation_span()` so every
   exported event has a trace id.
6. **Crash reporting** — `report_now()` + the excepthooks for
   unhandled-exception handling.
7. **Versioned config files** — `VersionedFileStore[T]` for
   the ruleset file the tool reads.
8. **CLI dispatch** — exit-code translation + subcommand
   registration via `mgf.common.cli`.
9. **The four gates** — what passes / fails, and why each one
   matters.

If you only have ~5 minutes, read steps 1, 3, and 9. They cover
80% of the value.

---

## Setup

```bash
mkdir telemetry-exporter && cd telemetry-exporter

# Initialise from the cookiecutter template (or use init-project
# inside an empty directory — both work).
cookiecutter gh:magogi-admin/mgf_common --directory templates/cookiecutter-mgf-project
# Prompt answers: project_name="telemetry-exporter",
#                 description="Read events, redact, emit OTel spans.",
#                 license="MIT".

cd telemetry-exporter
git init
uv sync
```

Run the smoke test to confirm the scaffold works:

```bash
uv run pytest
# tests/test_smoke.py::test_version_present PASSED
# tests/test_smoke.py::test_bootstrap_round_trip PASSED
```

You should now have:

```
telemetry-exporter/
├── pyproject.toml
├── src/telemetry_exporter/
│   ├── __init__.py
│   ├── __main__.py
│   └── py.typed
├── tests/
│   ├── conftest.py
│   └── test_smoke.py
├── docs/CLAUDE.md
└── .claude/settings.json
```

---

## Step 1 — Bootstrap

The single canonical entry point. Wires identity, logging,
OTel, and crash hooks transactionally with LIFO rollback on
failure.

`src/telemetry_exporter/__main__.py` already calls
`bootstrap()`. Run it:

```bash
uv run python -m telemetry_exporter
```

Output:

```
hello from telemetry_exporter
```

Watch what happens with no arguments — `bootstrap()` derived
the `app_name` and `app_version` from the package's
distribution metadata. No identity wiring required.

For ad-hoc scripts (no console-script entry), pass identity
explicitly:

```python
with mgf.common.bootstrap(
    app_name="telemetry-exporter",
    app_version="0.1.0",
):
    ...
```

→ Read more: [`01_lifecycle.md`](01_lifecycle.md).

---

## Step 2 — Settings

Declare typed configuration. Add `src/telemetry_exporter/settings.py`:

```python
"""telemetry-exporter settings — composed with mgf-common's MgfSettings."""

from __future__ import annotations

from typing import Annotated

from mgf.common.config import BaseAppSettings, settings_config
from mgf.common.settings import MgfSettings
from pydantic import Field


class Settings(BaseAppSettings):
    """Top-level settings for telemetry-exporter."""

    model_config = settings_config(env_prefix="TELEMETRY_EXPORTER_")

    # Domain-specific fields.
    events_path: str = "events.json"
    rules_path: str = "rules.yaml"
    batch_size: Annotated[int, Field(ge=1, le=10_000)] = 100

    # mgf-common's settings as a sub-field.
    mgf: MgfSettings = Field(default_factory=MgfSettings)
```

Operators set things via env or pyproject:

```bash
# Env
export TELEMETRY_EXPORTER_BATCH_SIZE=500
export TELEMETRY_EXPORTER_MGF__LOGGING__LEVEL=DEBUG

# Or pyproject.toml
[tool.telemetry-exporter]
batch_size = 500

[tool.telemetry-exporter.mgf.logging]
level = "DEBUG"
```

`Settings()` reads from env > dotenv > pyproject > defaults.

→ Read more: [`04_settings.md`](04_settings.md).

---

## Step 3 — Typed exceptions

Add `src/telemetry_exporter/exceptions.py`:

```python
"""telemetry-exporter exception hierarchy.

Subclasses mgf.common.exceptions per rule EH-01 — so the CLI
exit-code translator routes errors correctly without per-class
wiring.
"""

from __future__ import annotations

from mgf.common.exceptions import AppError, ConfigError, OperationError


class TelemetryExporterError(AppError):
    """Root for every error raised by telemetry-exporter."""


class RulesetError(ConfigError, TelemetryExporterError):
    """The rules file is missing, malformed, or schema-invalid."""


class EventBatchError(OperationError, TelemetryExporterError):
    """A batch of events failed to export.

    Carries the failing batch + cause so the CLI can surface
    actionable context.
    """

    def __init__(self, batch_index: int, count: int, cause: str) -> None:
        self.batch_index = batch_index
        self.count = count
        self.cause = cause
        super().__init__(
            f"batch #{batch_index} ({count} events) failed: {cause}"
        )
```

Now domain code raises typed errors:

```python
def load_rules(path: Path) -> list[Rule]:
    if not path.exists():
        raise RulesetError(f"rules file not found: {path}")
    ...
```

The CLI exit-code translator (step 8) maps each category to a
distinct exit code. No per-class wiring needed.

→ Read more: [`02_exceptions.md`](02_exceptions.md).

---

## Step 4 — Structured logging

Replace `LOG.info("hello")` with structured records. Open
`__main__.py` and update:

```python
from mgf.common.observability import get_logger

LOG = get_logger(__name__)


def do_work(settings: Settings) -> int:
    LOG.info(
        "starting",
        extra={
            "events_path": settings.events_path,
            "batch_size": settings.batch_size,
        },
    )
    ...
```

Run with `MGF_LOGGING_FORMAT=json` to see the JSON output:

```bash
MGF_LOGGING_FORMAT=json uv run python -m telemetry_exporter
```

```json
{"ts":"2026-05-01T12:34:56.789Z","level":"INFO","logger":"telemetry_exporter.__main__","msg":"starting","events_path":"events.json","batch_size":100,"app":"telemetry-exporter","app_version":"0.1.0"}
```

The redactor scrubs sensitive values automatically. Pass a
secret-shaped key (`api_key`, `password`, `token`, etc.) and
its value is replaced with `<redacted>`.

→ Read more: [`05_observability.md`](05_observability.md).

---

## Step 5 — OpenTelemetry tracing

Wrap each batch in an `operation_span`:

```python
from mgf.common.observability import operation_span


def export_batch(events: list[Event], batch_index: int) -> None:
    with operation_span(
        "telemetry_exporter.export_batch",
        batch_index=batch_index,
        event_count=len(events),
    ) as span:
        try:
            ...
            span.set_attribute("export.success", True)
        except Exception as exc:
            span.set_attribute("export.success", False)
            raise EventBatchError(batch_index, len(events), str(exc)) from exc
```

When OTel is configured (`MGF_OTEL_ENABLED=true` +
`OTEL_EXPORTER_OTLP_ENDPOINT=http://...`), every span exports.
Without OTel, `operation_span` is a no-op.

→ Read more: [`05_observability.md`](05_observability.md) §"OpenTelemetry".

---

## Step 6 — Crash reporting

Bootstrap already installed `sys.excepthook` and
`threading.excepthook` — every unhandled exception writes a
crash report under `<state>/telemetry-exporter/crashes/`.

Crash reports land at `<state>/<app>/crashes/` —
`<state>` resolves to `$XDG_STATE_HOME` if set, else
`~/.local/state`. For `telemetry-exporter` that's:

```
~/.local/state/telemetry-exporter/crashes/
```

(A future release will add public `default_crash_dir()` /
`default_log_path()` helpers — tracked as `FEEDBACK.md`
`PAPER-07`.)

For a manual crash report (e.g., from a long-running worker
that catches an exception, logs it, and continues):

```python
from mgf.common.observability import report_now

try:
    risky_work()
except SomeRecoverableError as exc:
    report_now(exc, source="worker.batch_loop")
    # ... continue with the next batch ...
```

Each report is a JSON file with the exception, traceback, app
identity, OTel trace context (if active), and any custom
`source=` you pass.

→ Read more: [`06_crash_reporting.md`](06_crash_reporting.md).

---

## Step 7 — Versioned config files

The tool reads a `rules.yaml` ruleset file. Define the schema
+ wire a `VersionedFileStore`:

```python
"""src/telemetry_exporter/rules.py"""

from __future__ import annotations

from pathlib import Path

from mgf.common.versioned import VersionedFileStore
from pydantic import BaseModel, ConfigDict


class Rule(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    pattern: str
    action: str  # "drop" / "keep" / "redact"


class RulesFile(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    version: int
    rules: list[Rule] = []


def open_rules_store(path: Path) -> VersionedFileStore[RulesFile]:
    return VersionedFileStore(
        path,
        schema=RulesFile,
        current_version=1,
    )
```

In `do_work`:

```python
from telemetry_exporter.rules import open_rules_store

def do_work(settings: Settings) -> int:
    rules_store = open_rules_store(Path(settings.rules_path))
    if not rules_store.exists():
        raise RulesetError(f"rules file not found: {settings.rules_path}")
    rules_file = rules_store.load()
    LOG.info("loaded rules", extra={"rule_count": len(rules_file.rules)})
    ...
```

Atomic writes, schema validation, migration callbacks for
future versions — all from one primitive.

→ Read more: [`recipes/versioned.md`](recipes/versioned.md).

---

## Step 8 — CLI dispatch

Replace the `do_work()` shape with a real subcommand-driven
CLI. Add `src/telemetry_exporter/cli.py`:

```python
"""telemetry-exporter CLI entry."""

from __future__ import annotations

import argparse
import sys

from mgf.common.cli import (
    EXIT_SUCCESS,
    build_cli,
    dispatch,
    run_and_translate,
    subcommand,
)


@subcommand("export", help="Export events as OTel spans.")
def run_export(args: argparse.Namespace) -> int:
    from telemetry_exporter.runner import export_events

    export_events()
    return EXIT_SUCCESS


@subcommand("validate", help="Schema-validate the rules file.")
def run_validate(args: argparse.Namespace) -> int:
    from telemetry_exporter.runner import validate_rules

    validate_rules()
    return EXIT_SUCCESS


def main(argv: list[str] | None = None) -> int:
    if argv is None:
        argv = sys.argv[1:]
    parser = build_cli(
        prog="telemetry-exporter",
        description="Read events, redact, emit OTel spans.",
    )
    args = parser.parse_args(list(argv))
    return dispatch(args, parser=parser)


if __name__ == "__main__":
    sys.exit(run_and_translate(main, sys.argv[1:]))
```

Wire it into `pyproject.toml`:

```toml
[project.scripts]
telemetry-exporter = "telemetry_exporter.cli:main"
```

Now `telemetry-exporter --help`, `telemetry-exporter export`,
`telemetry-exporter validate` all work. Errors raised inside a
subcommand are translated to stable exit codes
(`EXIT_CONFIG_ERROR=3`, `EXIT_OPERATION_ERROR=2`, etc.).

→ Read more: [`09_cli.md`](09_cli.md).

---

## Step 9 — The four gates

Run the gates that come pre-configured:

```bash
uv run ruff check
uv run mypy --strict src/
uv run pytest
uv run lint-imports
```

What each one catches:

| Gate | Catches |
|---|---|
| **ruff** | Syntax errors, import order, unused names, common bugs (B), deprecated patterns (UP), security smells (S), naive datetime (DTZ — DP-12), async correctness (ASYNC — DP-11). |
| **mypy --strict** | Every type mismatch, missing annotations, unsafe unions. The `py.typed` marker means consumer code mypy-checks against your annotations too. |
| **pytest** | Tests pass; warnings escalate to errors via `filterwarnings=["error", ...]`. Coverage gate at 80%. |
| **lint-imports** | Layering — your top-level imports don't depend on lower-layer modules. The cookiecutter ships one default layer pair (`<package>.cli` over `<package>.core`); add more as the package grows. |

Audit your project's standards conformance:

```bash
mgf-common catch-up                  # project setup audit
mgf-common doctor --standards        # code-level audit
```

Both are read-only. `catch-up --apply` performs mechanical
fixes; `doctor --standards` has no auto-fix path.

→ Read more: [`docs/standards/README.md`](../standards/README.md)
for the full rules catalogue.

---

## Where to go next

You have a working app at this point: bootstrap, typed
settings, typed exceptions, structured logs, OTel spans, crash
reporting, versioned config files, CLI subcommands, four gates.
The library has more surface to explore as needs arise:

- **HTTP client** — typed async `httpx` wrapper with redaction
  + retry: [`recipes/http.md`](recipes/http.md).
- **FastAPI integration** — lifespan + middleware + Depends
  helpers: [`recipes/fastapi.md`](recipes/fastapi.md).
- **SQLAlchemy + alembic** — async session manager + env.py
  one-liner: [`recipes/db.md`](recipes/db.md), [`recipes/alembic.md`](recipes/alembic.md).
- **Django** — `AppConfig` + middleware + JsonFormatter:
  [`recipes/django.md`](recipes/django.md).
- **Vault** — credential storage backends:
  [`07_vault.md`](07_vault.md).
- **Multi-tenancy** — when you have plug-in architectures or
  multiple identities in one process:
  [`multi_tenancy.md`](multi_tenancy.md).
- **Observability shim** — when you need to layer custom
  redactors / handlers on top of the upstream stack without
  forking: [`recipes/observability_shim.md`](recipes/observability_shim.md).
- **The full PUBLIC_API contract**:
  [`PUBLIC_API.md`](../../PUBLIC_API.md).

When you hit something the library is missing or shaped wrong,
file an entry in
[`FEEDBACK.md`](../../FEEDBACK.md) — the consumer feedback
queue is the canonical lift-signal channel.
