# Story 5.20: HTTP Request Context Enricher

## Status: Cancelled

## Priority: ~~High~~ N/A

## Estimated Effort: ~~Medium (2-3 days)~~ N/A

## Dependencies: None

## Epic: Built-in Enrichers

---

## Cancellation Rationale

**Decision Date:** 2026-01-06

This story was cancelled after analysis showed that existing functionality already addresses the use case adequately:

### Existing Capabilities

1. **`LoggingMiddleware`** already logs request completion with `method`, `path`, `status`, `latency_ms`, `correlation_id`
2. **`RequestContextMiddleware`** sets `request_id` in contextvars for every request
3. **`ContextVarsEnricher`** adds `request_id`, `user_id`, `trace_id`, `span_id` to every log entry
4. **`logger.bind()`** allows manual HTTP context binding when needed

### Why the Proposed Solution Was Unnecessary

| Proposed Feature                | Already Solved By                                  |
| ------------------------------- | -------------------------------------------------- |
| Correlate logs to requests      | ✅ `request_id` enrichment                         |
| See HTTP method/path for errors | ✅ Completion log has it, same `request_id`        |
| Filter logs by endpoint         | ✅ Query completion logs by path, use `request_id` |

### What Was Done Instead

Documentation was updated in `docs/user-guide/fastapi.md` to explain:

- How `request_id` correlation works
- How to use `logger.bind()` for explicit HTTP context
- Best practices for querying correlated logs

### Key Insight

Adding `http_method` and `http_path` to **every** log entry provides marginal value over the existing `request_id` correlation pattern, while increasing log size and code complexity.

---

## Original Context (for reference)

FastAPI and Starlette are primary use cases for fapilog. Currently, users must manually pass request context to every log call, which is tedious and error-prone:

```python
@app.get("/users/{user_id}")
async def get_user(request: Request, user_id: int):
    logger.info("Fetching user", user_id=user_id, path=request.url.path, method=request.method)
    # Every log call needs manual context...
```

An HTTP request enricher would automatically capture request context from `contextvars`, making logs rich without manual effort.

---

## Problem Statement

1. **Manual context passing** is tedious and often incomplete
2. **Inconsistent request logging** across different endpoints
3. **Missing correlation** between request and subsequent log entries
4. **No standard format** for HTTP context in log entries
5. **Privacy concerns** require careful handling of headers/IPs

---

## Acceptance Criteria

### AC1: Core Enricher Implementation

- [ ] Create `HttpRequestEnricher` class implementing `BaseEnricher`
- [ ] Capture request method, path, and query parameters
- [ ] Capture response status code (when available)
- [ ] Calculate request duration from start time
- [ ] Extract client IP with proxy header support (X-Forwarded-For)
- [ ] Extract user agent (truncated for safety)

### AC2: Context Integration

- [ ] Use `contextvars` for request state storage
- [ ] Provide `set_request_context()` helper for middleware integration
- [ ] Automatically clear context after request completes
- [ ] Support async and sync request handlers

### AC3: FastAPI/Starlette Middleware

- [ ] Provide optional middleware that sets request context automatically
- [ ] Middleware should be zero-config for basic usage
- [ ] Support configuration for header extraction, IP handling

### AC4: Privacy and Security

- [ ] Never log sensitive headers (Authorization, Cookie, etc.) by default
- [ ] Truncate user agent to prevent log bloat
- [ ] Support configurable field exclusions
- [ ] Hash or mask client IPs when configured

### AC5: Performance

- [ ] Enricher adds < 0.5ms overhead per request
- [ ] Context storage is lightweight
- [ ] No I/O operations in enricher

### AC6: Documentation

- [ ] Document enricher in `docs/plugins/enrichers.md`
- [ ] Add FastAPI integration example
- [ ] Document privacy configuration options

---

## Technical Design

### 1. Context Variables

