# stogger Documentation

```{image} assets/stogger_logo_ascii.txt
:alt: stogger logo
```

**Multi-target structured logging built on structlog**

Console, file, systemd journal, and PostgreSQL targets — plus AST-based convention checking via pytest-stogger.

## Quick Start

Install stogger and start logging:

```bash
uv add stogger
```

```python
import stogger
import structlog

# Initialize console logging and get a structlog logger
stogger.init_logging(verbose=True)
log = structlog.get_logger()
log.info("hello-world", user_id=123, action="login")
```

## Key Features

- **Multi-Target Output** — Simultaneous console (colorized), file, systemd journal, and PostgreSQL targets via `MultiRenderer`
- **Logging Decorators** — `@log_call`, `@log_result`, `@log_operation` decorators and `log_scope()` context manager with sync/async support
- **Message Translation** — TOML-based i18n with `_replace_msg` pattern for human-readable formatted log messages
- **Flexible Timestamps** — Configurable precision: `iso`, `iso_seconds`, `iso_no_z`, or `relative` (process-elapsed)
- **AST Convention Checking** — pytest-stogger enforces 13 logging rules (except-must-log, no-info-in-except, etc.) at test time
- **JSON Output** — Switch to structured JSON with `log_format="json"` in `[tool.stogger]`

## User Guide

```{toctree}
:maxdepth: 2
:caption: User Guide

user/index
```

## API Reference

```{toctree}
:maxdepth: 2
:caption: API Reference

api/index
```

## Development

```{toctree}
:maxdepth: 2
:caption: Development

dev/index
```

## Indices

- {ref}`genindex`
- {ref}`modindex`
- {ref}`search`


# User Guide

```{toctree}
:maxdepth: 1

getting_started
logging_patterns
reference
systemd
postgres
testing
cheatsheet
nix_integration
```


# Getting Started with stogger

stogger provides structured logging on top of structlog — console, file, and systemd journal targets in one call.

## Installation

Install stogger using uv:

```bash
uv add stogger
```

## Basic Usage

Here's how to start logging with stogger:

```python
import stogger
import structlog

# Initialize logging (console by default)
stogger.init_logging(verbose=True)

# Get a logger instance
log = structlog.get_logger()

# Log with structured data
log.info("user-login", _replace_msg="User {username} logged in", user_id=123, username="alice", ip="192.168.1.1")
log.warning("rate-limit-exceeded", _replace_msg="Rate limit exceeded for user {user_id}", user_id=123, attempts=5, limit=3)
log.error("database-connection-failed", _replace_msg="Database connection failed to {host}", host="db.example.com", timeout=30)
```

## Key Concepts

### Event-Style Logging

stogger promotes event-style logging where each log entry represents a specific event:

```python
log.info("order-created", _replace_msg="Order {order_id} created for customer {customer_id}", order_id=12345, customer_id=67890, amount=99.99)
```

### Structured Data

Always include relevant context as keyword arguments:

```python
log.info("payment-processed", 
         _replace_msg="Payment {payment_id} processed for {amount} {currency}",
         payment_id="pay_123",
         amount=49.99,
         currency="USD",
         gateway="stripe",
         processing_time_ms=245)
```

## Configuration

### TOML Configuration

For persistent settings, add a ``[tool.stogger]`` section to your ``pyproject.toml``:

```toml
[tool.stogger]
verbose = true
log_format = "simple"
```

See the feature-specific guides (systemd, postgres) for detailed configuration options.

### Python API

stogger can also be configured programmatically:

```python
import stogger

# Configure with custom settings
# Prefer init_logging; configure is subject to change.
stogger.init_logging(
    verbose=True,
    logdir="logs/",
    syslog_identifier="my-app"
)
```

## Next Steps

- Learn about [Logging Patterns](logging_patterns.md) for effective logging
- Set up [Systemd Journal Integration](systemd.md) for services running under systemd
- Set up [PostgreSQL Integration](postgres.md) for queryable persistent logs
- Read [Testing with stogger](testing.md) to test your log output
- Check out the [API Reference](../api/index) for detailed documentation

# TL;DR: Logging Cheatsheet

Short attention span edition. Do these things and your logs will be useful, consistent, and easy to analyze.

## The 80/20 Rules

- Use event-style messages (kebab-case) as the primary message
  - Examples: `"user-login"`, `"cache-miss"`, `"payment-failed"`
- Always log structured fields (no f-strings in the message)
  - Put data in fields; optionally add `_replace_msg` for a human sentence
- Pick the right level:
  - `debug` — internal operations, noisy details
  - `info` — business events and major milestones
  - `warning` — unusual but non-failing conditions
  - `error` — failures and handled exceptions
  - `critical` — system unusable or operator action required
- Include stable context fields:
  - Common: `user_id`, `request_id` or `correlation_id`, `operation`, `component`, `status_code`
- Measure time:
  - Prefer `duration_ms` for operations, or log `*-started` / `*-finished` pairs
- Never log secrets/PII:
  - Mask tokens, passwords, and personal data; consider PII redaction in your logging design
- Keep the cardinality low (avoid unbounded label values)

## Exceptions — do this

- Inside `except`, prefer `log.exception(...)` — it automatically includes `exc_info=True` and adds the full traceback to the `exception` field. No need for `error=str(e)`:

```python
try:
    do_work()
except Exception:
    log = structlog.get_logger()
    log.exception(
        "work-failed",
        operation="do_work",
    )
    # optionally: raise
```

- Alternative (when you need `log.error` for specific reasons):

```python
try:
    do_work()
except Exception as e:
    log.error(
        "work-failed",
        operation="do_work",
        exc_info=True,    # oder: exc_info=e
    )
```

- Why this matters: stogger processes `exc_info` and renders a full traceback into the `exception` field. Without `exc_info`, the stack trace is lost.
- Never use `exc_info` on `info`/`debug` — too noisy and unnecessary.

## Message formatting

- Use `_replace_msg` to add a readable sentence while keeping structure:

```python
log.info(
    "user-login",
    _replace_msg="User {username} logged in from {ip}",
    username=user.username,
    user_id=user.id,
    ip=request.remote_addr,
)
```

## Output Blocks

- Use `_raw_output` to embed tool output with ANSI colors (console gets colors, file gets stripped):

```python
log.warning("type-errors",
         _raw_output_prefix="ty",
         _raw_output=ty_result.output.strip())
```

