# Story 1.32: Log Errors on Skipped Paths

**Status:** Complete
**Priority:** Medium
**Depends on:** None

---

## Context / Background

The `skip_paths` feature in `LoggingMiddleware` allows users to exclude high-frequency endpoints (e.g., `/health`, `/metrics`) from logging to reduce noise. However, the current implementation performs a hard skip—errors on skipped paths are completely silenced.

**Current behavior** (`src/fapilog/fastapi/logging.py:44-46`):
```python
if path in self._skip_paths:
    return await call_next(request)  # Bypasses ALL logging, including errors
```

**The problem:** If a health endpoint returns a 500 error, that's operationally significant information that should be logged. The current behavior creates a blind spot for failures on commonly-skipped paths.

**Competitor analysis:** Express.js's Morgan logger provides a `skip` function that receives both request and response, enabling patterns like `skip: (req, res) => req.url === '/health' && res.statusCode < 400`. No Python library offers this as a declarative config option.

**Documentation gap:** The cookbook at `docs/cookbook/skip-noisy-endpoints.md:144-156` claims errors are still logged on skipped paths, but this doesn't match the implementation.

---

## Scope (In / Out)

### In Scope

- Add `log_errors_on_skip` parameter to `LoggingMiddleware` (default: `True`)
- Propagate parameter through `setup_logging()` in `src/fapilog/fastapi/setup.py`
- Update main documentation to cover this behavior
- Fix documentation/implementation mismatch in cookbook

### Out of Scope

- Regex or wildcard patterns for skip_paths (separate feature)
- Per-path error thresholds (e.g., only log 5xx, not 4xx)
- Metrics/counters for skipped paths

---

## Acceptance Criteria

### AC1: Errors Logged on Skipped Paths by Default

**Description:** When a request to a skipped path raises an exception, it is logged at ERROR level.

**Validation:**
```python
from starlette.testclient import TestClient

app = make_app(skip_paths=["/health"])

@app.get("/health")
def health():
    raise RuntimeError("Database connection failed")

client = TestClient(app, raise_server_exceptions=False)
resp = client.get("/health")

# Error is logged despite path being skipped
assert any(e["message"] == "request_failed" for e in logger.events)
assert any(e["metadata"].get("path") == "/health" for e in logger.events)
```

### AC2: Successful Requests on Skipped Paths Not Logged

**Description:** Successful (non-error) requests on skipped paths remain silent.

**Validation:**
```python
@app.get("/health")
def health():
    return {"status": "ok"}

client.get("/health")

# No log entries for successful health check
assert all(e["metadata"].get("path") != "/health" for e in logger.events)
```

### AC3: Opt-Out Available

**Description:** Users can disable error logging on skipped paths if desired.

**Validation:**
```python
lifespan = setup_logging(
    skip_paths=["/health"],
    log_errors_on_skip=False,  # Opt out
)
# Errors on /health are now silenced
```

### AC4: Documentation Updated

**Description:** Main documentation covers the `log_errors_on_skip` parameter.

**Validation:**
- Parameter documented in API reference
- Behavior explained in FastAPI integration guide
- Cookbook page updated to remove incorrect claim (or align with implementation)

### AC5: Contract Test - Error Logging Uses Same Format

**Description:** Errors logged on skipped paths use the same `_log_error` method and format as non-skipped paths.

**Validation:**
```python
# Error on skipped path
error_on_skip = get_error_log("/health")

# Error on normal path
error_on_normal = get_error_log("/api/users")

# Same structure
assert error_on_skip.keys() == error_on_normal.keys()
assert "request_failed" in error_on_skip["message"]
```

---

## Implementation Notes

### File Changes

```
src/fapilog/fastapi/logging.py (MODIFIED - add log_errors_on_skip logic)
src/fapilog/fastapi/setup.py (MODIFIED - propagate parameter)
tests/unit/test_logging_middleware.py (MODIFIED - add test cases)
tests/integration/test_fastapi_logging_middleware.py (MODIFIED - add integration tests)
docs/user-guide/fastapi.md (MODIFIED - document parameter)
docs/integrations/fastapi.md (MODIFIED - document parameter)
docs/cookbook/skip-noisy-endpoints.md (MODIFIED - align with implementation)
```

### Key Implementation