```python
# src/fapilog/plugins/enrichers/http_request.py

from __future__ import annotations

import time
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Any

# Context storage for request state
_http_request_context: ContextVar[HttpRequestContext | None] = ContextVar(
    "http_request_context", default=None
)


@dataclass
class HttpRequestContext:
    """Captured HTTP request context."""

    method: str
    path: str
    query_string: str = ""
    client_ip: str | None = None
    user_agent: str | None = None
    request_id: str | None = None
    start_time: float = field(default_factory=time.monotonic)
    status_code: int | None = None

    # Additional headers (filtered for safety)
    headers: dict[str, str] = field(default_factory=dict)


def set_request_context(
    method: str,
    path: str,
    *,
    query_string: str = "",
    client_ip: str | None = None,
    user_agent: str | None = None,
    request_id: str | None = None,
    headers: dict[str, str] | None = None,
) -> HttpRequestContext:
    """Set HTTP request context for the current async context.

    Call this at the start of request handling (typically in middleware).

    Args:
        method: HTTP method (GET, POST, etc.)
        path: Request path (e.g., /api/users/123)
        query_string: Query string without leading ?
        client_ip: Client IP address
        user_agent: User-Agent header (will be truncated)
        request_id: Request ID for correlation
        headers: Additional safe headers to include

    Returns:
        The created HttpRequestContext
    """
    ctx = HttpRequestContext(
        method=method.upper(),
        path=path,
        query_string=query_string,
        client_ip=client_ip,
        user_agent=user_agent[:256] if user_agent else None,
        request_id=request_id,
        headers=headers or {},
    )
    _http_request_context.set(ctx)
    return ctx


def set_response_status(status_code: int) -> None:
    """Set the response status code in current request context.

    Call this after response is determined (typically in middleware).
    """
    ctx = _http_request_context.get()
    if ctx is not None:
        ctx.status_code = status_code


def clear_request_context() -> None:
    """Clear the HTTP request context.

    Call this after request completes (typically in middleware finally block).
    """
    _http_request_context.set(None)


def get_request_context() -> HttpRequestContext | None:
    """Get the current HTTP request context, if any."""
    return _http_request_context.get()
```

### 2. Enricher Implementation

```python
# src/fapilog/plugins/enrichers/http_request.py (continued)

import time
from dataclasses import dataclass
from typing import Any


@dataclass
class HttpRequestEnricherConfig:
    """Configuration for HTTP request enricher."""

    include_query_string: bool = True
    include_user_agent: bool = True
    include_client_ip: bool = True
    include_duration: bool = True
    include_headers: list[str] = field(default_factory=list)
    mask_client_ip: bool = False  # Hash last octet for privacy
    user_agent_max_length: int = 256
    prefix: str = "http_"  # Field name prefix


class HttpRequestEnricher:
    """Enrich log entries with HTTP request context.

    Automatically captures request information from contextvars when
    set by middleware or manual instrumentation.

    Fields added (with default prefix "http_"):
    - http_method: HTTP method (GET, POST, etc.)
    - http_path: Request path
    - http_query: Query string (if enabled)
    - http_status: Response status code (if set)
    - http_duration_ms: Request duration in milliseconds
    - http_client_ip: Client IP address (if enabled)
    - http_user_agent: User-Agent header (truncated)
    - http_request_id: Request correlation ID
    """

    name = "http_request"

    def __init__(
        self,
        *,
        config: HttpRequestEnricherConfig | dict | None = None,
        **kwargs: Any,
    ) -> None:
        from ..utils import parse_plugin_config

        cfg = parse_plugin_config(HttpRequestEnricherConfig, config, **kwargs)
        self._config = cfg
        self._prefix = cfg.prefix

    async def start(self) -> None:
        """Initialize enricher (no-op)."""
        pass

    async def stop(self) -> None:
        """Cleanup enricher (no-op)."""
        pass

    async def enrich(self, event: dict) -> dict:
        """Add HTTP request context to the event.

        Returns empty dict if no request context is set.
        """
        ctx = get_request_context()
        if ctx is None:
            return {}

        result: dict[str, Any] = {}
        p = self._prefix

        # Always include method and path
        result[f"{p}method"] = ctx.method
        result[f"{p}path"] = ctx.path

        # Optional fields
        if self._config.include_query_string and ctx.query_string:
            result[f"{p}query"] = ctx.query_string

        if ctx.status_code is not None:
            result[f"{p}status"] = ctx.status_code

        if self._config.include_duration:
            duration_ms = (time.monotonic() - ctx.start_time) * 1000
            result[f"{p}duration_ms"] = round(duration_ms, 2)

        if self._config.include_client_ip and ctx.client_ip:
            ip = ctx.client_ip
            if self._config.mask_client_ip:
                ip = self._mask_ip(ip)
            result[f"{p}client_ip"] = ip

        if self._config.include_user_agent and ctx.user_agent:
            result[f"{p}user_agent"] = ctx.user_agent[:self._config.user_agent_max_length]

        if ctx.request_id:
            result[f"{p}request_id"] = ctx.request_id

        # Include configured headers
        for header in self._config.include_headers:
            header_lower = header.lower()
            if header_lower in ctx.headers:
                safe_key = header_lower.replace("-", "_")
                result[f"{p}header_{safe_key}"] = ctx.headers[header_lower]

        return result

    async def health_check(self) -> bool:
        """Verify enricher is operational."""
        return True

    def _mask_ip(self, ip: str) -> str:
        """Mask IP address for privacy (last octet for IPv4, last group for IPv6)."""
        if ":" in ip:  # IPv6
            parts = ip.rsplit(":", 1)
            return f"{parts[0]}:****"
        else:  # IPv4
            parts = ip.rsplit(".", 1)
            return f"{parts[0]}.***"
```

