# Story 1.23: Cache Settings on Hot Path

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

---

## Context / Background

The GPT-5.2 audit identified a performance bottleneck in `_prepare_payload()` where `Settings()` is instantiated on every log call.

**Current state in `src/fapilog/core/logger.py:255-284`:**

```python
def _prepare_payload(self, level: str, message: str, ...) -> dict | None:
    ...
    try:
        s = Settings()  # Created on EVERY log call (#1)
        rate = float(s.observability.logging.sampling_rate)
        filters = getattr(getattr(s, "core", None), "filters", []) or []
        ...
    except Exception:
        pass

    try:
        if level in {"ERROR", "CRITICAL"}:
            from .settings import Settings as _S
            window = float(_S().core.error_dedupe_window_seconds)  # Created on EVERY error (#2)
            ...
    except Exception:
        pass
```

**Problem:**

- `Settings()` involves Pydantic model instantiation, parsing, and validation
- This overhead occurs on every single log call (line 255) or every error (line 284)
- At high logging rates (10k+ logs/sec), this becomes a measurable bottleneck
- The sampling_rate check is deprecated anyway - this code path should be minimal
- Error deduplication also suffers unnecessary overhead

**Impact:**

- Unnecessary CPU overhead on hot path
- Memory churn from temporary Settings objects
- Latency spikes under high load

---

## Scope (In / Out)

### In Scope

- Cache sampling-related settings at logger/mixin initialization
- Remove per-call Settings() instantiation from hot path
- Maintain deprecation warning behavior for sampling_rate
- Ensure settings changes still take effect (on logger recreation)

### Out of Scope

- Dynamic settings reloading (not currently supported anyway)
- Removing deprecated sampling_rate entirely (separate cleanup)
- Other performance optimizations

---

## Acceptance Criteria

### AC1: No Settings() in Hot Path

**Description:** `_prepare_payload()` no longer instantiates Settings() - neither for sampling nor error deduplication.

**Validation:**

```bash
# Check that Settings() calls are only in _common_init, not _prepare_payload
grep -n "Settings()" src/fapilog/core/logger.py
# Should show only in _common_init area, not lines 255 or 284
```

### AC2: Settings Values Cached at Init

**Description:** Sampling rate, filter config, and error dedupe window are cached during mixin initialization.

**Validation:**

```python
# In _LoggerMixin._common_init() or similar:
self._cached_sampling_rate: float = ...
self._cached_sampling_filters: set[str] = ...
self._cached_error_dedupe_window: float = ...
```

### AC3: Deprecation Warning Still Works

**Description:** Users with sampling_rate < 1.0 still see deprecation warning.

**Validation:**

```python
import warnings
with warnings.catch_warnings(record=True) as w:
    warnings.simplefilter("always")
    # Log with sampling_rate < 1.0 configured
    logger.info("test")
    assert any("sampling_rate is deprecated" in str(x.message) for x in w)
```

### AC4: Existing Tests Pass

**Description:** All logger tests continue to pass unchanged.

**Validation:**

```bash
pytest tests/unit/test_logger*.py -v
```

---

## Implementation Notes

### File Changes

```text
src/fapilog/core/logger.py (MODIFIED - cache settings at init)
tests/unit/test_logger_performance.py (NEW - optional benchmark)
```

### Key Changes

**Option A: Cache at mixin init (Recommended)**

```python
class _LoggerMixin:
    def _common_init(self, ...):
        ...
        # Cache settings values once
        try:
            s = Settings()
            self._sampling_rate = float(s.observability.logging.sampling_rate)
            filters = getattr(getattr(s, "core", None), "filters", []) or []
            self._sampling_filters = {
                name.replace("-", "_").lower()
                for name in filters
                if isinstance(name, str)
            }
            self._sampling_configured = bool(
                self._sampling_filters & {"sampling", "adaptive_sampling", "trace_sampling"}
            )
            self._error_dedupe_window = float(s.core.error_dedupe_window_seconds)
        except Exception:
            self._sampling_rate = 1.0
            self._sampling_filters = set()
            self._sampling_configured = False
            self._error_dedupe_window = 0.0
```

**Option B: Module-level caching**

```python
# At module level, lazily cached
_cached_settings: Settings | None = None

def _get_sampling_settings() -> tuple[float, set[str], bool]:
    global _cached_settings
    if _cached_settings is None:
        _cached_settings = Settings()
    ...
```

Option A is preferred as it ties caching to logger lifecycle.

---

## Tasks

### Phase 1: Implementation

- [ ] Add cached sampling fields to `_LoggerMixin`
- [ ] Initialize cached fields in `__init__`
- [ ] Update `_prepare_payload()` to use cached values
- [ ] Remove Settings() call from hot path

### Phase 2: Testing

- [ ] Run existing logger tests
- [ ] Verify deprecation warning still works
- [ ] Optional: Add simple benchmark test

### Phase 3: Documentation

- [ ] Update CHANGELOG

---

## Tests

### Existing Tests

All existing tests must pass:

- `tests/unit/test_logger*.py` (10+ test files)

### Optional New Test

```python
# tests/unit/test_logger_performance.py
def test_prepare_payload_no_settings_instantiation(mocker):
    """Verify Settings() is not called on every log."""
    mock_settings = mocker.patch("fapilog.core.logger.Settings")

    logger = get_logger()
    # After init, Settings was called
    init_count = mock_settings.call_count

    for _ in range(100):
        logger.info("test message")

    # Settings should not be called again during logging
    assert mock_settings.call_count == init_count
```

---

## Definition of Done

### Code Complete

- [x] Settings() removed from _prepare_payload()
- [x] Sampling settings cached at init
- [x] Deprecation warning preserved

### Quality Assurance

- [x] All existing tests pass
- [x] `ruff check` passes
- [x] `mypy` passes

### Documentation

- [x] CHANGELOG updated

---

## Risks / Rollback

### Risks

1. **Risk:** Settings changes after logger creation not reflected
   - **Mitigation:** This is already the case; loggers don't hot-reload settings

2. **Risk:** Caching introduces subtle bugs with sampling behavior
   - **Mitigation:** Existing tests cover sampling; deprecation warning test added

### Rollback Plan

If issues occur:

1. Revert to per-call Settings() instantiation
2. Accept performance overhead as acceptable

---

## Related Stories

- **Related:** Story 1.21 - Logger module extraction (logger.py complexity)
- **Note:** This addresses GPT-5.2 audit performance bottleneck finding

---

## Code Review

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

### Summary

Caches sampling_rate, filter_config, and error_dedupe_window at logger initialization, removing Settings() instantiation from the hot path.

### AC Verification

| Criterion | Evidence |
|-----------|----------|
| AC1: No Settings() in Hot Path | `logger.py:144` - only Settings() call in `_common_init()` |
| AC2: Settings Values Cached at Init | `logger.py:136-158` - cached fields initialized |
| AC3: Deprecation Warning Still Works | `logger.py:282-291` + test validates |
| AC4: Existing Tests Pass | 141 tests pass |

### Quality Gates

- [x] ruff check passed
- [x] ruff format passed
- [x] mypy passed
- [x] diff-cover 85.7% (uncovered lines are exception fallback and probabilistic sampling)
- [x] No weak assertions
- [x] No dead code

---

## Change Log

| Date       | Change        | Author |
|------------|---------------|--------|
| 2026-01-17 | Code review passed | Claude |
| 2026-01-16 | Initial draft | Claude |
