pryscraper/DEVELOPMENT.md
cryptorugmunch 117001006f feat(logging): add structlog + JSON logging (CONVENTIONS.md Part 5)
Pry logs are now JSON objects with the required fields (timestamp,
level, service, event, plus key-value pairs). This is the standard
required by CONVENTIONS.md Part 5 and is what makes the service
operable in production (Loki, ELK, etc. can index the structured
records).

New module logging_config.py:
  setup_logging(level, fmt) - configure once at process startup
  get_logger(name)         - get a structlog logger; falls back to stdlib
  is_configured()          - diagnostic for /health

Configuration via env vars:
  PRY_LOG_FORMAT=json|console   (default json)
  PRY_LOG_LEVEL=DEBUG|INFO|...  (default INFO)
  PRY_LOG_STRICT_EXTRAS=1       (default unset = lenient)

Backward compatibility:
  - stdlib logging.getLogger(__name__) calls still work
  - setup_logging bridges stdlib through structlog's formatter
  - In lenient mode, extra={...} keys that collide with reserved
    LogRecord names (e.g. 'name') are moved to an `extra` sub-dict
    so existing code doesn't crash

Wired in:
  api.py: setup_logging() at module import time; lifespan log uses
          structlog style (logger.info("event", key="value") without
          the `extra={...}` wrapper)
  pyproject.toml: structlog>=24.0.0 dep added

Fixed source files that used reserved LogRecord keys in extra={...}:
  agency.py:        "name" -> "agency_name"
  auth_connector.py: "name" -> "credential_name"
  monitor.py:       "name" -> "monitor_name"
  pipelines.py:     "name" -> "pipeline_name"
  llm_providers/registry.py: "name" -> "provider_name"
These would have crashed with KeyError "Attempt to overwrite 'name' in
LogRecord" the moment a real log handler was attached.

Tests: 8/8 in test_logging_config.py pass. Full test suite went from
14 failures -> 2 (one is the SSE subprocess test that doesn't work in
this sandbox; one was the openapi title test that I also fixed in
this commit).

Documentation: DEVELOPMENT.md now has a full "Logging" section with
quick-start, config, and the reserved-key gotcha.
2026-07-02 20:55:41 +02:00

153 lines
4.1 KiB
Markdown

[//]: # (SPDX-License-Identifier: MIT)
[//]: # (Copyright (c) 2026 Rug Munch Media LLC)
# DEVELOPMENT.md — PryScraper
> Dev workflow. Install, code, test, commit, PR.
## Setup
### Prerequisites
- Python 3.12+ / Node 20+
- `gopass` for secrets
- `mise` for tool version mgmt (or manual)
- `pre-commit` for hooks
### Install
```bash
git clone https://git.rugmunch.io/RugMunchMedia/pryscraper.git
cd pryscraper
make install
pre-commit install
```
### Environment
```bash
# Required env vars (loaded from gopass on deploy, .env locally)
# See .env.example
cp .env.example .env
$EDITOR .env # fill in test values
```
## Workflow
### 1. Create a branch
```bash
git checkout -b feat/my-feature
# or fix/my-bug, docs/my-doc, chore/my-chore
```
### 2. Make changes
- Write code
- Add tests
- Update docs (AGENTS.md, ARCHITECTURE.md, STATUS.md)
### 3. Run pre-commit locally
```bash
make lint
make test
make typecheck
make security
```
### 4. Commit (conventional)
```bash
make commit # interactive
# or:
git commit -m "feat(scope): add new feature"
```
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, ops, security
### 5. Push + PR
```bash
git push -u origin feat/my-feature
# Open PR on forgejo: https://git.rugmunch.io/RugMunchMedia/pryscraper/pulls/new
```
### 6. Wait for CI
- All checks must pass
- Review by CODEOWNERS
- Squash-merge to main
- Auto-deploys to Talos (via forgejo webhook)
## Daily End-of-Day
```bash
make status # show what's changed
fleet-commit # commit helper with checklist
```
## Code Style
- Python: ruff (lint + format), mypy strict
- TypeScript: eslint + prettier
- Shell: shellcheck
- Markdown: vale
See [standards/CONVENTIONS.md](https://git.rugmunch.io/RugMunchMedia/standards/raw/branch/main/CONVENTIONS.md).
## Logging
Pry uses `structlog` for structured (JSON) logging per `CONVENTIONS.md` Part 5.
Every log line is a JSON object with the required fields: `timestamp`, `level`,
`service`, `event`, plus any key-value pairs you pass to the logger.
### Quick start
```python
from logging_config import setup_logging, get_logger
# Call once at process startup. api.py does this at module import time.
setup_logging()
log = get_logger(__name__)
log.info("scrape_started", url=url, mode="stealth")
log.warning("rate_limited", host=host, retry_after=retry)
log.error("scrape_failed", url=url, error=str(e))
```
Output (JSON, one record per line):
```json
{"url": "https://...", "mode": "stealth", "event": "scrape_started", "level": "info", "timestamp": "2026-07-02T18:34:19.567377Z", "service": "pry"}
```
### Configuration
Set via environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `PRY_LOG_FORMAT` | `json` | `json` (production) or `console` (local dev, colored) |
| `PRY_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `PRY_LOG_STRICT_EXTRAS` | (unset) | `1` to fail-fast on `extra={...}` keys that collide with reserved LogRecord names (default is lenient — reserved keys get moved to an `extra` sub-dict) |
### Backward compatibility
Code that uses stdlib `logging.getLogger(__name__)` keeps working. The
`setup_logging()` call bridges stdlib through structlog's formatter, so
`logger.warning("msg", extra={"foo": 1})` produces the same JSON shape as
`get_logger(__name__).warning("msg", foo=1)`.
### Reserved key gotcha
`logger.warning("event", extra={"name": "x"})` will crash with `KeyError:
"Attempt to overwrite 'name' in LogRecord"` in strict mode, because `name`
is a reserved stdlib field. In lenient mode (default) the key gets moved
to an `extra` sub-dict. **Prefer the structlog style** (`log.warning("event", name="x")`)
which doesn't have this issue.
### Where setup_logging() is called
- `api.py` at module import time (lifespan startup also calls it for safety)
- `cli.py` should call it before any logger usage (TODO)
### Adding to a new module
```python
from logging_config import get_logger # or just `import logging; log = logging.getLogger(__name__)`
log = get_logger(__name__)
def my_function(x: int) -> int:
log.info("my_function_called", x=x)
return x * 2
```