### 3. FastAPI/Starlette Middleware

```python
# src/fapilog/fastapi/middleware.py (or add to existing)

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response

from ..plugins.enrichers.http_request import (
    clear_request_context,
    set_request_context,
    set_response_status,
)


class FapilogRequestMiddleware(BaseHTTPMiddleware):
    """Middleware that sets HTTP request context for fapilog enrichers.

    Usage:
        from fapilog.fastapi import FapilogRequestMiddleware

        app = FastAPI()
        app.add_middleware(FapilogRequestMiddleware)
    """

    def __init__(
        self,
        app,
        *,
        trust_proxy_headers: bool = True,
        request_id_header: str = "X-Request-ID",
        safe_headers: list[str] | None = None,
    ):
        super().__init__(app)
        self._trust_proxy = trust_proxy_headers
        self._request_id_header = request_id_header
        self._safe_headers = set(h.lower() for h in (safe_headers or []))

    async def dispatch(self, request: Request, call_next) -> Response:
        # Extract request info
        client_ip = self._get_client_ip(request)
        request_id = request.headers.get(self._request_id_header)
        user_agent = request.headers.get("user-agent")

        # Extract safe headers
        headers = {}
        for header in self._safe_headers:
            if header in request.headers:
                headers[header] = request.headers[header]

        # Set context
        set_request_context(
            method=request.method,
            path=request.url.path,
            query_string=str(request.url.query),
            client_ip=client_ip,
            user_agent=user_agent,
            request_id=request_id,
            headers=headers,
        )

        try:
            response = await call_next(request)
            set_response_status(response.status_code)
            return response
        finally:
            clear_request_context()

    def _get_client_ip(self, request: Request) -> str | None:
        """Extract client IP, respecting proxy headers if configured."""
        if self._trust_proxy:
            # Check X-Forwarded-For first
            forwarded = request.headers.get("x-forwarded-for")
            if forwarded:
                # Take first IP (original client)
                return forwarded.split(",")[0].strip()

            # Check X-Real-IP
            real_ip = request.headers.get("x-real-ip")
            if real_ip:
                return real_ip.strip()

        # Fall back to direct client
        if request.client:
            return request.client.host
        return None
```

### 4. Registration

```python
# src/fapilog/plugins/enrichers/__init__.py

from .http_request import (
    HttpRequestEnricher,
    HttpRequestEnricherConfig,
    set_request_context,
    set_response_status,
    clear_request_context,
    get_request_context,
)

register_builtin(
    "fapilog.enrichers",
    "http_request",
    HttpRequestEnricher,
    aliases=["http-request"],
)

__all__ = [
    # ... existing exports ...
    "HttpRequestEnricher",
    "HttpRequestEnricherConfig",
    "set_request_context",
    "set_response_status",
    "clear_request_context",
    "get_request_context",
]
```

---

## Test Plan

### Unit Tests

**test_http_request_enricher.py**