```python
# In LoggingMiddleware.__init__
self._log_errors_on_skip = bool(log_errors_on_skip) if log_errors_on_skip is not None else True

# In LoggingMiddleware.dispatch
if path in self._skip_paths:
    if not self._log_errors_on_skip:
        return await call_next(request)
    # Wrap in try-except to catch errors on skipped paths
    start = time.perf_counter()
    correlation_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
    try:
        return await call_next(request)
    except Exception as exc:
        status_code = exc.status_code if isinstance(exc, HTTPException) else 500
        await self._log_error(
            request=request,
            status_code=status_code,
            correlation_id=correlation_id,
            latency_ms=(time.perf_counter() - start) * 1000.0,
            exc=exc,
        )
        raise
```

---

## Tasks

### Phase 1: Core Implementation

- [ ] Add `log_errors_on_skip` parameter to `LoggingMiddleware.__init__`
- [ ] Modify `dispatch` to wrap skipped paths in try-except when enabled
- [ ] Extract correlation ID logic to reusable helper (avoid duplication)
- [ ] Add parameter to `setup_logging()` in setup.py

### Phase 2: Testing

- [ ] Unit test: error on skipped path is logged (default behavior)
- [ ] Unit test: success on skipped path is not logged
- [ ] Unit test: `log_errors_on_skip=False` silences errors
- [ ] Integration test: end-to-end with real FastAPI app
- [ ] Contract test: error format matches non-skipped errors

### Phase 3: Documentation

- [ ] Document `log_errors_on_skip` in `docs/user-guide/fastapi.md`
- [ ] Document `log_errors_on_skip` in `docs/integrations/fastapi.md`
- [ ] Align cookbook page with implementation
- [ ] Update CHANGELOG

---

## Tests

### Unit Tests

- `tests/unit/test_logging_middleware.py`
  - `test_skipped_path_error_logged_by_default`
  - `test_skipped_path_success_not_logged`
  - `test_log_errors_on_skip_false_silences_errors`
  - `test_error_format_matches_normal_path`

### Integration Tests

- `tests/integration/test_fastapi_logging_middleware.py`
  - `test_health_endpoint_error_logged`
  - `test_health_endpoint_success_silent`

---

## Definition of Done

### Code Complete

- [ ] All acceptance criteria implemented
- [ ] Default behavior is backward-compatible (errors logged)
- [ ] No duplication of correlation ID logic

### Quality Assurance

- [ ] Unit tests written and passing
- [ ] Integration tests written and passing
- [ ] `ruff check` passes
- [ ] `mypy` passes
- [ ] Coverage >= 90% on changed lines
- [ ] `vulture src/ tests/` passes

### Documentation

- [ ] `docs/user-guide/fastapi.md` updated
- [ ] `docs/integrations/fastapi.md` updated
- [ ] Cookbook aligned with implementation
- [ ] CHANGELOG updated

---

## Risks / Rollback

### Risks

1. **Risk:** Behavior change - users expecting complete silence will now see error logs
   - **Mitigation:** This adds visibility (beneficial), not removes it. Provide `log_errors_on_skip=False` opt-out for users who need complete silence.

2. **Risk:** Performance impact from try-except on skipped paths
   - **Mitigation:** Minimal - only adds overhead when errors occur (rare case)

### Rollback Plan

If issues occur:
1. Revert the PR
2. Previous behavior (hard skip) is restored

---

## Related Stories

- **Related:** Story 12.7 - Skip Noisy Endpoints (cookbook documentation)
- **Enables:** Future story for per-path error level configuration

---

## Code Review

**Date:** 2026-01-21
**Reviewer:** Claude
**Verdict:** OK to PR

### Summary

Added `log_errors_on_skip` parameter to LoggingMiddleware (default: True) to log unhandled exceptions on skipped paths while suppressing success logs.

### AC Verification

| Criterion | Evidence |
|-----------|----------|
| AC1: Errors logged on skipped paths | `logging.py:55-66` - try-except wrapper calls `_log_error` |
| AC2: Success not logged | `logging.py:54` - returns directly without logging |
| AC3: Opt-out available | `logging.py:48-49` - early return when `log_errors_on_skip=False` |
| AC4: Documentation updated | `user-guide/fastapi.md:533`, `cookbook/skip-noisy-endpoints.md:179-204` |
| AC5: Contract test | `test_error_format_matches_normal_path` verifies same keys |

### Quality Gates

- [x] ruff check passed
- [x] ruff format passed
- [x] mypy passed
- [x] diff-cover >= 90% (92%)
- [x] No weak assertions
- [x] No dead code (false positives only)

---

## Change Log

| Date | Change | Author |
|------|--------|--------|
| 2026-01-21 | Initial draft | Claude |
| 2026-01-21 | Implementation complete, code review passed | Claude |
