# flask-pii-logger

Structured JSON logger with automatic PII redaction for Flask applications.

Logs are emitted as single-line JSON objects — ready to ship to Datadog, CloudWatch, Loki, or any log aggregator — with sensitive fields like passwords, tokens, IBANs, and card numbers scrubbed automatically before they ever hit disk.

---

## Features

- Structured JSON output on every log line
- Automatic PII redaction — passwords, tokens, IBANs, card numbers, API keys
- Per-request context stamping via `put_context`
- Rotating file handler + stdout handler out of the box
- Works with any Flask app factory pattern
- Full exception logging with stack traces (also redacted)

---

## Installation

```bash
pip install flask-pii-logger
```

Or from source:

```bash
git clone https://github.com/your-org/flask-pii-logger.git
cd flask-pii-logger
uv venv
uv pip install -e ".[dev]"
```

---

## Quick Start

```python
import logging
from flask import Flask
from flask_pii_logger import logger

def create_app():
    app = Flask(__name__)

    logger.init(
        app,
        level=logging.INFO,
        log_file="/var/log/myapp/app.log",  # optional — omit for stdout only
        backup_count=15,                     # optional — rotated files to keep
    )

    return app
```

---

## Logging

```python
from flask_pii_logger import logger

logger.info("User signed in", event="auth.login", user_id=42)
logger.warning("Rate limit approaching", event="rate.warning", remaining=5)
logger.error("Payment declined", event="payment.declined", order_id="ord_123")
logger.debug("Request payload", event="debug.payload", body=request.json)

# Inside an except block — captures type, message, and full stack trace
try:
    process_payment()
except Exception as exc:
    logger.exception(exc, event="payment.failed", order_id="ord_123")
```

---

## Per-Request Context

Use `put_context` inside `before_request` to stamp every log in that request with shared fields like `request_id`, `user_id`, or `tenant_id`:

```python
import uuid
from flask import request
from flask_pii_logger import logger

@app.before_request
def attach_context():
    logger.put_context("request_id", str(uuid.uuid4()))
    logger.put_context("method", request.method)
    logger.put_context("path", request.path)
```

Every subsequent log in that request will automatically include those fields:

```json
{
  "timestamp": "2026-05-06T10:00:00.000Z",
  "level": "INFO",
  "name": "myapp",
  "message": "Payment done",
  "event": "payment.success",
  "order_id": "ord_123",
  "request_id": "a1b2-c3d4",
  "method": "POST",
  "path": "/pay"
}
```

---

## PII Redaction

Redaction is automatic — you never need to scrub data manually before logging.

**Sensitive keys** are redacted by name regardless of value:

| Key | Redacted |
|---|---|
| `password`, `password_hash` | yes |
| `token`, `access_token`, `refresh_token` | yes |
| `authorization`, `auth` | yes |
| `secret`, `client_secret` | yes |
| `api_key`, `x_api_key` | yes |
| `iban`, `account_number` | yes |
| `card_number`, `cvv` | yes |

**Pattern-based redaction** runs on all string values:

| Pattern | Example |
|---|---|
| IBAN | `GB29NWBK60161331926819` → `[REDACTED]` |
| Card numbers | `4111 1111 1111 1111` → `[REDACTED]` |
| Bearer tokens | `Authorization: Bearer eyJ...` → `[REDACTED]` |
| Inline secrets | `password=s3cr3t` → `[REDACTED]` |

Redaction applies recursively to nested dicts, lists, and tuples:

```python
logger.info("User updated", user={
    "name": "Alice",
    "password": "s3cr3t",        # → [REDACTED]
    "card_number": "4111..."     # → [REDACTED]
})
```

---

## Log Output Format

Every line is a single JSON object:

```json
{
  "timestamp": "2026-05-06T10:00:01.123Z",
  "level": "ERROR",
  "name": "myapp",
  "message": "Unhandled exception",
  "event": "payment.failed",
  "error": {
    "type": "TimeoutError",
    "message": "Gateway timed out",
    "stack": ["Traceback (most recent call last):", "..."]
  },
  "order_id": "ord_123",
  "request_id": "a1b2-c3d4"
}
```

---

## API Reference

### `logger.init(app, level, log_file, backup_count)`

Initializes the logger. Call once in your app factory.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `app` | `Flask` | required | Your Flask application instance |
| `level` | `int` | `logging.INFO` | Minimum log level |
| `log_file` | `str \| None` | `None` | Path to rotating log file. `None` logs to stdout only |
| `backup_count` | `int` | `15` | Number of rotated log files to keep |

### `logger.info(msg, event, **kwargs)`
### `logger.warning(msg, event, **kwargs)`
### `logger.error(msg, event, **kwargs)`
### `logger.debug(msg, event, **kwargs)`

Log at the given level. All extra keyword arguments are included as fields in the JSON output.

| Parameter | Type | Description |
|---|---|---|
| `msg` | `str` | Log message |
| `event` | `str \| None` | Machine-readable event name e.g. `payment.failed` |
| `**kwargs` | `Any` | Any extra fields to include in the log |

### `logger.exception(exc, message, event, **kwargs)`

Log an exception with full stack trace. Use inside `except` blocks.

| Parameter | Type | Default | Description |
|---|---|---|---|
| `exc` | `Exception` | required | The caught exception |
| `message` | `str` | `"Unhandled exception"` | Human-readable message |
| `event` | `str \| None` | `None` | Machine-readable event name |

### `logger.put_context(key, value)`

Attach a field to the current request's log context. Must be called within a Flask request context (e.g. inside `before_request`).

---

## Development

```bash
# Clone and set up
git clone https://github.com/your-org/flask-pii-logger.git
cd flask-pii-logger
uv venv
uv pip install -e ".[dev]"

# Run tests
uv run pytest tests/ -v
```