```python
import pytest
from fapilog.plugins.enrichers.http_request import (
    HttpRequestEnricher,
    HttpRequestEnricherConfig,
    set_request_context,
    set_response_status,
    clear_request_context,
)


@pytest.fixture
def enricher():
    return HttpRequestEnricher()


@pytest.fixture(autouse=True)
def cleanup_context():
    yield
    clear_request_context()


@pytest.mark.asyncio
async def test_enrich_with_request_context(enricher):
    """Should add request fields when context is set."""
    set_request_context(
        method="GET",
        path="/api/users/123",
        client_ip="192.168.1.1",
        user_agent="Mozilla/5.0",
    )

    result = await enricher.enrich({})

    assert result["http_method"] == "GET"
    assert result["http_path"] == "/api/users/123"
    assert result["http_client_ip"] == "192.168.1.1"
    assert "http_duration_ms" in result


@pytest.mark.asyncio
async def test_enrich_without_context(enricher):
    """Should return empty dict when no context is set."""
    result = await enricher.enrich({})
    assert result == {}


@pytest.mark.asyncio
async def test_response_status_captured(enricher):
    """Should capture response status code."""
    set_request_context(method="POST", path="/api/users")
    set_response_status(201)

    result = await enricher.enrich({})
    assert result["http_status"] == 201


@pytest.mark.asyncio
async def test_ip_masking():
    """Should mask IP when configured."""
    enricher = HttpRequestEnricher(config={"mask_client_ip": True})
    set_request_context(method="GET", path="/", client_ip="192.168.1.123")

    result = await enricher.enrich({})
    assert result["http_client_ip"] == "192.168.1.***"


@pytest.mark.asyncio
async def test_user_agent_truncation():
    """Should truncate long user agents."""
    enricher = HttpRequestEnricher(config={"user_agent_max_length": 50})
    long_ua = "A" * 200
    set_request_context(method="GET", path="/", user_agent=long_ua)

    result = await enricher.enrich({})
    assert len(result["http_user_agent"]) == 50


@pytest.mark.asyncio
async def test_custom_prefix():
    """Should use custom field prefix."""
    enricher = HttpRequestEnricher(config={"prefix": "req_"})
    set_request_context(method="GET", path="/test")

    result = await enricher.enrich({})
    assert "req_method" in result
    assert "http_method" not in result
```

### Integration Tests

**test_fastapi_http_enricher_integration.py**

```python
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from fapilog import get_logger
from fapilog.fastapi import FapilogRequestMiddleware
from fapilog.testing import MockSink


@pytest.fixture
def app_with_enricher():
    app = FastAPI()
    app.add_middleware(FapilogRequestMiddleware)

    sink = MockSink()
    logger = get_logger(
        settings={"core": {"enrichers": ["http_request"]}},
        sinks=[sink],
    )

    @app.get("/test/{item_id}")
    async def get_item(item_id: int):
        logger.info("Getting item", item_id=item_id)
        return {"item_id": item_id}

    return app, sink


def test_request_enriched_in_logs(app_with_enricher):
    app, sink = app_with_enricher
    client = TestClient(app)

    response = client.get("/test/42")
    assert response.status_code == 200

    # Check log entry has HTTP context
    entries = sink.get_entries()
    assert len(entries) >= 1

    entry = entries[0]
    assert entry["http_method"] == "GET"
    assert entry["http_path"] == "/test/42"
    assert "http_duration_ms" in entry
```

---

## Documentation Updates

### docs/plugins/enrichers.md

Add section:

````markdown
## HTTP Request Enricher

Automatically adds HTTP request context to log entries.

### Quick Start

```python
from fastapi import FastAPI
from fapilog import get_logger, Settings
from fapilog.fastapi import FapilogRequestMiddleware

app = FastAPI()
app.add_middleware(FapilogRequestMiddleware)

settings = Settings()
settings.core.enrichers = ["http_request"]
logger = get_logger(settings=settings)

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    logger.info("Fetching user", user_id=user_id)
    # Log automatically includes: http_method, http_path, http_duration_ms, etc.
```
````

### Fields Added

| Field              | Description            | Example              |
| ------------------ | ---------------------- | -------------------- |
| `http_method`      | HTTP method            | `GET`                |
| `http_path`        | Request path           | `/api/users/123`     |
| `http_query`       | Query string           | `sort=name&limit=10` |
| `http_status`      | Response status        | `200`                |
| `http_duration_ms` | Request duration       | `45.23`              |
| `http_client_ip`   | Client IP              | `192.168.1.1`        |
| `http_user_agent`  | User agent (truncated) | `Mozilla/5.0...`     |
| `http_request_id`  | Correlation ID         | `abc-123-def`        |

### Configuration

```python
settings.enricher_config.http_request = {
    "include_query_string": True,
    "include_user_agent": True,
    "include_client_ip": True,
    "include_duration": True,
    "mask_client_ip": False,  # Set True for privacy
    "user_agent_max_length": 256,
    "prefix": "http_",
}
```

### Manual Context (Without Middleware)

```python
from fapilog.plugins.enrichers.http_request import set_request_context, clear_request_context

async def my_handler(request):
    set_request_context(
        method=request.method,
        path=request.path,
        client_ip=request.client.host,
    )
    try:
        # Your handler logic with logging
        logger.info("Processing request")
    finally:
        clear_request_context()
```

```

---

## Backwards Compatibility

- New enricher, no breaking changes
- Middleware is opt-in
- Existing logging continues to work without HTTP context

---

## Related Stories

- Story 5.2: Add filter plugin type
- Story 5.21: Kubernetes enricher
- Fapilog FastAPI integration module

```