- See [Logging Patterns: Output Rendering](logging_patterns.md#output-rendering) for the full table of output keys.

## Naming and consistency

- Prefer these field names (keep them consistent):
  - IDs: `user_id`, `request_id`, `correlation_id`
  - Timing: `duration_ms`
  - HTTP: `method`, `path`, `status_code`
  - Generic: `operation`, `component`

## Sampling and throttling

- Avoid chatty `info` logs in tight loops; use `debug`, sample, or aggregate
- Consider adding a `sample_rate` field to mark sampled events

## Do / Don’t

- Do:

```python
log.info("order-created", _replace_msg="Order {order_id} created for user {user_id}", order_id=oid, user_id=uid, total_cents=total)
```

- Don’t:

```python
log.info(f"order {oid} by user {uid} created with total {total}")
```

## FAQ

- Do we still need `exc_info=True`? Yes, if you want a stack trace. Use `log.exception(...)` (preferred) or pass `exc_info=True`/`exc_info=e` on error logs. stogger's processors will capture and format it into the `exception` field.
- Can I log plain strings? Technically yes, but you lose structure. Always prefer event name + fields.

See also: full guide in [Logging Patterns](logging_patterns.md).


# Logging Patterns

Common patterns for structured logging with stogger. For log level details, decorator API, and output key reference, see [Reference](reference.md).

## User Output with `_replace_msg`

Use `log.info()` instead of `print()` or `typer.echo()` for line-oriented user output. The event name provides structure; `_replace_msg` provides the human-readable sentence:

```python
log.info("package-installed",
         _replace_msg="Successfully installed {package_name} v{version} ({size_mb:.1f} MB)",
         package_name="hello",
         version="2.10.0",
         size_mb=15.7)
```

Single source of truth for user communication and diagnostics — structured data for analysis, formatted text for humans, audit trail of user interactions.

## Exception Handling

Use `log.exception()` in except blocks. It is equivalent to `log.error()` with `exc_info=True` and automatically includes the full traceback. Do not pass `error=str(e)` — the exception context is already captured.

```python
try:
    process_package(package_data)
except ValidationError as e:
    log.exception("package-validation-failed",
                  package_name=package_data.get("name"))
    raise
```

Use `log.error` with `exc_info=True` only when you have a specific reason not to use `log.exception()`. Never use `exc_info` on `info`/`debug` levels.

## Correlation IDs

Track requests across services with correlation IDs:

```python
import uuid

correlation_id = str(uuid.uuid4())

log.info("request-started",
         correlation_id=correlation_id,
         endpoint="/api/orders",
         method="POST")

# ... processing ...

log.info("request-completed",
         correlation_id=correlation_id,
         status_code=201,
         processing_time_ms=150)
```

## Common Patterns

### Function Tracing

```python
def process_package(package_name: str):
    log.info("package-processing-started", package_name=package_name)

    try:
        result = perform_processing(package_name)
        log.info("package-processing-completed",
                package_name=package_name,
                result_size=len(result))
        return result
    except Exception as e:
        log.exception("package-processing-failed", package_name=package_name)
        raise
```

Or use the [`@log_call`](reference.md#log_call--entry-logging) decorator instead.

### Timing Operations

```python
import time

def timed_operation():
    start = time.time()
    log.info("operation-started", operation="data_sync")

    result = perform_operation()

    duration = time.time() - start
    log.info("operation-completed",
            operation="data_sync",
            duration_ms=round(duration * 1000))

    return result
```

Or use the [`@log_result`](reference.md#log_result--exit-logging-with-timing) decorator instead.

### CLI Command Logging

```python
def deploy_command(package_name: str, environment: str):
    log.info("deployment-started",
             _replace_msg="Deploying {package} to {env}...",
             package=package_name,
             env=environment)

    try:
        result = deploy_package(package_name, environment)
        log.info("deployment-completed",
                 _replace_msg="Successfully deployed {package} to {env}",
                 package=package_name,
                 env=environment,
                 deployment_id=result.id)
    except DeploymentError as e:
        log.exception("deployment-failed",
                      _replace_msg="Failed to deploy {package}: {error}",
                      package=package_name,
                      error=str(e))
        raise
```

### Progress Reporting

```python
def process_files(file_list: list[str]):
    total = len(file_list)
    log.info("batch-processing-started",
             _replace_msg="Processing {total} files...",
             total=total)

    for i, file_path in enumerate(file_list, 1):
        log.debug("file-processing-started", file_path=file_path, index=i)
        process_file(file_path)

        if i % 10 == 0 or i == total:
            log.info("batch-progress-update",
                     _replace_msg="Processed {current}/{total} files",
                     current=i,
                     total=total,
                     progress_percent=round(i/total*100))

    log.info("batch-processing-completed",
             _replace_msg="All {total} files processed successfully",
             total=total)
```

## Error Context

```python
def validate_config(config_path: Path):
    log.debug("config-validation-started", config_path=str(config_path))

    if not config_path.exists():
        log.error("config-file-missing",
                  _replace_msg="Configuration file not found: {path}",
                  config_path=str(config_path))
        raise FileNotFoundError(f"Config file missing: {config_path}")

    try:
        with open(config_path) as f:
            config = yaml.safe_load(f)
    except yaml.YAMLError as e:
        log.exception("config-parse-failed",
                      _replace_msg="Invalid YAML in config file: {error}",
                      config_path=str(config_path),
                      error=str(e))
        raise

    log.info("config-validation-completed",
             _replace_msg="Configuration validated successfully",
             config_path=str(config_path),
             config_keys=list(config.keys()))

    return config
```

## Security

Never log passwords, tokens, or raw PII. Structure log calls to exclude sensitive fields from the outset:

```python
log.info("user-authenticated", user_id=123, email_domain="example.com")
```

## Performance

Log batch start/end, not every item:

```python
log.info("batch-processing-started", item_count=len(large_list))
# ... process items ...
log.info("batch-processing-completed", processed_count=processed)
```

For high-volume events, sample instead of logging every occurrence:

```python
import random

if random.random() < 0.1:  # 10% sampling
    log.debug("high-frequency-event", event_data=data)
```

Avoid chatty `info` logs in tight loops. Use `debug`, sample, or aggregate instead.

## Monitoring-Friendly Logging

Design logs for easy parsing by monitoring systems:

```python
log.info("api-response",
         endpoint="/api/users",
         method="GET",
         status_code=200,
         response_time_ms=45,
         cache_hit=True)
```


# Nix Integration for stogger

Build docs, run tests, develop — all in a Nix shell. Requires Nix with flakes enabled.

## Enable flakes

Add to `~/.config/nix/nix.conf` or `/etc/nix/nix.conf`:

```
experimental-features = nix-command flakes
```

## Development shell

```bash
nix develop
```

Inside the shell, all tools are available:

```bash
build-docs          # build documentation once
live-docs           # start live docs server
pytest              # run tests
ruff check          # lint
ty check src/       # type check
```

With direnv, the shell loads automatically:

```bash
echo "use flake" > .envrc
direnv allow
```

## Build docs

```bash
nix run .#build-docs    # build once
nix run .#live-docs     # live server
```

## Build package and run checks

```bash
nix build               # build the stogger package
nix flake check         # run all checks (tests, linting)
```

## What the flake provides

- Python ≥3.13 with all dependencies
- stogger package built and available
- Documentation tools (Sphinx, Furo, MyST)
- Development tools (pytest, ruff, ty, pre-commit)


# PostgreSQL Integration

stogger sends structured log events to a PostgreSQL table, making logs queryable with
SQL without additional infrastructure. Events are stored with typed columns for common
query dimensions and a JSONB catch-all for arbitrary fields.

## When You Need This

Services that already run PostgreSQL and want log persistence without setting up a
dedicated log aggregation stack:

- Logs become rows you can `SELECT`, `JOIN`, and aggregate with standard SQL.
- The JSONB `data` column keeps all structured fields queryable without schema
  migrations.
- Works alongside console, file, and journal targets — PostgreSQL is just another
  output.

## Installation

```bash
uv add stogger-postgres
```

`stogger-postgres` pulls in `psycopg` v3 (pure-Python fallback available) and declares
`stogger` as a dependency. No code changes required — `init_logging()` discovers the
package at runtime.

## Configuration

Settings live in `[tool.stogger]` in `pyproject.toml`:

```toml
[tool.stogger]
enable_postgres = true
postgres_dsn = "postgresql://stogger:%PASSWORD%@db.example.com:5432/logs"
# socket auth: "postgresql://stogger:@/logs?host=/var/run/postgresql"
postgres_table = "stogger_logs"  # optional
```

### enable_postgres

Controls whether `init_logging()` attempts to register the PostgreSQL target.

- `true` — import `stogger-postgres` and register the database logger.
- `false` (default) — skip PostgreSQL registration entirely. No import attempt.

### postgres_dsn

Connection string passed to psycopg. Supports any valid PostgreSQL DSN.

- The `%PASSWORD%` placeholder is replaced at runtime with the value of
  `STOGGER_POSTGRES_PASSWORD`.
- For socket/peer authentication, omit the placeholder:
  `postgresql://stogger:@/logs?host=/var/run/postgresql`.
- Password must not be committed to version control — use the environment variable.

### postgres_table

Table name for log events.

- Default: `"stogger_logs"`.
- The table is created automatically at startup if it does not exist.

## How It Works

`init_logging()` follows this sequence:

1. Build the loggers dict (file, console).
2. If `enable_postgres` is `true`, attempt
   `from stogger_postgres import get_postgres_logger_factory`.
3. On `ImportError` (package not installed), log a one-time info message and continue
   without PostgreSQL.
4. The factory creates the table via `CREATE TABLE IF NOT EXISTS`, then returns a
   `PostgresLogger`.
5. Each log event triggers one synchronous INSERT.

### Fallback Behavior

| Condition | Behavior |
| --- | --- |
| `stogger-postgres` installed, `enable_postgres = true` | PostgreSQL + console + file. |
| `stogger-postgres` installed, `enable_postgres = false` | PostgreSQL skipped. Console + file active. |
| `stogger-postgres` not installed, `enable_postgres = true` | Info message logged. Console + file active. |
| Connection fails at startup | Warning to stderr. `DummyPostgresLogger` used (no-op). Console + file active. |
| INSERT fails at runtime | Warning to stderr. Event dropped. Other targets continue. |

No configuration produces crashes in any environment. Database failures produce
warnings, not exceptions.

## Table Schema

`PostgresRenderer` extracts known fields into typed columns and packs everything else
into the JSONB `data` column:

| Column | Type | Source |
| --- | --- | --- |
| `id` | BIGSERIAL PRIMARY KEY | auto |
| `timestamp` | TIMESTAMPTZ NOT NULL | event_dict `timestamp` |
| `level` | TEXT NOT NULL | event_dict `level` |
| `event` | TEXT NOT NULL | event_dict `event` |
| `func` | TEXT | event_dict `func` (from decorators) |
| `scope` | TEXT | event_dict `scope` (from `log_scope`) |
| `data` | JSONB NOT NULL DEFAULT `'{}'` | all remaining event_dict fields |

Indexes: `timestamp` (DESC), `level`, `event`. GIN index on `data`.

Common query dimensions (`timestamp`, `level`, `event`, `func`, `scope`) are real
columns with indexes. Arbitrary structured fields remain queryable via `data`.

## Querying with SQL

```sql
-- Recent errors
SELECT timestamp, event, data
FROM stogger_logs
WHERE level = 'error'
ORDER BY timestamp DESC
LIMIT 50;

-- Events from a specific function
SELECT timestamp, event, data
FROM stogger_logs
WHERE func = 'process_payment';

-- Structured field in JSONB
SELECT timestamp, event, data->>'user_id' AS user_id
FROM stogger_logs
WHERE data->>'user_id' = '123';

-- Event counts by type, last 24 hours
SELECT event, count(*)
FROM stogger_logs
WHERE timestamp > now() - interval '24 hours'
GROUP BY event
ORDER BY count(*) DESC;

-- Logs within a scope
SELECT timestamp, level, event
FROM stogger_logs
WHERE scope = 'order-processing'
ORDER BY timestamp DESC;
```

## Troubleshooting

### "stogger-postgres not available" message

The package is not installed. Add it to your dependencies:

```bash
uv add stogger-postgres
```

### Connection refused / timeout

Verify the DSN is correct and the PostgreSQL server is reachable:

```bash
psql "postgresql://stogger:@/logs?host=/var/run/postgresql"
```

Socket authentication requires the PostgreSQL socket path to match. Check
`unix_socket_directories` in `postgresql.conf`.

### Table not created automatically

Schema creation runs once at startup. If it fails (permissions, invalid DSN), a warning
is logged to stderr and the `DummyPostgresLogger` is used. Check stderr output from your
service for the warning message.

### %PASSWORD% not replaced

The `STOGGER_POSTGRES_PASSWORD` environment variable must be set in the service
environment. For systemd units:

```ini
[Service]
Environment="STOGGER_POSTGRES_PASSWORD=mysecret"
```

For NixOS:

```nix
systemd.services.myapp.environment.STOGGER_POSTGRES_PASSWORD = "mysecret";
```

Or use a secrets management solution — never commit the password to version control.


# Reference

Quick lookup for log levels, output keys, decorators, and field name conventions.

## Log Levels

| Level | When to use | `_replace_msg` |
|-------|-------------|----------------|
| `DEBUG` | Diagnostic info, internal operations | **Never** |
| `INFO` | Business events, user-facing output | **Should** for user-oriented output |
| `WARNING` | Unusual but non-failing conditions | Yes |
| `ERROR` | Failures needing attention | Yes |
| `CRITICAL` | System unusable, operator action required | Yes |

```python
log.debug("cache-lookup", key="user:123", hit=True, ttl=300)
log.info("user-login", _replace_msg="User {user_id} logged in", user_id=123)
log.warning("rate-limit-approaching", _replace_msg="Rate limit approaching for user {user_id}", user_id=123)
log.error("payment-gateway-timeout", _replace_msg="Payment gateway timeout for {gateway}", gateway="stripe")
log.critical("database-unavailable", _replace_msg="Database unavailable after {attempts} attempts", attempts=3)
```

## Decorators

Import from the top-level package:

```python
from stogger import log_call, log_result, log_operation, log_scope
```

All decorators support sync and async functions.

### @log_call — Entry Logging

Logs function invocation with resolved arguments. Replaces manual `log.info("called", func=..., args={...})`.

```python
@log_call
def process_package(package_name: str):
    ...
    # Logs: {"event": "called", "func": "mymodule.process_package",
    #        "args": {"package_name": "hello"}}
```

### @log_result — Exit Logging with Timing

Logs return value and duration on success, exception info on failure.

```python
@log_result
def timed_operation():
    return perform_operation()
    # Logs: {"event": "returned", "func": "mymodule.timed_operation",
    #        "result": ..., "duration_ms": 12.3}
```

On exception, logs `{"event": "failed", "exc_type": "ValueError", "exc_msg": "...", "duration_ms": ...}` and re-raises.

### @log_operation — Full Audit Logging

Combines entry and exit logging: arguments, return value, and duration in one event.

```python
@log_operation(include_args=["query"], exclude_args=["password"])
def authenticate(query: str, password: str) -> bool:
    ...
    # Logs: {"event": "operation", "func": "mymodule.authenticate",
    #        "args": {"query": "admin"}, "result": true, "duration_ms": 15.3}
```

Use `include_args` to whitelist or `exclude_args` to blacklist argument names. Both strip `self` and `cls` automatically.

### log_scope() — Scoped Context Logging

Context manager for logging a named scope with structured fields. Use for transactions, migrations, or any logical unit of work.

```python
with log_scope("db_transaction", table="users") as scope:
    insert(user)
    scope.add_fields(rows_inserted=1)
    # Exit: {"event": "scope-end", "scope": "db_transaction",
    #   "table": "users", "rows_inserted": 1, "duration_ms": 45.2}
```

`add_fields()` accumulates fields during the scope. On exception, logs `{"event": "scope-failed", ...}` and re-raises. Works with `async with`.

## Output Keys

Use these keys to render tool output, command results, and tracebacks:

| Key | Prefix | DIM | ANSI behavior |
|-----|--------|-----|---------------|
| `cmd_output_line` | `> ` | Yes | Stripped via DIM wrapping |
| `_output` | empty | No | Stripped via `write()` closure |
| `_raw_output` | configurable via `_raw_output_prefix` | No | **Preserved** in console, stripped in file |
| `_raw_output_prefix` | used as prefix label | — | Sets prefix for `_raw_output` |
| `stdout` | `out` | Yes | Stripped via DIM wrapping |
| `stderr` | `err` | Yes | Stripped via DIM wrapping |
| `stack` | `stack` | No | Stripped via `write()` closure |
| `exception_traceback` | `exception` | No | Stripped via `write()` closure |

### Raw Output with ANSI Passthrough

Use `_raw_output` for tool output containing ANSI color codes. Console preserves colors; log file gets stripped output:

```python
log.warning(
    "component-type-errors",
    _replace_msg="{component}: {count} type error(s)",
    component=component_name,
    count=result.output.count("error:"),
    _raw_output_prefix="ty",
    _raw_output=result.output.strip(),
)
```

## Field Name Conventions

Prefer these names for consistency across logs:

- IDs: `user_id`, `request_id`, `correlation_id`
- Timing: `duration_ms`
- HTTP: `method`, `path`, `status_code`
- Generic: `operation`, `component`

Keep cardinality low — avoid unbounded label values.


# Systemd Journal Integration

stogger sends structured log events to the systemd journal when running under systemd. This avoids duplicate timestamps and level indicators that appear when console output is captured by the journal.

## When You Need This

Any Python service that runs as a systemd unit benefits from journal integration:

- Console output in systemd gets prefixed with journal metadata (timestamp, unit name) — stogger's own formatting adds a second set, producing redundant headers in `journalctl`.
- The journal renderer writes native journal fields (`PRIORITY`, `SYSLOG_IDENTIFIER`, `CODE_LINE`) instead of formatted text lines.
- Console logging is automatically suppressed when `JOURNAL_STREAM` is detected.

## Installation

```bash
uv add stogger-systemd
```

`stogger-systemd` pulls in `systemd-python` (Linux-only) and declares `stogger` as a dependency. No code changes required — `init_logging()` discovers the package at runtime.

## Configuration

Settings live in `[tool.stogger]` in `pyproject.toml`:

```toml
[tool.stogger]
enable_systemd = true
systemd_facility = 128            # optional: syslog facility code (default: LOG_LOCAL0 = 128)
```

### enable_systemd

Controls whether `init_logging()` attempts to register the journal target.

- `true` (default) — import `stogger-systemd` and register the journal logger.
- `false` — skip journal registration entirely. No import attempt.

### systemd_facility

Syslog facility code passed to `SystemdJournalRenderer`. Maps to `syslog.LOG_LOCALn` constants.

- `null` / unset (default) — uses `LOG_LOCAL0` (128).
- Integer value — passed through to the renderer unchanged.

Most services never need to change this. It matters only when filtering journal output by facility.

## How It Works

`init_logging()` follows this sequence:

1. Build the loggers dict (file, console).
2. Suppress console output when `JOURNAL_STREAM` env var is set (systemd sets this for every service).
3. If `enable_systemd` is `true`, attempt `from stogger_systemd import get_journal_logger_factory`.
4. On `ImportError` (package not installed), log a one-time info message and continue without journal.
5. Configure structlog with all available targets.

### Fallback Behavior

| Condition | Behavior |
|-----------|----------|
| `stogger-systemd` installed, running under systemd | Journal + file. Console suppressed. |
| `stogger-systemd` installed, not under systemd | Journal target registers but writes nothing. Console + file active. |
| `stogger-systemd` not installed, `JOURNAL_STREAM` set | Info message logged. Console + file active. |
| `stogger-systemd` not installed, no `JOURNAL_STREAM` | Silent skip. Console + file active. |

No configuration produces errors or warnings in non-systemd environments.

## Journal Fields

`SystemdJournalRenderer` transforms each event into journal-native fields:

| Field | Source |
|-------|--------|
| `PRIORITY` | Mapped from log level (`info` → 6, `warning` → 4, `error` → 3, etc.) |
| `SYSLOG_IDENTIFIER` | `syslog_identifier` from config (default: `"stogger"`) |
| `SYSLOG_FACILITY` | `systemd_facility` from config (default: 128) |
| `CODE_LINE` | Source file and line number |
| `MESSAGE` | Event ID + formatted `_replace_msg` or key-value pairs |
| All event keys | Uppercased (e.g. `USER_ID=123`, `ACTION="login"`) |

## Querying with journalctl

```bash
# All logs from your service
journalctl -u myapp.service

# Filter by syslog identifier
journalctl -t myapp

# Errors only
journalctl -t myapp -p err

# Specific event by ID
journalctl -t myapp MESSAGE="user-login*"

# Structured field query
journalctl -t myapp USER_ID=123

# Follow live output
journalctl -u myapp.service -f

# Since a specific time
journalctl -u myapp.service --since "1 hour ago"
```

## systemd Unit Example

```ini
[Unit]
Description=My Application
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/myapp
SyslogIdentifier=myapp
StandardOutput=journal
StandardError=journal

# Let stogger detect journal via JOURNAL_STREAM
# No special configuration needed

[Install]
WantedBy=multi-user.target
```

The `SyslogIdentifier` in the unit file and `syslog_identifier` in `[tool.stogger]` should match. This ensures consistent filtering with `journalctl -t`.

## NixOS Example

For NixOS deployments, the service definition fits into the system configuration:

```nix
systemd.services.myapp = {
  wantedBy = [ "multi-user.target" ];
  after = [ "network.target" ];
  serviceConfig = {
    ExecStart = "${pkgs.myapp}/bin/myapp";
    SyslogIdentifier = "myapp";
    StandardOutput = "journal";
    StandardError = "journal";
  };
};
```

## Troubleshooting

### Duplicate entries in journalctl

Console logging is not suppressed. This happens when `JOURNAL_STREAM` is not set in the service environment. Verify with:

```bash
systemctl show myapp.service -p Environment
```

Under normal systemd operation, `JOURNAL_STREAM` is always present.

### "stogger-systemd not available" message

The package is not installed. Add it to your dependencies:

```bash
uv add stogger-systemd
```

### Journal fields are empty or missing

Check that `enable_systemd` is not set to `false` in `[tool.stogger]`. The default is `true`, so this only applies if explicitly disabled.

### systemd-python fails to install

`systemd-python` requires libsystemd headers. On NixOS this is handled automatically. On other distributions:

```bash
# Debian/Ubuntu
apt install libsystemd-dev

# Fedora
dnf install systemd-devel
```


# Testing with stogger

How to test log output in projects that use stogger.

## Setup

Add `pytest-structlog` as a dev dependency:

```bash
uv add --dev pytest-structlog
```

No configuration needed — `pytest-structlog` auto-discovers structlog and
provides a `log` fixture that captures all structlog events.

## Unit Tests

Use the `log` fixture (type: `StructuredLogCapture`) to assert on captured events:

```python
from pytest_structlog import StructuredLogCapture

import stogger
import structlog

stogger.init_logging()
log_module = structlog.get_logger()

def process_order(order_id: int):
    log_module.info("order-processed", _replace_msg="Order {order_id} done", order_id=order_id)
    log_module.debug("order-details", items=3, total_cents=9900)

def test_process_order(log: StructuredLogCapture):
    process_order(42)

    # Assert specific event exists
    assert log.has("order-processed", order_id=42, level="info")

    # Assert debug context
    assert log.has("order-details", items=3)

    # Assert full event list
    assert log.events == [
        log.info("order-processed", order_id=42),
        log.debug("order-details", items=3, total_cents=9900),
    ]

    # Count occurrences
    assert log.count("order-processed") == 1
```

### Assertion Patterns

- **`log.has(event_name, **context)`** — check a single event with optional context
- **`log.events`** — full list of captured event dicts for exact matching
- **`log.count(event_name)`** — number of times an event was logged
- **`log.events >= [expected]`** — ordered subset match (checks subset, preserves order)

```python
# Subset match: only check the events you care about
assert log.events >= [
    {"event": "order-processed", "level": "info"},
    {"event": "order-shipped", "level": "info"},
]

# Negation: event was NOT logged with this context
assert not log.has("order-processed", order_id=999)
```

## CLI / Integration Tests with Typer CliRunner

The `log` fixture captures events directly from the structlog pipeline.
This works regardless of where stogger renders output (stderr, file,
journal). No special configuration needed.

```python
from typer.testing import CliRunner
from pytest_structlog import StructuredLogCapture

from myapp.cli import app

runner = CliRunner()

def test_deploy_command(log: StructuredLogCapture):
    result = runner.invoke(app, ["deploy", "myapp", "--env", "staging"])

    assert result.exit_code == 0
    assert log.has("deployment-started", package="myapp", env="staging")
    assert log.has("deployment-completed")
```

Two kinds of assertions, two tools:

- **Structured assertions**: `log.has("event-id")` — verify the right events
  were emitted with the right context
- **User-visible output**: `result.output` — check rendered text that the
  user would see (CliRunner captures stderr too)

## _replace_msg Assertions

`_replace_msg` is a processor concern — it transforms the event dict for
display. In tests, assert on the raw structured data, not the rendered string:

```python
def test_user_greeting(log: StructuredLogCapture):
    log_module.info("welcome", _replace_msg="Welcome {username}!", username="alice")

    # Assert on the event name and structured data
    assert log.has("welcome", username="alice")

    # _replace_msg appears in the event dict if you need it
    assert log.has("welcome", _replace_msg="Welcome {username}!", username="alice")
```

## When to Use log_to_stdlib

The `log_to_stdlib` processor bridges structlog events to Python's `logging`
module. This is useful **only** for:

- Legacy code that reads from stdlib `logging` handlers
- Ad-hoc debugging with `logging.basicConfig()`

Do **not** add `log_to_stdlib` to your pipeline just to use `pytest caplog`.
Instead, use `pytest-structlog` which captures events directly — no bridge
needed, no duplicate output.

```{note}
`log_to_stdlib` is documented in the API reference for `stogger.core`. It is
intentionally not part of the default pipeline. Adding it causes every event
to appear twice.
```

## Anti-Patterns

- **Don't reconfigure structlog in test conftests.** Replacing stogger's
  pipeline with a custom processor defeats the purpose of a shared logging
  library. The `log` fixture captures events without touching the pipeline.
- **Don't import `PartialFormatter` or other internals from `stogger.core`.**
  If you need `_replace_msg` formatting in tests, assert on the raw event
  data with `log.has()` instead of re-rendering templates yourself.
- **Don't build custom capture processors.** `pytest-structlog` already
  provides event capture. Adding a second capture mechanism creates
  maintenance burden and diverges from the project's logging conventions.
- **Don't use `caplog` or `log_to_stdlib`** — `pytest-structlog` is the
  right tool for structlog-based projects.



API Reference
=============

.. toctree::
   :maxdepth: 2

   stogger/index


stogger
=======

.. py:module:: stogger

.. autoapi-nested-parse::

   Opinionated structured logging built on structlog.

   Docs embedded in this package: llms.txt (index, ~50 entries), _sources/ (individual markdown files).
   llms-full.txt contains ALL docs in one file but is VERY large (~8000+ lines) — prefer reading
   individual files from _sources/ instead.

Submodules
----------

.. toctree::
   :maxdepth: 1

   /api/stogger/config/index
   /api/stogger/core/index
   /api/stogger/factory/index
   /api/stogger/processors/index



stogger.config
==============

.. py:module:: stogger.config

.. autoapi-nested-parse::

   Configuration handling for stogger.

Classes
-------

.. autoapisummary::

   stogger.config.ProjectStructure
   stogger.config.FormatConfig
   stogger.config.StoggerConfig

Functions
---------

.. autoapisummary::

   stogger.config.detect_project_structure

Module Contents
---------------

.. py:class:: ProjectStructure

   Detected project layout used for source and test discovery.

   Attributes:
       source_dirs: Relative paths to source directories (e.g. ``["src"]``).
       test_dirs: Relative paths to test directories (e.g. ``["tests"]``).
       exclude_patterns: Glob patterns for files excluded from logging analysis.
       detection_source: How the structure was determined — ``"pyproject.toml"``,
           ``"heuristics"``, or ``"defaults"``.
       project_root: Absolute path to the project root directory.

   .. py:attribute:: source_dirs
      :type:  list[str]

   .. py:attribute:: exclude_patterns
      :type:  list[str]

   .. py:attribute:: detection_source
      :type:  str

   .. py:attribute:: project_root
      :type:  pathlib.Path

   .. py:method:: get_source_paths()

      Resolve source directories to absolute paths.

      Returns:
          List of absolute Paths joining ``project_root`` with each
          entry in ``source_dirs``.

   .. py:method:: should_exclude_from_logging_analysis(file_path)

      Check whether a file should be excluded from logging analysis.

      Files inside ``test_dirs`` or matching any ``exclude_patterns`` glob are
      excluded. Files outside ``project_root`` are always excluded.

      Args:
          file_path: Absolute path to the file to check.

      Returns:
          ``True`` if the file should be excluded.

.. py:class:: FormatConfig

   Configuration for log format settings, loaded from ``[tool.stogger.format]``.

   Attributes:
       timestamp_precision: Timestamp format — ``"iso"``, ``"iso_seconds"``,
           ``"iso_no_z"``, or ``"relative"``. Default ``"iso_seconds"``.
       min_level: Minimum log level to display. Default ``"info"``.
       show_code_info: Include file name and line number. Default ``False``.
       pad_event_width: Minimum width for the event column. Default ``30``.

   .. py:attribute:: timestamp_precision
      :type:  str
      :value: 'iso_seconds'

   .. py:attribute:: min_level
      :type:  str
      :value: 'info'

   .. py:attribute:: show_code_info
      :type:  bool
      :value: False

   .. py:attribute:: pad_event_width
      :type:  int
      :value: 30

.. py:class:: StoggerConfig(**kwargs)

   Central configuration for stogger, merged from ``[tool.stogger]`` in
   ``pyproject.toml`` and keyword arguments passed at construction.

   Key attributes (with defaults):

   Attributes:
       verbose (bool): Enable verbose output. Default ``False``.
       logdir (Path | None): Directory for log files. Default ``None``.
       log_cmd_output (bool): Log subprocess command output. Default ``False``.
       log_to_console (bool): Also log to the console. Default ``True``.
       syslog_identifier (str): Identifier for syslog/systemd journal.
           Default ``"stogger"``.
       show_caller_info (bool): Include caller file/line in log output.
           Default ``False``.
       translation_dir (Path | None): Directory containing message
           translations. Default ``None``.
       language (str): Language code for log messages. Default ``"en"``.
       log_format (str): Output format — ``"simple"`` or ``"json"``.
           Default ``"simple"``.
       async_logging (bool): Use asynchronous log writing. Default ``False``.
       enable_systemd (bool): Enable systemd/journal integration.
           Default ``True``.
       systemd_facility (str | None): Syslog facility for systemd output.
           Default ``None``.
       src_dir (str): Primary source directory name. Default ``"src"``.
       format (FormatConfig): Format configuration. Default ``FormatConfig()``.
       ast_respect_gitignore (bool): Honor ``.gitignore`` during AST
           analysis. Default ``True``.
       ast_max_parameters (int): Max parameters before flagging a function.
           Default ``8``.
       ast_logging_focus (bool): Focus AST analysis on logging patterns.
           Default ``True``.
       ast_enabled_patterns (list | None): Specific AST patterns to enable.
           ``None`` enables all. Default ``None``.

   .. py:attribute:: verbose
      :type:  bool
      :value: False

   .. py:attribute:: logdir
      :type:  pathlib.Path | None
      :value: None

   .. py:attribute:: log_cmd_output
      :type:  bool
      :value: False

   .. py:attribute:: log_to_console
      :type:  bool
      :value: True

   .. py:attribute:: syslog_identifier
      :type:  str
      :value: 'stogger'

   .. py:attribute:: show_caller_info
      :type:  bool
      :value: False

   .. py:attribute:: translation_dir
      :type:  pathlib.Path | None
      :value: None

   .. py:attribute:: language
      :type:  str
      :value: 'en'

   .. py:attribute:: log_format
      :type:  str
      :value: 'simple'

   .. py:attribute:: async_logging
      :type:  bool
      :value: False

   .. py:attribute:: enable_systemd
      :type:  bool
      :value: True

   .. py:attribute:: systemd_facility
      :type:  str | None
      :value: None

   .. py:attribute:: enable_postgres
      :type:  bool
      :value: False

   .. py:attribute:: postgres_dsn
      :type:  str | None
      :value: None

   .. py:attribute:: postgres_table
      :type:  str
      :value: 'stogger_logs'

   .. py:attribute:: src_dir
      :type:  str
      :value: 'src'

   .. py:attribute:: format
      :type:  FormatConfig

   .. py:attribute:: ast_respect_gitignore
      :type:  bool
      :value: True

   .. py:attribute:: ast_max_parameters
      :type:  int
      :value: 8

   .. py:attribute:: ast_logging_focus
      :type:  bool
      :value: True

   .. py:attribute:: ast_enabled_patterns
      :type:  list | None
      :value: None

.. py:function:: detect_project_structure(project_root = None)

   Detect project structure using smart heuristics.

   Args:
       project_root: Project root directory. If None, uses current working directory.

   Returns:
       ProjectStructure with detected information.

   Raises:
       ValueError: If project structure cannot be determined and user configuration is required.



stogger.core
============

.. py:module:: stogger.core

.. autoapi-nested-parse::

   Core logging functionality for stogger.

Attributes
----------

.. autoapisummary::

   stogger.core.log
   stogger.core.JOURNAL_LEVELS
   stogger.core.KEYS_TO_SKIP_IN_JOURNAL_MESSAGE

Classes
-------

.. autoapisummary::

   stogger.core.PartialFormatter
   stogger.core.TranslationProcessor
   stogger.core.ConsoleFileRenderer
   stogger.core.JSONRenderer
   stogger.core.SelectRenderedString
   stogger.core.JournalLoggerFactory
   stogger.core.SystemdJournalRenderer
   stogger.core.PostgresRenderer
   stogger.core.CmdOutputFileRenderer
   stogger.core.MultiRenderer
   stogger.core.MultiOptimisticLoggerFactory
   stogger.core.MultiOptimisticLogger

Functions
---------

.. autoapisummary::

   stogger.core.prefix
   stogger.core.add_pid
   stogger.core.add_caller_info
   stogger.core.process_exc_info
   stogger.core.format_exc_info
   stogger.core.log_to_stdlib
   stogger.core.init_logging
   stogger.core.init_early_logging
   stogger.core.init_command_logging
   stogger.core.drop_cmd_output_logfile
   stogger.core.logging_initialized

Module Contents
---------------

.. py:data:: log

.. py:class:: PartialFormatter(missing='<missing>', bad_format='<bad format>')

   Bases: :py:obj:`string.Formatter`

   .. py:attribute:: missing
      :value: '<missing>'

   .. py:attribute:: bad_format
      :value: '<bad format>'

   .. py:method:: get_field(field_name, args, kwargs)

   .. py:method:: format_field(value, format_spec)

.. py:class:: TranslationProcessor(translations)

   .. py:attribute:: translations

   .. py:attribute:: formatter

.. py:function:: prefix(name, s)

   Add a prefix to each line of a multi-line string.

.. py:class:: ConsoleFileRenderer(format_config=None, min_level=None, show_caller_info=None)

   Render `event_dict` nicely aligned, in colors, and ordered with
   specific knowledge about stogger structures.

   .. py:attribute:: LEVELS
      :type:  ClassVar[list[str]]
      :value: ['alert', 'critical', 'error', 'exception', 'warn', 'warning', 'info', 'debug', 'trace']

   .. py:attribute:: format_config
      :value: None

   .. py:attribute:: min_level

   .. py:attribute:: show_caller_info

   .. py:attribute:: pad_event

   .. py:attribute:: show_logger_brackets
      :value: False

   .. py:attribute:: show_pid
      :value: False

   .. py:attribute:: timestamp_format

.. py:class:: JSONRenderer(min_level='info')

   JSON renderer for structured logging output.

   .. py:attribute:: min_level_idx

.. py:function:: add_pid(_logger, _method_name, event_dict)

.. py:function:: add_caller_info(_logger, _method_name, event_dict)

.. py:function:: process_exc_info(_logger, _method_name, event_dict)

.. py:function:: format_exc_info(_logger, _name, event_dict)

   Renders exc_info if it's present.
   Expects the tuple format returned by sys.exc_info().
   Compared to structlog's format_exc_info(), this renders the exception
   information separately which is better for structured logging targets.

.. py:class:: SelectRenderedString(key = 'console')

   Processor that selects a string from the dict returned by ConsoleFileRenderer.

   This ensures that structlog.stdlib.ProcessorFormatter receives a string
   as required, avoiding RuntimeWarnings.

   .. py:attribute:: key
      :value: 'console'

.. py:function:: log_to_stdlib(_logger, _name, event_dict)

   Bridge structlog events to Python's standard library logging module.

   This processor forwards every structlog event to ``logging.log()`` so that
   tools relying on stdlib handlers (e.g. ``pytest caplog``) can capture them.

   **Do NOT add this to the default processor pipeline.** Stogger configures
   its own renderers and file handlers. Adding this processor causes every
   event to appear twice — once via stogger's ``MultiRenderer`` and once via
   the stdlib root logger.

   Use cases where this *is* useful:

   - Legacy code that reads from stdlib ``logging`` handlers
   - Ad-hoc debugging with ``logging.basicConfig()``

   For test assertions, prefer ``pytest-structlog`` which captures events
   directly from the structlog pipeline without needing a stdlib bridge.

   Args:
       _logger: The structlog logger (unused).
       _name: The log method name (unused).
       event_dict: The structured event dictionary.

   Returns:
       The unmodified ``event_dict`` (pass-through processor).

.. py:function:: init_logging(*, logdir = None, log_cmd_output = False, log_to_console = True, syslog_identifier = None, verbose = None, show_caller_info = None)

   Initialize full structured logging with console, file, and journal targets.

   Configures structlog with a multi-target rendering pipeline: systemd journal,
   optional command output file, and a colorized console/file renderer. Sets up
   processors for PID, log level, exception formatting, timestamps, and caller info.

   Args:
       logdir: Directory for log files. A ``{syslog_identifier}.log`` file is created
           here when writable. Required if ``log_cmd_output`` is True.
       log_cmd_output: Enable separate command output logging to a dedicated file
           in ``logdir``. Requires ``logdir`` to be set.
       log_to_console: Log to stderr. Disabled automatically when running under
           systemd journal (detected via ``JOURNAL_STREAM`` env var).
       syslog_identifier: Identifier string for syslog/journal entries. Also used
           as the main log file name (``{syslog_identifier}.log``). When None
           (default), uses the value from settings (``syslog_identifier`` in
           ``[tool.stogger]`` config).
       verbose: When True, sets the console log level to ``"debug"``.
           When None (default), uses the level from settings (typically ``"info"``).
       show_caller_info: Whether to display code location (file, function, line)
           in console output. When None (default), uses the setting from
           ``FormatConfig.show_code_info``.

   Raises:
       ValueError: If ``log_cmd_output`` is True but ``logdir`` is not set.

.. py:function:: init_early_logging(*, verbose = False)

   Initialize minimal structured logging before full setup.

   Configures a lightweight structlog pipeline (timestamp, level, console renderer)
   so that early startup messages are properly formatted instead of appearing as
   raw dicts. No-op if structlog is already configured. Errors during setup are
   suppressed to avoid crashing during early initialization, but logged at debug level.

   Args:
       verbose: When ``True``, emit debug messages showing the caller that invoked
           this function. Also enabled when the ``STOGGER_DEBUG`` environment variable
           is set.

.. py:class:: JournalLoggerFactory

   Stub factory for systemd journal logger integration.

   Returns ``None`` by default. Actual systemd journal support is provided by
   the ``stogger-systemd`` package, which replaces this factory with a real
   journal writer. Use this as a placeholder so logging pipelines work without
   the systemd extra installed.

.. py:data:: JOURNAL_LEVELS

.. py:data:: KEYS_TO_SKIP_IN_JOURNAL_MESSAGE
   :value: ['_output', '_raw_output', '_raw_output_prefix', '_replace_msg', 'code_file', 'code_func',...

.. py:class:: SystemdJournalRenderer(syslog_identifier, syslog_facility=syslog.LOG_LOCAL0)

   Render structlog events as systemd journal fields.

   Transforms event dicts into journal-compatible key-value pairs with
   uppercased field names, syslog priority/facility, and a human-readable
   message string. Strings are kept un-JSON-encoded to preserve line breaks
   in ``journalctl`` output.

   Args:
       syslog_identifier: Identifier string for SYSLOG_IDENTIFIER field.
       syslog_facility: Syslog facility code (default: ``syslog.LOG_LOCAL0``).

   .. py:attribute:: syslog_identifier

   .. py:attribute:: syslog_facility
      :value: 128

   .. py:method:: handle_json_fallback(obj)

      Same as structlog's json fallback.
      Supports obj.__structlog__() for custom object serialization.

   .. py:method:: dump_for_journal(obj)

      Encode values as JSON, except strings.
      We keep strings unchanged to display line breaks properly in journalctl
      and graylog.

.. py:class:: PostgresRenderer

   Render event_dict into a column dict for PostgreSQL INSERT.

   Extracts known columns (timestamp, level, event, func, scope) and
   packs remaining fields into JSONB data.

   .. py:attribute:: KNOWN_FIELDS

.. py:class:: CmdOutputFileRenderer

   Renderer for command output file logging.

.. py:class:: MultiRenderer(**renderers)

   Calls multiple renderers with a shallow copy of the event dict and collects
   their messages in a dict with the renderer names as keys and their
   rendered output as values. It doesn't care about the rendered messages
   so different logger types can get different types of messages.
   Normally, this should be placed last in the processors chain.
   Errors in renderers are ignored silently.

   .. py:attribute:: renderers

.. py:class:: MultiOptimisticLoggerFactory(context, factories)

   Factory that creates ``MultiOptimisticLogger`` instances.

   Holds shared context (e.g. ``logdir``) and a dict of sub-logger factories
   (e.g. ``"console"`` → ``PrintLoggerFactory``, ``"file"`` → ``PrintLoggerFactory``).
   Each factory is called once per ``MultiOptimisticLogger`` instantiation.

   Args:
       context: Shared context dict available to all created loggers.
       factories: Dict mapping target names to structlog logger factory callables.

   .. py:attribute:: context

   .. py:attribute:: factories

.. py:class:: MultiOptimisticLogger(loggers)

   Distribute log messages to multiple sub-loggers by target name.

   Receives a dict of rendered outputs keyed by target name (e.g. ``"console"``,
   ``"file"``, ``"journal"``) and dispatches each to the corresponding
   sub-logger. Targets not present in the message are skipped. Errors in
   individual sub-loggers are caught and reported to stdlib logging to prevent
   one failing target from affecting others.

   Args:
       loggers: Dict mapping target names to structlog logger instances.

   .. py:attribute:: loggers

   .. py:method:: msg(**messages)

.. py:function:: init_command_logging(log, logdir=None)

   Add a command output file logger to the active multi-logger factory.

   Opens (or overwrites) a dedicated log file for capturing subprocess command
   output separately from the main log. When running under systemd (detected
   via ``INVOCATION_ID`` env var), the filename includes a timestamp and
   invocation ID for uniqueness.

   Args:
       log: A structlog BoundLogger instance (typically from ``structlog.get_logger()``).
       logdir: Directory for the command output file. Falls back to the
           ``logdir`` stored in the current ``MultiOptimisticLoggerFactory`` context.

.. py:function:: drop_cmd_output_logfile(log)

   Close and delete the command output log file.

   Removes the file created by ``init_command_logging``. Use this when no
   meaningful command output was produced to avoid leaving empty log files.

   Args:
       log: A structlog BoundLogger instance for diagnostic messages.

   Raises:
       KeyError: If the ``cmd_output_file`` factory is not present in the
           active ``MultiOptimisticLoggerFactory`` (i.e. ``init_command_logging``
           was never called).

.. py:function:: logging_initialized()

   Check whether structured logging has been configured.

   Returns:
       True if ``init_logging`` or ``init_early_logging`` has been called
       successfully, False otherwise.



stogger.factory
===============

.. py:module:: stogger.factory

.. autoapi-nested-parse::

   Factory functions for building stogger components.

Attributes
----------

.. autoapisummary::

   stogger.factory.log

Functions
---------

.. autoapisummary::

   stogger.factory.build_shared_processors
   stogger.factory.build_renderer
   stogger.factory.configure_stdlib_logging

Module Contents
---------------

.. py:data:: log

.. py:function:: build_shared_processors(config)

   Builds processors that are shared between sync and async modes.

.. py:function:: build_renderer(config)

   Builds the final renderer based on the log format.

.. py:function:: configure_stdlib_logging(config, processors)

   Configures the standard Python logging library.



stogger.processors
==================

.. py:module:: stogger.processors

.. autoapi-nested-parse::

   Processor factory functions.

Functions
---------

.. autoapisummary::

   stogger.processors.build_timestamp_processor

Module Contents
---------------

.. py:function:: build_timestamp_processor(config)

   Build a TimeStamper processor based on config.format.timestamp_precision.

   Central factory function for timestamp configuration. All TimeStamper
   call sites use this function to ensure consistent utc=True and correct fmt.

   Args:
       config: A config object with a .format attribute containing FormatConfig.

   Returns:
       A TimeStamper processor configured with the appropriate fmt and utc=True.



# Development

```{toctree}
:maxdepth: 1

type_checking_guide
testing_guide
adr/index


# Type Checking Guide

Stogger uses **ty** (Astral's type checker, configured in `[tool.ty.src]`) for static type analysis. This guide covers the type patterns specific to this codebase.

## Project Type Configuration

```toml
# pyproject.toml
[tool.ty.src]
exclude = ["tests", "docs"]

requires-python = ">=3.13"
```

Type checking runs as part of `tox`:

```bash
# Via tox (recommended — runs with correct dependencies)
CI=1 uv run tox -p

# Direct (if needed)
uv run ty check src/
```

## Modern Type Syntax

Stogger targets Python 3.13+. Use modern syntax everywhere — no `typing` module imports for standard containers:

```python
# ✅ Correct — modern syntax
def process(data: dict[str, Any]) -> list[str] | None:
    ...

items: list[str] = []
config: dict[str, Any] = {}
result: str | None = None

# ❌ Wrong — legacy typing
from typing import List, Dict, Optional
def process(data: Dict[str, Any]) -> Optional[List[str]]:
    ...
```

## Common Patterns in Stogger

### attrs classes with type annotations

`StoggerConfig` and `FormatConfig` use `attrs.define`. Type annotations go directly on attributes:

```python
import attrs

@attrs.define(slots=False)
class FormatConfig:
    timestamp_precision: str = "iso_seconds"
    min_level: str = "info"
    show_code_info: bool = False
    pad_event_width: int = 30

@attrs.define
class StoggerConfig:
    verbose: bool = False
    logdir: Path | None = None
    systemd_facility: str | None = None
    ast_enabled_patterns: list | None = None
```

### Processor type signature

All structlog processors follow the `StructlogProcessor` protocol defined in `_types.py`:

```python
type EventDict = MutableMapping[str, Any]

class StructlogProcessor(Protocol):
    def __call__(
        self,
        logger: object,
        method_name: str,
        event_dict: EventDict,
    ) -> EventDict | None: ...
```

Use `EventDict` from `stogger._types` for processor signatures:

```python
from stogger._types import EventDict

def my_processor(_logger: object, _method_name: str, event_dict: EventDict) -> EventDict:
    event_dict["custom_field"] = "value"
    return event_dict
```

### `ty: ignore` directives

Stogger uses `ty: ignore` (not `type: ignore`) for the Astral type checker. Always include the specific error code:

```python
# ty: ignore[unresolved-import] — dynamic import of optional package
from stogger_systemd import get_journal_logger_factory  # ty: ignore[unresolved-import]

# ty: ignore[unresolved-attribute] — accessing private structlog API
structlog._frames._find_first_app_frame_and_name(...)  # ty: ignore[unresolved-attribute]

# ty: ignore[invalid-argument-type] — structlog processor chain typing gaps
structlog.configure(processors=processors)  # ty: ignore[invalid-argument-type]
```

### Dynamic imports for optional packages

Systemd and PostgreSQL support are optional. Use try/except with `ty: ignore`:

```python
if cfg.enable_postgres:
    try:
        from stogger_postgres import get_postgres_logger_factory  # ty: ignore[unresolved-import]
        factory = get_postgres_logger_factory(dsn=cfg.postgres_dsn, table=cfg.postgres_table)
        loggers["postgres"] = factory
    except ImportError:
        pass  # Optional package not installed
```

### `noqa` comments

Stogger uses Ruff with specific rule codes. Common suppressions:

```python
# noqa: SLF001 — accessing private attributes (e.g., _file, __attrs_init__)
# noqa: PLC0415 — late import inside function body (intentional for optional deps)
# noqa: T201 — print() statements (used for early stderr warnings)
# noqa: S603, S607 — subprocess without shell=False
```

## Troubleshooting

### `unresolved-import` for optional packages

The `stogger-systemd` and `stogger-postgres` packages are workspace members. They're imported dynamically and need `ty: ignore[unresolved-import]`.

### `invalid-argument-type` in processor chains

Structlog's processor chain typing doesn't perfectly match all stogger processor signatures. Use `ty: ignore[invalid-argument-type]` where the runtime behavior is correct but the type system can't verify it.

### attrs `__attrs_init__` access

`StoggerConfig.__init__` merges TOML config with kwargs and calls `self.__attrs_init__(...)`. This requires `ty: ignore[unresolved-attribute]` because attrs generates this method at class creation time.


# format-config-extension

## Context

Stogger hardcodes all timestamps to ISO 8601 with microsecond precision. The existing `SimpleFormatSettings` dataclass is dead config — unreachable from pyproject.toml or public API, with documented values that were never implemented. The broader config layer uses manual `_load_config()` parsing without validation. This spec addresses both: timestamp format configuration and a proper attrs-based config layer.

## Decisions

### config-layer-attrs

#### Context

`StoggerConfig` uses a manual `__init__` with `_load_config()` that parses TOML, merges kwargs, and extracts ~20 fields via `config.get(key, default)`. No validation, no type safety. `SimpleFormatSettings` is a separate dataclass that is never constructed from config — always defaults.

#### Decision

Migrate `StoggerConfig` to `attrs` classes with proper converters and validators. `FormatConfig` is a nested attrs class for `[tool.stogger.format]`, following the existing `[tool.stogger.ast]` precedent. `SimpleFormatSettings` is deleted — its living fields migrate to `FormatConfig`.

#### Alternatives

a. Only new `FormatConfig` as attrs, rest stays manual — rejected, half-cleaned state.
b. Pydantic instead of attrs — rejected, heavy runtime dependency for a 2-dep library.
c. attrs + cattrs — rejected, cattrs is overkill for simple TOML-to-attrs mapping.
d. Keep dataclasses with `__post_init__` validation — rejected, reimplements attrs validators.

#### Consequences

`attrs` becomes a new runtime dependency. Config loading gains type safety and validation. `SimpleFormatSettings` dies. One-time migration effort for `StoggerConfig` (~160 lines).

### format-config-fields

#### Context

`SimpleFormatSettings` has 6 fields. Three are never used from config (`show_logger_brackets`, `show_pid`, `timestamp_format`). Three are functional (`min_level`, `show_code_info`, `pad_event_width`). A new `timestamp_precision` field is needed.

#### Decision

`FormatConfig` (attrs class) contains:

| Field | Type | Default | Origin |
|---|---|---|---|
| `timestamp_precision` | `str` | `"iso_seconds"` | New |
| `min_level` | `str` | `"info"` | From SimpleFormatSettings |
| `show_code_info` | `bool` | `False` | From SimpleFormatSettings |
| `pad_event_width` | `int` | `30` | From SimpleFormatSettings |

Dead fields (`show_logger_brackets`, `show_pid`, `timestamp_format`) are not migrated.

#### Alternatives

a. Only `timestamp_precision` in FormatConfig, keep SimpleFormatSettings — rejected, defeats cleanup purpose.
b. All 6 SimpleFormatSettings fields migrated — rejected, includes dead fields.
c. No FormatConfig, fields live in StoggerConfig flat — rejected, loses sub-section structure.

#### Consequences

`ConsoleFileRenderer` receives `FormatConfig` instead of `SimpleFormatSettings`. Four fields total. Config TOML uses `[tool.stogger.format]` section.

### timestamp-precision-values

#### Context

structlog's `TimeStamper` accepts `fmt="iso"` (with µs), arbitrary strftime strings, or `fmt=None` (unix float). The feature needs four format variants with zero validation on invalid input (GIGO).

#### Decision

Four supported values:

| Value | TimeStamper fmt | Output example | Description |
|---|---|---|---|
| `"iso"` | `"iso"` | `2026-05-02T12:34:56.123456Z` | Full precision with µs |
| `"iso_seconds"` | `"%Y-%m-%dT%H:%M:%SZ"` | `2026-05-02T12:34:56Z` | Seconds only |
| `"iso_no_z"` | `"%Y-%m-%dT%H:%M:%S"` | `2026-05-02T12:34:56` | Seconds, no Z suffix |
| `"relative"` | `"iso"` (kept for pipeline) | `+2.341s` | Renderer computes delta from process start |

Default changes from `"iso"` to `"iso_seconds"` — breaking change, no deprecation. Invalid values pass through to structlog/strftime (GIGO).

#### Alternatives

a. Free-form strftime with "iso" shortcut — rejected, ugly in TOML, validation complexity.
b. Two values only (full/seconds) — rejected, drops iso_no_z and relative.
c. Validation with error on invalid value — rejected, user wants GIGO.

#### Consequences

Breaking change: existing log output loses microseconds unless user opts into `"iso"`. Three ISO variants handled by TimeStamper fmt parameter. Relative handled by renderer.

### pipeline-approach

#### Context

Three separate `TimeStamper` call sites exist (factory.py:41, core.py:507, core.py:582), all hardcoded to `fmt="iso"`. The renderer's `_format_timestamp` only strips Z for `"iso_no_z"`, passes everything else through.

#### Decision

ISO variants (`iso`, `iso_seconds`, `iso_no_z`): `TimeStamper` receives the appropriate `fmt` parameter. Renderer wraps in ANSI colors, no further transformation needed.

Relative format: `TimeStamper` keeps `fmt="iso"` (unchanged). Renderer computes `time.time() - format_config._process_start` and formats as `+X.XXXs`. The `FormatConfig` stores `_process_start: float = time.time()` at instantiation.

#### Alternatives

a. TimeStamper always `fmt=None` (unix float), custom processor formats everything — rejected, reimplements TimeStamper.
b. TimeStamper unchanged, renderer strips µs/Z from ISO strings — rejected, wasteful string manipulation.
c. Custom TimestampProcessor replaces TimeStamper — rejected, maintains own timestamp logic.

#### Consequences

Minimal pipeline change for ISO variants (just fmt parameter). Relative needs renderer change and process-start tracking. No new processors added.

### call-site-unification

#### Context

Three TimeStamper instantiations with inconsistent parameters: `init_logging()` uses `utc=False`, the other two use `utc=True`. One omits `key="timestamp"`.

#### Decision

Central `build_timestamp_processor(config)` function creates `TimeStamper` with the correct `fmt` from `config.format.timestamp_precision`. All three call sites use this function. All sites normalized to `utc=True`.

#### Alternatives

a. Config passed to each site, each creates own TimeStamper — rejected, code duplication.
b. Only fix factory.py and init_early_logging, leave init_logging — rejected, leaves inconsistency.
c. Keep all three hardcoded, renderer handles formatting — rejected, renderer becomes complex.

#### Consequences

Single source of truth for timestamp configuration. `init_logging()` gains `utc=True` (was `utc=False`). Three code paths reduced to one factory function.

### test-strategy

#### Context

Zero tests exist for timestamp config flowing from TOML through to renderer. Existing tests cover `ConsoleFileRenderer` with direct `SimpleFormatSettings` construction.

#### Decision

TDD approach. Spec-validation tests in `tests/impl_spec/test_format_config_extension.py` (xfail, garbage-collected after passing). Permanent decision tests in `tests/test_core.py` and `tests/test_config.py`. Tests cover:

- TOML `[tool.stogger.format]` loading → FormatConfig field values
- Each `timestamp_precision` value produces correct TimeStamper fmt
- Renderer output for all four format values
- Relative format shows elapsed time
- GIGO: invalid value produces output without crash
- Default is `"iso_seconds"` when no config present

#### Alternatives

a. Tests-after — rejected, no spec validation traceability.
b. Only extend existing tests — rejected, no config-flow coverage.
c. No automated tests — rejected, config pipeline is critical path.

#### Consequences

Full test coverage for config flow. Bidirectional traceability: ADR references test file paths. Spec-validation tests verify design decisions before implementation.

## Verified By

<!-- Tests will be added after implementation -->


# Architecture Decision Records

```{toctree}
:maxdepth: 1

format-config-extension
postgres-target
stogger-systemd
stogger-self-logging
```


# postgres-target

## Context

Stogger logs to console, file, and systemd journal but has no database target. PostgreSQL as a logging target makes logs queryable, analysable, and persistent — especially useful for services already running PostgreSQL. The new target follows the established external-package pattern (mirroring stogger-systemd) to keep the core dependency-free.

## Decisions

### package-placement

#### Context

PostgreSQL requires a heavy native dependency. Stogger core is deliberately free of I/O-heavy dependencies. The stogger-systemd package establishes the precedent: external package as workspace member, discovered at runtime via dynamic import.

#### Decision

External package `stogger-postgres` as workspace member under `packages/`. The renderer (`PostgresRenderer`) lives in core stogger (like `SystemdJournalRenderer`). The logger/factory (`PostgresLogger`, `PostgresLoggerFactory`) lives in the external package.

#### Alternatives

a. Built into stogger core — forces all users to install psycopg
b. Optional extra behind `[postgres]` — breaks with established pattern

#### Consequences

Clean separation. Core stays light. Users who want PostgreSQL install `stogger-postgres`. Runtime dynamic import mirrors journal pattern exactly.

### postgres-driver

#### Context

The sync-per-event write pattern is decided (see `write-pattern`). The driver must support synchronous writes, have a pure-Python fallback for environments without a C compiler, and be actively maintained.

#### Decision

psycopg v3 (psycopg). Modern API, pure-Python fallback via `psycopg[pure]`, pipeline mode for future batched writes, `COPY` support for bulk operations. Declared as dependency in `packages/stogger-postgres/pyproject.toml`.

#### Alternatives

a. psycopg2 — legacy, C-extension only, difficult on some platforms
b. pg8000 — pure-Python but less widely adopted, fewer features

#### Consequences

Standard modern choice. Pure-Python fallback ensures installability everywhere. Pipeline mode available if write-pattern evolves to batched.

### schema-columns

#### Context

Events have known high-cardinality fields (timestamp, level, event, func, scope) plus arbitrary user-defined fields. The schema must balance query performance on known fields with flexibility for unknown fields.

#### Decision

Fixed columns for high-query-volume fields + JSONB catch-all:

| Column | Type | Source |
|---|---|---|
| `id` | BIGSERIAL PRIMARY KEY | auto |
| `timestamp` | TIMESTAMPTZ NOT NULL | event_dict `timestamp` |
| `level` | TEXT NOT NULL | event_dict `level` |
| `event` | TEXT NOT NULL | event_dict `event` |
| `func` | TEXT | event_dict `func` (from decorators) |
| `scope` | TEXT | event_dict `scope` (from log_scope) |
| `data` | JSONB NOT NULL DEFAULT '{}' | all remaining event_dict fields |

Indexes: `timestamp` (DESC), `level`, `event`. GIN index on `data`.

#### Alternatives

a. Minimal (id, timestamp, level, event + JSONB) — func/scope require JSONB queries
b. Fully configurable schema — no out-of-the-box experience

#### Consequences

Common query dimensions are real columns with indexes. func and scope as separate columns enable efficient filtering by decorated function or scope name. Arbitrary fields remain queryable via JSONB.

### data-pipeline

#### Context

The renderer transforms event_dict for the target. The logger performs I/O. This separation is established by SystemdJournalRenderer (transforms to journal fields) → JournalLogger.msg(dict) (calls journal.send).

#### Decision

`PostgresRenderer` (in core stogger) extracts known fields into column dict, packs remaining fields into JSONB `data`, returns `{"postgres": column_dict}`. `PostgresLogger.msg(column_dict)` (in external package) executes the INSERT. `DummyPostgresLogger` is the no-op fallback.

Renderer responsibility: field extraction, column mapping, JSONB packing.
Logger responsibility: connection management, schema creation, INSERT execution.

#### Alternatives

a. Raw pass-through — logger does transformation + INSERT, renderer is dummy. Violates renderer/logger separation.
b. SQL string — renderer produces INSERT statement. Couples renderer to table schema directly.

#### Consequences

Consistent with established structlog patterns. Renderer is testable without database. Logger is thin I/O layer.

### connection-config

#### Context

Users must configure the database connection. Socket/peer authentication is common (no password needed). When passwords are needed, they must not be committed to version control.

#### Decision

DSN in `pyproject.toml` under `[tool.stogger]` key `postgres_dsn`. Password optional via `STOGGER_POSTGRES_PASSWORD` environment variable. Placeholder `%PASSWORD%` in DSN is replaced at runtime. Socket auth: DSN without placeholder works directly (e.g. `postgresql://stogger:@/logs?host=/var/run/postgresql`).

Config keys added to `StoggerConfig`:
- `enable_postgres: bool = False`
- `postgres_dsn: str | None = None`
- `postgres_table: str = "stogger_logs"`

#### Alternatives

a. Full DSN from environment variable only — works but DSN must be entirely in ENV
b. Separate config keys per connection parameter — more config overhead

#### Consequences

DSN can be safely versioned. Password never in code. Socket auth has zero extra complexity.

### error-strategy

#### Context

Logging must not crash the application. If a target fails, other targets continue.

#### Decision

Silent fallback at every failure point: connection failure, schema creation failure, INSERT failure. Each logs a warning to stderr (not to structlog — avoids recursive logging). Target is skipped, other targets continue. Mirrors the journal fallback pattern in `_build_logger_factories()`.

#### Alternatives

a. Buffer and retry — memory leak risk during extended outages
b. Fail hard — crashes application when database is down

#### Consequences

Robust under database outages. Users see warnings, logs flow to working targets.

### schema-creation

#### Context

The table must exist before INSERTs. Users should not need manual DDL steps.

#### Decision

`CREATE TABLE IF NOT EXISTS` executed in `PostgresLoggerFactory.__call__()` — once per logger instantiation, at startup. If creation fails, `DummyPostgresLogger` is returned (no-op fallback).

#### Alternatives

a. Lazy creation on first INSERT — delays error detection
b. Explicit setup function — worse DX, user must remember to call it

#### Consequences

Table is guaranteed to exist before any INSERT. Errors surface immediately at startup. No manual setup required.

### write-pattern

#### Context

Logging happens in the hot path. The target must not noticeably slow down the application.

#### Decision

Synchronous INSERT per event. Each `PostgresLogger.msg(dict)` executes one INSERT and returns. Overhead ~1-2ms per event.

#### Alternatives

a. Batched writes — higher throughput but more complex, delayed delivery
b. Async background writer — highest throughput but most complex

#### Consequences

Low latency per event. Predictable behaviour. For very high volume (>1000 events/s), batched writes may be needed in future.

### test-strategy

#### Context

Tests must cover the target without requiring a running PostgreSQL instance in CI. The stogger-systemd package establishes the test pattern.

#### Decision

Mirror the systemd test pattern:

1. **Mock-based integration tests** (in stogger core): 4-path decision matrix testing `enable_postgres` × import success × env var presence. Uses `patch.dict(sys.modules, ...)` to mock `stogger_postgres`.

2. **Real-package tests** (in `packages/stogger-postgres/`): guarded by `pytest.importorskip("stogger_postgres")` and `@pytest.mark.integration`. Tests `get_postgres_logger_factory()`, `PostgresLoggerFactory.__call__()`, `DummyPostgresLogger.msg()`.

3. **Spec validation tests**: in `tests/impl_spec/test_postgres_target.py` with xfail markers. Test import paths, config keys, renderer contract, schema creation flow.

#### Alternatives

a. Spec validation tests only — less confidence
b. No automated tests — unacceptable for new target

#### Consequences

Full coverage of the registration flow without PostgreSQL in CI. Real-package tests available for local development with a running database.

## Verified By

<!-- Tests will be added after implementation -->


# stogger-self-logging

## Context

Stogger's own codebase violates its own logging conventions: `complexity-needs-log` fires on `_filter_args()` in `_decorators.py`, and `log-suppression-budget` reports 28 suppressed statements (budget: 7). Several code paths in `core.py` and `config.py` silently swallow errors or skip diagnostic opportunities. Stogger should practice what it preaches by adding proper logging to non-circular code paths and configuring per-file-ignores where logging would be circular.

## Decisions

### overall-strategy

#### Context

Two active violations in `_decorators.py`. Real logging at code paths that benefit from it, config fallback for infrastructure code where logging would be circular or bureaucratic.

#### Decision

Hybrid: add `log.debug()` / `log.warning()` at non-circular code paths in `core.py` and `config.py`. Extend `per-file-ignores` for `_decorators.py` (pure logging infrastructure — logging it would be circular).

#### Alternatives

a. Config-only — fast but stogger never practices its own conventions
b. Real logging everywhere — risks recursion in rendering code

#### Consequences

~8 new log statements across two files plus one config change. Both violations resolved.

### logging-level

#### Context

New log statements need a level respecting stogger conventions CR-4/CR-5.

#### Decision

All new statements at `log.debug()` (no `_replace_msg` needed per CR-5). One exception: `PermissionError` in `_build_logger_factories()` at `log.warning()` with `_replace_msg` because a missing log file is a real operational problem.

#### Alternatives

a. Mix of debug + info — noise for no audience benefit
b. All info — violates stogger convention that infra logging should be debug

#### Consequences

Debug statements invisible unless `verbose=True`. Warning event `file-open-permission-denied` needs `_replace_msg` and addition to `exempt_event_ids`.

### recursion-safety

#### Context

Adding `log.debug()` inside `PartialFormatter.get_field()` and `format_field()` (core.py:36-51) raises a recursion concern — the log call goes through the structlog pipeline which includes renderers that use `PartialFormatter`.

#### Decision

Safe to proceed. Proof: (1) `log.debug()` has no `_replace_msg` per CR-5, (2) `ConsoleFileRenderer` only constructs `PartialFormatter` when `_replace_msg` is present, (3) `TranslationProcessor` is not in the default `init_logging` processor chain, (4) the kv-rendering path (line 297-302) does string interpolation only, no `PartialFormatter` involved.

#### Alternatives

a. Skip PartialFormatter logging — loses diagnostic value for template debugging
b. Use stdlib logging instead — breaks convention, different output format

#### Consequences

Two new `log.debug()` statements in `PartialFormatter` are safe. No code comments about recursion needed — the proof is structural.

### config-single-source

#### Context

`conftest.py` auto-derives `infrastructure_files` from `per-file-ignores` entries that contain both `except-must-log` and `complexity-needs-log`. The explicit `infrastructure_files` key in `pyproject.toml` is currently the only place `_decorators.py` gets its infrastructure status. After adding `_decorators.py` to `per-file-ignores`, the explicit list becomes redundant.

#### Decision

Remove the explicit `infrastructure_files` key from `[tool.pytest-stogger]` in `pyproject.toml`. Let `conftest.py` auto-derive everything from `per-file-ignores`. Single source of truth.

#### Alternatives

a. Keep both — explicit list as documentation, `setdefault` doesn't overwrite
b. Only add per-file-ignores, leave infrastructure_files — minimal change but redundant

#### Consequences

After change: `per-file-ignores` has three entries (`core.py`, `_colors.py`, `_decorators.py`), `infrastructure_files` is auto-derived from those. Config is DRY.

### event-id-naming

#### Context

New event IDs need kebab-case, max 4 words per CR-1.

#### Decision

Negative naming as drafted: `no-stogger-section`, `no-hatch-section`, `no-pytest-section`, `format-field-missing`, `format-field-bad-format`, `early-init-failed`, `stogger-postgres-not-installed`, `file-open-permission-denied`. All are precise, grepable, under 4 words.

#### Alternatives

a. Action-based (`probing-skipped-stogger`) — longer, describes action not state
b. Parameterized (`no-config-section` with `section=` key) — DRYer but harder to grep

#### Consequences

8 new event IDs, all kebab-case, all under 4 words. `file-open-permission-denied` added to `exempt_event_ids`.

### test-strategy

#### Context

Existing tests cover `PartialFormatter` (4 tests), probe functions (indirect via `detect_project_structure`), and decorator helpers (indirect via decorator tests). No tests for PermissionError path, early-init suppress path, or ImportError paths.

#### Decision

Tests-after for new logging paths only. New tests in natural test structure (`test_core.py`, `test_config.py`). No spec-validation tests (overkill for log statements). No tests for config-only changes.

#### Alternatives

a. TDD with spec-validation tests — disproportionate overhead for ~8 log statements
b. No new tests — acceptable but new warning event should have coverage

#### Consequences

New test cases for: `file-open-permission-denied` warning, `early-init-failed` debug, `stogger-postgres-not-installed` debug, probe early-exit debugs. ~4-5 new test functions.

## Verified By

<!-- Tests will be added after implementation -->


# stogger-systemd

## Context

Services running under systemd produce redundant metadata in journalctl — double timestamps and level indicators because journalctl adds its own header while stogger's ConsoleFileRenderer adds another. JournalLoggerFactory is a stub returning None, so nothing writes to the journal via journal.send(). Extracting the journal I/O into a separate workspace package with an optional dependency on systemd-python fixes this.

## Decisions

### packaging-model

#### Context

The journal I/O needs isolation from core because systemd-python only installs on Linux. The project uses hatch-vcs with src-layout. Users should be able to install a separate package from PyPI.

#### Decision

uv workspace with separate package. Root pyproject.toml stays as stogger package and becomes workspace root via [tool.uv.workspace] members = ["packages/*"]. New package at packages/stogger-systemd/ with its own pyproject.toml. Both packages share VCS version via hatch-vcs with raw-options.root pointing to the git root. Install via pip install stogger-systemd which pulls stogger + systemd-python.

#### Alternatives

a. Optional extra in same distribution (pip install stogger[systemd]) — no package separation, all code in one wheel. Rejected: users expect separate package for separate concern.
b. Separate repository — maximum isolation but versioning nightmare for shared VCS. Rejected.
c. Inline module with conditional import — no isolation, all users carry the import overhead. Rejected.

#### Consequences

Two independently publishable packages sharing one VCS tag. packages/stogger-systemd/ declares stogger as dependency, uses [tool.uv.sources] stogger = { workspace = true } during development. Platform isolation at dependency level — pip install stogger-systemd fails gracefully on non-Linux via systemd-python resolution.

### scope-split

#### Context

Core holds journal data transformation (SystemdJournalRenderer, JOURNAL_LEVELS) and a stub logger factory. The real journal I/O needs the systemd-python dependency.

#### Decision

Only the I/O layer moves to packages/stogger-systemd/: JournalLogger (with journal.send()), DummyJournalLogger, and the real JournalLoggerFactory. Renderer, level constants, and syslog import stay in core — pure data transformation, no platform deps.

#### Alternatives

a. Full extraction of renderer + factory — requires plugin mechanism in core for dict formatting. Rejected.
b. Everything including syslog constants out — syslog is stdlib on all platforms, renderer useful in tests. Rejected.

#### Consequences

Core keeps ~60 lines of journal formatting. New package is a thin I/O shim (~30 lines). Stub in core remains as fallback.

### integration-hook

#### Context

Core must discover the real journal logger factory when stogger-systemd is installed. Since it is a separate package, ImportError fires when it is not installed.

#### Decision

Dynamic import with ImportError fallback. Core attempts from stogger_systemd import get_journal_logger_factory. Falls back to stub on ImportError. Zero-config for users.

#### Alternatives

a. Entry point plugin — over-engineering for single integration point. Rejected.
b. Explicit import stogger_systemd side-effect — breaks "install and it works" DX. Rejected.
c. None-check pattern — only needed when module is always present (same distribution). Not applicable for separate package. Rejected.

#### Consequences

Single try/except block. The import path stogger_systemd.get_journal_logger_factory is the stable contract between packages.

### enable-systemd-source

#### Context

init_logging() needs to know whether to attempt journal registration. StoggerConfig already has an enable_systemd field read from [tool.stogger] in pyproject.toml.

#### Decision

enable_systemd comes from pyproject.toml config only — no new kwarg on init_logging(). The function reads StoggerConfig internally to get the setting. Convention over configuration: the config file is the source of truth.

#### Alternatives

a. New kwarg enable_systemd: bool = True — adds another parameter to an already long signature. Rejected.
b. Accept StoggerConfig object — API break for existing callers. Rejected.

#### Consequences

init_logging() gains a side-effect (reading pyproject.toml via StoggerConfig). Programmatic override requires writing to [tool.stogger] or calling StoggerConfig(enable_systemd=False) separately.

### journal-registration-flow

#### Context

init_logging() currently builds a loggers dict, then calls structlog.configure(). Journal registration is completely absent. Console suppression under JOURNAL_STREAM exists but is unrelated.

#### Decision

Standalone block after loggers-dict construction, before structlog.configure(). Flow: (1) file logger, (2) console logger with JOURNAL_STREAM suppression, (3) journal logger via dynamic import — independent of console, gated by enable_systemd from config, (4) configure. Console suppression and journal registration are decoupled concerns.

#### Alternatives

a. Journal registration inside if log_to_console block — conflates two concerns. Rejected.
b. Separate helper function _try_register_journal() — over-abstraction for one try/except. Rejected.

#### Consequences

Clear control flow. JOURNAL_STREAM detection triggers info message when import fails, but does not gate the import attempt itself.

### systemd-facility-plumbing

#### Context

StoggerConfig.systemd_facility exists but init_logging() hardcodes syslog.LOG_LOCAL1 in the SystemdJournalRenderer constructor (core.py line 493). LOG_LOCAL1 was fc-agent-specific. The renderer's own default is LOG_LOCAL0 (line 642).

#### Decision

Fix now. init_logging() reads systemd_facility from StoggerConfig and passes it through to SystemdJournalRenderer unchanged. When config value is None, uses LOG_LOCAL0 (the renderer's own default). No type conversion — config value is passed as-is.

#### Alternatives

a. Keep LOG_LOCAL1 default — backward compat with fc-agent, but was never a conscious decision. Rejected.
b. Convert str to int via getattr(syslog, value) — adds complexity for a field that should match the renderer's expected type. Rejected.

#### Consequences

Existing users with [tool.stogger] systemd_facility set get correct behavior. Users relying on the hardcoded LOG_LOCAL1 get LOG_LOCAL0 instead — no practical impact since journal filtering rarely distinguishes between LOCAL0/LOCAL1.

### fallback-behavior

#### Context

Most stogger users don't need journal integration. Fallback must be graceful.

#### Decision

When JOURNAL_STREAM detected but stogger-systemd not installed (ImportError): one-time info message "systemd journal detected but stogger-systemd not available. Install stogger-systemd package for journal integration." No package manager mention. Non-systemd: silent skip.

#### Alternatives

a. Silent skip always — NixOS/systemd users have no indication journal is missing. Rejected.
b. Hard error — breaks existing setups that never used journal. Rejected.

#### Consequences

One conditional info message when JOURNAL_STREAM is set. Informational, not a warning.

### api-contract

#### Context

Core needs a stable, minimal interface to import from the extra package.

#### Decision

stogger-systemd exports get_journal_logger_factory() -> JournalLoggerFactory (structlog-compatible factory). Plus JournalLogger and DummyJournalLogger as importable classes.

#### Alternatives

a. Bare JournalLogger class — core would need to instantiate, coupling to constructor. Rejected.
b. Whole module as plugin — no explicit contract. Rejected.

#### Consequences

Single integration point. Core: factory = get_journal_logger_factory() then loggers["journal"] = factory.

### test-strategy

#### Context

Integration point in core.py must be testable without systemd-python.

#### Decision

Dual test locations. Permanent tests in tests/test_systemd_integration.py — mock stogger_systemd import via unittest.mock.patch. Spec-validation tests in tests/impl_spec/ until Phase 2 makes them green, then cleanup. Test matrix: (1) enable_systemd=True + import succeeds -> journal registered, (2) enable_systemd=True + ImportError -> fallback, (3) enable_systemd=False -> no import attempt, (4) JOURNAL_STREAM + ImportError -> info message.

#### Alternatives

a. Only spec-validation tests — no permanent coverage after cleanup. Rejected.
b. Only permanent tests — spec-validation contract not explicitly tracked. Rejected.

#### Consequences

All four integration paths tested without systemd-python dependency.

### migration-notice

#### Context

Existing stogger users may wonder if this is breaking.

#### Decision

No breaking change. Core never delivered actual journal I/O (stub returned None). At most "New: stogger-systemd package available" note.

#### Alternatives

a. Minor changelog entry — acceptable but not required since nothing removed. Rejected as unnecessary.
b. Major version bump — overkill, no behavior changes. Rejected.

#### Consequences

Stogger stays on current version. stogger-systemd is a pure addition.

## Verified By

<!-- Tests will be added after implementation -->


# Testing Guide

How tests are structured, what fixtures exist, and what patterns to follow when writing tests for stogger.

## Test Structure

```
tests/
├── conftest.py                    # Shared autouse fixture
├── test_core.py                   # Core rendering and pipeline tests
├── test_config.py                 # StoggerConfig and project detection
├── test_factory.py                # Factory and stdlib integration
├── test_decorators.py             # log_call, log_result, log_operation, log_scope
├── test_integration.py            # Cross-module pipeline tests
├── test_architecture.py           # pytest-archon layer boundary rules
├── test_exception_logging.py      # AST-based except-block convention checks
├── test_e2e_single_module_app.py  # Full pipeline, no mocks
├── test_systemd_integration.py    # Systemd renderer (mocked)
├── test_systemd_integration_real.py # Requires stogger-systemd package
├── test_postgres_integration.py   # Postgres renderer (mocked)
├── test_postgres_integration_real.py # Requires stogger-postgres package
```

## Markers

Defined in `pyproject.toml [tool.pytest.ini_options]`:

- `@pytest.mark.integration` — Tests real module interactions (most tests)
- `@pytest.mark.e2e` — Full pipeline exercises with no mocks
- `@pytest.mark.slow` — Tests taking more than 1 second

Run fast tests only: `uv run pytest -m "not slow"`

## Key Fixtures

### `conftest.py` — autouse structlog reset

```python
@pytest.fixture(autouse=True)
def _reset_structlog():
    """Reset structlog configuration after each test to avoid state leakage."""
    yield
    structlog.reset_defaults()
```

Every test file gets this automatically. Tests that configure structlog themselves (e.g., `test_integration.py`) also include a cleanup fixture that closes file handles from `MultiOptimisticLoggerFactory`.

### `captured_events` (test_decorators.py)

Configures structlog with a capturing processor that appends every event dict to a list. Used to assert on decorator output:

```python
@pytest.fixture
def captured_events():
    events: list[dict] = []
    structlog.configure(
        processors=[lambda _, __, ed: (events.append(dict(ed)), str(ed))[1]],
        wrapper_class=structlog.BoundLogger,
        logger_factory=structlog.PrintLoggerFactory(),
        cache_logger_on_first_use=False,
    )
    return events
```

### `create_pyproject_toml` (test_config.py)

Creates a temporary `pyproject.toml` with `[tool.stogger]` settings and patches `Path.cwd()`:

```python
@pytest.fixture
def create_pyproject_toml():
    with tempfile.TemporaryDirectory() as tmpdir:
        config_dir = Path(tmpdir)
        pyproject_path = config_dir / "pyproject.toml"
        with open(pyproject_path, "w") as f:
            f.write('[tool.stogger]\nverbose = true\n...')
        with patch("pathlib.Path.cwd", return_value=config_dir, autospec=True):
            yield
```

### `source_files` and `stogger_config` (test_exception_logging.py)

Provided by pytest-stogger. Returns source file paths and `[tool.pytest-stogger]` config for AST-based convention checking.

## Testing Patterns

### Configuring structlog in tests

Always set `cache_logger_on_first_use=False` so the autouse reset fixture can reconfigure:

```python
structlog.configure(
    processors=[...],
    wrapper_class=structlog.BoundLogger,
    logger_factory=...,
    cache_logger_on_first_use=False,
)
```

### Testing renderers

Call the renderer directly with the structlog processor signature `(logger, method_name, event_dict)`:

```python
renderer = ConsoleFileRenderer(format_config=FormatConfig())
result = renderer(None, "info", {
    "event": "test-event",
    "timestamp": "2024-01-01T00:00:00Z",
    "level": "info",
})
assert "test-event" in result["console"]
```

### Testing decorator output

Use `captured_events` fixture, then assert on the event dict:

```python
def test_log_call_captures_args(captured_events):
    @log_call
    def greet(name: str):
        return f"hello {name}"

    greet("world")
    evt = captured_events[0]
    assert evt["event"] == "called"
    assert evt["args"] == {"name": "world"}
```

### Architecture enforcement

`test_architecture.py` uses pytest-archon to enforce the dependency graph:

```
config.py ← (no internal deps)
_types.py ← (no internal deps)
_colors.py ← (no internal deps)
_regexes.py ← (no internal deps)
processors.py ← config.py
core.py ← config.py, _types.py, processors.py, _colors.py
factory.py ← config.py, core.py, processors.py
```

### AST convention checks

`test_exception_logging.py` runs pytest-stogger rules against the source. The `[tool.pytest-stogger]` section in `pyproject.toml` configures which files to scan and per-file rule exemptions.

## Coverage

Configured in `pyproject.toml`:

```toml
[tool.coverage.run]
source = ["src"]
branch = true
omit = ["tests/*", ".venv/*"]

[tool.coverage.report]
show_missing = true
precision = 2
```

Run with: `uv run pytest --cov=stogger --cov-report=term-missing`

## Commands

| Command | Purpose |
|---------|---------|
| `uv run pytest` | Run all tests |
| `uv run pytest -m "not slow"` | Fast tests only |
| `uv run pytest -m integration` | Integration tests |
| `uv run pytest -m e2e` | End-to-end tests |
| `uv run pytest --cov=stogger` | Tests with coverage |
| `CI=1 uv run tox -p` | Full CI pipeline (lint, test, docs, build) |

