# Story 10.7: Fluent Builder API

## Status: InProgress

## Priority: Medium

## Estimated Effort: Medium (2-3 days)

## Dependencies

- **Depends on:** Story 10.1 (Configuration Presets) - Builder uses preset system
- **Depends on:** Story 10.4 (Human-Readable Config Strings) - Builder methods support string formats ("10 MB", "daily")

## Epic / Series

Part of Epic 10: Developer Experience and Ergonomics / Series 10.x

---

## Context / Background

Some users prefer a discoverable, chainable builder pattern for configuration. A fluent builder should provide IDE-friendly method chaining without creating a new configuration model.

**Note**: fapilog already provides multiple configuration options:

- **Presets** (Story 10.1) - ✅ Simple one-liner: `get_logger(preset="production")`
- **Settings object** - ✅ Full control: `Settings(core__log_level="INFO", ...)`
- **Environment variables** - ✅ 12-factor: `FAPILOG_CORE__LOG_LEVEL=INFO`

**Builder pattern adds value for:**

- Users who prefer method chaining and IDE autocomplete
- Progressive configuration (start with preset, override specific settings)
- Readable, self-documenting configuration code

**Builder is optional** - existing APIs (presets, Settings) remain the primary configuration methods.

## Scope (In / Out)

### In Scope

- `LoggerBuilder` and `AsyncLoggerBuilder` with chainable methods
- Builder methods for common configuration:
  - Core: level, preset, name
  - Performance: queue_size, batch_size, batch_timeout
  - Sinks: file, stdout, http, webhook
  - Security: redaction (fields, patterns)
  - Context: default bound context
  - Plugins: enrichers, filters (by name)
- Validation on `build()` with clear error messages
- Integration with Story 10.4 (human-readable strings: "10 MB", "daily")

### Out of Scope

- **A new configuration system** that replaces Settings (builder is thin wrapper)
- **Additional sink types** beyond what already exists
- **All Settings options** (builder focuses on common cases; use Settings for advanced)
- **Plugin instance configuration** (builder uses plugin names, not instances)
- **Cloud sink builder methods** (cloudwatch, loki, postgres) - these are specialized sinks; users needing them should use Settings directly for full configuration control

## Acceptance Criteria

### AC1: LoggerBuilder Implementation

**`LoggerBuilder` supports chaining and returns logger:**

```python
from fapilog import LoggerBuilder

logger = (
    LoggerBuilder()
    .with_name("api")
    .with_level("INFO")
    .with_preset("production")  # Apply preset first
    .add_file("./logs", max_bytes="50 MB", interval="daily")
    .with_redaction(fields=["password", "ssn"])
    .build()
)
```

**Behavior:**

- All methods return `self` for chaining
- `build()` creates Settings object and calls `get_logger(settings=...)`
- Type hints ensure IDE autocomplete works
- Builder uses mutable fluent pattern (methods modify internal state and return `self`)

### AC2: AsyncLoggerBuilder Implementation

**`AsyncLoggerBuilder` supports async build:**

```python
from fapilog import AsyncLoggerBuilder

logger = await (
    AsyncLoggerBuilder()
    .with_level("INFO")
    .add_file("./logs")
    .build_async()
)
```

**Behavior:**

- Same API as `LoggerBuilder` but async
- `build_async()` awaits `get_async_logger(settings=...)`
- All methods return `self` for chaining

### AC3: Core Configuration Methods

**Builder methods for core settings:**

```python
builder.with_name("logger_name")        # Logger name
builder.with_level("INFO")              # Log level (DEBUG, INFO, WARNING, ERROR)
builder.with_preset("production")       # Apply preset (can override after)
builder.with_queue_size(10000)          # Max queue size
builder.with_batch_size(100)            # Batch max size
builder.with_batch_timeout("1s")       # Batch timeout (supports Story 10.4 strings)
```

**Preset behavior:**

- `with_preset()` applies preset configuration first
- Subsequent methods override preset values
- Example: `with_preset("production").with_level("DEBUG")` → production preset with DEBUG level

### AC4: Sink Configuration Methods

**Builder methods for common sinks:**

```python
# File sink (rotating)
builder.add_file(
    directory="./logs",
    max_bytes="50 MB",           # Supports Story 10.4 strings
    interval="daily",            # Supports Story 10.4 strings
    max_files=10,
    compress=True,
)

# Stdout sink
builder.add_stdout(format="json")      # or "pretty", "auto"
builder.add_stdout_pretty()             # Convenience method

# HTTP sink
builder.add_http(
    endpoint="https://logs.example.com",
    timeout="30s",               # Supports Story 10.4 strings
    headers={"Authorization": "Bearer token"},
)

# Webhook sink
builder.add_webhook(
    endpoint="https://webhook.example.com",
    secret="shared-secret",
    timeout="5s",
)

# Cloud sinks (cloudwatch, loki, postgres) - use Settings for these
# Example: Settings(sink_config__cloudwatch__log_group_name="/my-app/logs", ...)
```

**Sink method behavior:**

- Each `add_*` method adds sink to configuration
- Multiple sinks can be added (fanout)
- Sink configuration uses Story 10.4 string formats where applicable

### AC5: Security and Context Methods

**Builder methods for security and context:**

```python
# Redaction
builder.with_redaction(
    fields=["password", "ssn", "api_key"],  # Field names to redact
    patterns=["secret.*", "token.*"],        # Regex patterns (optional)
)

# Default bound context
builder.with_context(
    service="api",
    env="production",
    version="1.0.0",
)

# Enrichers (by name)
builder.with_enrichers("runtime_info", "context_vars", "kubernetes")

# Filters (by name)
builder.with_filters("level", "sampling")
```

### AC6: Validation

**Builder validates configuration on `build()`:**

```python
# Invalid: file sink without directory
builder = LoggerBuilder().add_file()  # Missing directory
builder.build()  # Raises ValueError: "File sink requires directory parameter"

# Invalid: preset and explicit settings conflict
builder = LoggerBuilder().with_preset("production").with_level("DEBUG")
# This is valid - preset applies first, level overrides

# Invalid: multiple presets
builder = LoggerBuilder().with_preset("dev").with_preset("production")
builder.build()  # Raises ValueError: "Cannot apply multiple presets"
```

**Validation rules:**

- File sink requires `directory` parameter
- HTTP/Webhook sinks require `endpoint` parameter
- Only one preset can be applied
- Clear error messages show what's missing

### AC7: Settings Mapping

**Builder maps to Settings correctly:**

```python
# Builder code
logger = (
    LoggerBuilder()
    .with_level("INFO")
    .with_queue_size(5000)
    .add_file("./logs", max_bytes="10 MB")
    .build()
)

# Equivalent Settings code
from fapilog import Settings, get_logger
settings = Settings(
    core__log_level="INFO",
    core__max_queue_size=5000,
    sink_config__rotating_file__directory="./logs",
    sink_config__rotating_file__max_bytes=10 * 1024 * 1024,  # "10 MB" parsed
)
logger = get_logger(settings=settings)
```

**Mapping strategy:**

- Builder accumulates configuration in internal dict
- `build()` converts dict to Settings object
- Settings validation applies (Pydantic validation)
- Builder is truly thin wrapper (no new logic)

### AC8: Documentation and Examples

**Documentation includes:**

- Builder API reference with all methods
- Examples comparing Settings vs. Builder
- When to use each approach guide
- Integration with Story 10.4 (string formats)

## Implementation Notes

### File Structure

```
src/fapilog/builder.py (NEW)
  - LoggerBuilder class
  - AsyncLoggerBuilder class

src/fapilog/__init__.py (MODIFIED)
  - Export LoggerBuilder, AsyncLoggerBuilder

tests/unit/test_builder_api.py (NEW)
```

### Implementation Approach

**1. Builder Class Structure:**

```python
# src/fapilog/builder.py
from __future__ import annotations
from typing import Any

class LoggerBuilder:
    """Fluent builder for configuring sync loggers.

    Builder accumulates Settings-compatible configuration and creates
    a logger via get_logger() on build().
    """

    def __init__(self) -> None:
        self._config: dict[str, Any] = {}
        self._preset: str | None = None
        self._sinks: list[dict[str, Any]] = []

    def with_name(self, name: str) -> LoggerBuilder:
        """Set logger name."""
        self._config["name"] = name
        return self

    def with_level(self, level: str) -> LoggerBuilder:
        """Set log level (DEBUG, INFO, WARNING, ERROR)."""
        self._config.setdefault("core", {})["log_level"] = level.upper()
        return self

    def with_preset(self, preset: str) -> LoggerBuilder:
        """Apply preset configuration.

        Preset is applied first, then subsequent methods override.
        Only one preset can be applied.
        """
        if self._preset is not None:
            raise ValueError(f"Preset already set to '{self._preset}'. Cannot apply '{preset}'.")
        self._preset = preset
        return self

    def with_queue_size(self, size: int) -> LoggerBuilder:
        """Set max queue size."""
        self._config.setdefault("core", {})["max_queue_size"] = size
        return self

    def with_batch_size(self, size: int) -> LoggerBuilder:
        """Set batch max size."""
        self._config.setdefault("core", {})["batch_max_size"] = size
        return self

    def with_batch_timeout(self, timeout: str | float) -> LoggerBuilder:
        """Set batch timeout (supports Story 10.4 strings: '5s', '1h')."""
        from .core.types import _parse_duration
        seconds = _parse_duration(timeout) if isinstance(timeout, str) else timeout
        self._config.setdefault("core", {})["batch_timeout_seconds"] = seconds
        return self

    def add_file(
        self,
        directory: str,
        *,
        max_bytes: str | int = "10 MB",
        interval: str | int | None = None,
        max_files: int | None = None,
        compress: bool = False,
    ) -> LoggerBuilder:
        """Add rotating file sink.

        Args:
            directory: Log directory (required)
            max_bytes: Max bytes before rotation (supports Story 10.4 strings)
            interval: Rotation interval (supports Story 10.4 strings: 'daily', '1h')
            max_files: Max rotated files to keep
            compress: Compress rotated files
        """
        if not directory:
            raise ValueError("File sink requires directory parameter")

        from .core.types import _parse_size, _parse_duration

        file_config: dict[str, Any] = {"directory": directory}

        if isinstance(max_bytes, str):
            file_config["max_bytes"] = _parse_size(max_bytes)
        else:
            file_config["max_bytes"] = max_bytes

        if interval is not None:
            if isinstance(interval, str):
                file_config["interval_seconds"] = _parse_duration(interval)
            else:
                file_config["interval_seconds"] = interval

        if max_files is not None:
            file_config["max_files"] = max_files

        if compress:
            file_config["compress_rotated"] = True

        self._sinks.append({"name": "rotating_file", "config": file_config})
        return self

    def add_stdout(self, *, format: str = "json") -> LoggerBuilder:
        """Add stdout sink.

        Args:
            format: Output format ("json", "pretty", "auto")
        """
        sink_name = "stdout_pretty" if format == "pretty" else "stdout_json"
        self._sinks.append({"name": sink_name})
        return self

    def add_stdout_pretty(self) -> LoggerBuilder:
        """Add pretty-formatted stdout sink (convenience method)."""
        return self.add_stdout(format="pretty")

    def add_http(
        self,
        endpoint: str,
        *,
        timeout: str | float = "30s",
        headers: dict[str, str] | None = None,
    ) -> LoggerBuilder:
        """Add HTTP sink.

        Args:
            endpoint: HTTP endpoint URL (required)
            timeout: Request timeout (supports Story 10.4 strings)
            headers: Additional HTTP headers
        """
        if not endpoint:
            raise ValueError("HTTP sink requires endpoint parameter")

        from .core.types import _parse_duration

        http_config: dict[str, Any] = {"endpoint": endpoint}

        if isinstance(timeout, str):
            http_config["timeout_seconds"] = _parse_duration(timeout)
        else:
            http_config["timeout_seconds"] = timeout

        if headers:
            http_config["headers"] = headers

        self._sinks.append({"name": "http", "config": http_config})
        return self

    def add_webhook(
        self,
        endpoint: str,
        *,
        secret: str | None = None,
        timeout: str | float = "5s",
        headers: dict[str, str] | None = None,
    ) -> LoggerBuilder:
        """Add webhook sink.

        Args:
            endpoint: Webhook destination URL (required)
            secret: Shared secret for signing (optional)
            timeout: Request timeout (supports Story 10.4 strings)
            headers: Additional HTTP headers
        """
        if not endpoint:
            raise ValueError("Webhook sink requires endpoint parameter")

        from .core.types import _parse_duration

        webhook_config: dict[str, Any] = {"endpoint": endpoint}

        if secret:
            webhook_config["secret"] = secret

        if isinstance(timeout, str):
            webhook_config["timeout_seconds"] = _parse_duration(timeout)
        else:
            webhook_config["timeout_seconds"] = timeout

        if headers:
            webhook_config["headers"] = headers

        self._sinks.append({"name": "webhook", "config": webhook_config})
        return self

    def with_redaction(
        self,
        *,
        fields: list[str] | None = None,
        patterns: list[str] | None = None,
    ) -> LoggerBuilder:
        """Configure redaction.

        Args:
            fields: Field names to redact (e.g., ["password", "ssn"])
            patterns: Regex patterns to redact (e.g., ["secret.*"])
        """
        redactor_config: dict[str, Any] = {}

        if fields:
            redactor_config.setdefault("field_mask", {})["fields_to_mask"] = fields

        if patterns:
            redactor_config.setdefault("regex_mask", {})["patterns"] = patterns

        if redactor_config:
            self._config.setdefault("redactor_config", {}).update(redactor_config)
            # Enable redactors if not already enabled
            self._config.setdefault("core", {}).setdefault("redactors", [])
            if "field_mask" in redactor_config:
                self._config["core"].setdefault("redactors", []).append("field_mask")
            if "regex_mask" in redactor_config:
                self._config["core"].setdefault("redactors", []).append("regex_mask")

        return self

    def with_context(self, **kwargs: Any) -> LoggerBuilder:
        """Set default bound context.

        Args:
            **kwargs: Context key-value pairs
        """
        self._config.setdefault("core", {})["default_bound_context"] = kwargs
        return self

    def with_enrichers(self, *enrichers: str) -> LoggerBuilder:
        """Enable enrichers by name.

        Args:
            *enrichers: Enricher names (e.g., "runtime_info", "kubernetes")
        """
        self._config.setdefault("core", {}).setdefault("enrichers", []).extend(enrichers)
        return self

    def with_filters(self, *filters: str) -> LoggerBuilder:
        """Enable filters by name.

        Args:
            *filters: Filter names (e.g., "level", "sampling")
        """
        self._config.setdefault("core", {}).setdefault("filters", []).extend(filters)
        return self

    def build(self) -> Any:
        """Build and return logger.

        Returns:
            SyncLoggerFacade instance

        Raises:
            ValueError: If configuration is invalid
        """
        import copy

        from . import get_logger
        from .core.settings import Settings

        # Start with preset (copied to avoid mutating cache) or empty config
        if self._preset:
            from .core.presets import get_preset
            config = copy.deepcopy(get_preset(self._preset))
            # Merge builder config on top of preset (builder overrides preset)
            self._deep_merge(config, self._config)
        else:
            config = self._config

        # Add sinks to config
        if self._sinks:
            sink_names = [s["name"] for s in self._sinks]
            config.setdefault("core", {})["sinks"] = sink_names

            # Add sink configs
            sink_config = {}
            for sink in self._sinks:
                if "config" in sink:
                    sink_config[sink["name"]] = sink["config"]
            if sink_config:
                config.setdefault("sink_config", {}).update(sink_config)

        # Validate and create Settings
        try:
            settings = Settings(**config)
        except Exception as e:
            raise ValueError(f"Invalid builder configuration: {e}") from e

        # Create logger
        name = config.get("name")
        return get_logger(name=name, settings=settings)

    def _deep_merge(self, base: dict[str, Any], override: dict[str, Any]) -> None:
        """Merge override into base (mutates base). Override wins."""
        for key, value in override.items():
            if key in base and isinstance(base[key], dict) and isinstance(value, dict):
                self._deep_merge(base[key], value)
            else:
                base[key] = value


class AsyncLoggerBuilder(LoggerBuilder):
    """Fluent builder for configuring async loggers."""

    async def build_async(self) -> Any:
        """Build and return async logger.

        Returns:
            AsyncLoggerFacade instance

        Raises:
            ValueError: If configuration is invalid
        """
        import copy

        from . import get_async_logger
        from .core.settings import Settings

        # Start with preset (copied to avoid mutating cache) or empty config
        if self._preset:
            from .core.presets import get_preset
            config = copy.deepcopy(get_preset(self._preset))
            self._deep_merge(config, self._config)
        else:
            config = self._config

        if self._sinks:
            sink_names = [s["name"] for s in self._sinks]
            config.setdefault("core", {})["sinks"] = sink_names

            sink_config = {}
            for sink in self._sinks:
                if "config" in sink:
                    sink_config[sink["name"]] = sink["config"]
            if sink_config:
                config.setdefault("sink_config", {}).update(sink_config)

        try:
            settings = Settings(**config)
        except Exception as e:
            raise ValueError(f"Invalid builder configuration: {e}") from e

        name = config.get("name")
        return await get_async_logger(name=name, settings=settings)
```

**2. Settings Mapping Strategy:**

- Builder methods map directly to Settings nested structure
- `with_level("INFO")` → `Settings(core__log_level="INFO")`
- `add_file("./logs")` → `Settings(sink_config__rotating_file__directory="./logs")`
- Preset is applied first, then builder methods override

**3. Validation:**

- File sink: `directory` required
- HTTP/Webhook sinks: `endpoint` required
- Cloud sinks: appropriate parameters required
- Only one preset can be applied
- Settings validation (Pydantic) catches other issues

**Assumption**: Builder accumulates Settings-compatible dicts and then calls `get_logger(settings=...)` or `get_async_logger(settings=...)`.

## Tasks

### Phase 1: Core Builder Implementation

- [ ] Create `src/fapilog/builder.py`
- [ ] Implement `LoggerBuilder` base class
- [ ] Implement core methods: `with_name()`, `with_level()`, `with_preset()`
- [ ] Implement performance methods: `with_queue_size()`, `with_batch_size()`, `with_batch_timeout()`
- [ ] Implement `build()` method (creates Settings, calls `get_logger()`)
- [ ] Implement `_merge_config()` helper for preset merging

### Phase 2: Sink Methods

- [ ] Implement `add_file()` with Story 10.4 string support
- [ ] Implement `add_stdout()` and `add_stdout_pretty()`
- [ ] Implement `add_http()` with Story 10.4 string support
- [ ] Implement `add_webhook()`
- [ ] Ensure all sink methods support Story 10.4 string formats

### Phase 3: Security and Context Methods

- [ ] Implement `with_redaction()` (fields and patterns)
- [ ] Implement `with_context()` (default bound context)
- [ ] Implement `with_enrichers()` (by name)
- [ ] Implement `with_filters()` (by name)

### Phase 4: Async Builder

- [ ] Implement `AsyncLoggerBuilder` (extends `LoggerBuilder`)
- [ ] Implement `build_async()` method
- [ ] Ensure async builder has same API as sync builder

### Phase 5: Validation

- [ ] Add validation for file sink (directory required)
- [ ] Add validation for HTTP/Webhook sinks (endpoint required)
- [ ] Add validation for multiple presets
- [ ] Ensure Settings validation (Pydantic) catches other issues
- [ ] Add clear error messages for all validation failures

### Phase 6: Testing

- [ ] Create `tests/unit/test_builder_api.py`
- [ ] Test method chaining returns self
- [ ] Test `build()` creates logger correctly
- [ ] Test preset application and override
- [ ] Test all sink methods
- [ ] Test validation errors
- [ ] Test Settings mapping accuracy
- [ ] Test Story 10.4 string format integration
- [ ] Test `build_async()` for async builder
- [ ] Integration test: builder logger works correctly

### Phase 7: Documentation

- [ ] Update `README.md` with builder example
- [ ] Create `docs/api-reference/builder.md` (NEW)
- [ ] Add builder examples to `docs/user-guide/configuration.md`
- [ ] Add comparison: Settings vs. Builder vs. Presets
- [ ] Add "When to use each approach" guide
- [ ] Update `CHANGELOG.md`

## Tests

### Unit Tests (`tests/unit/test_builder_api.py`)

```python
"""Unit tests for fluent builder API."""

import pytest

from fapilog import AsyncLoggerBuilder, LoggerBuilder


class TestLoggerBuilder:
    """Test LoggerBuilder class."""

    def test_method_chaining_returns_self(self):
        """All methods return self for chaining."""
        builder = LoggerBuilder()
        assert builder.with_level("INFO") is builder
        assert builder.with_name("test") is builder
        assert builder.with_queue_size(1000) is builder

    def test_build_creates_logger(self):
        """build() creates and returns logger."""
        logger = LoggerBuilder().with_level("INFO").build()
        assert logger is not None
        assert hasattr(logger, "info")
        assert hasattr(logger, "error")

    def test_preset_application(self):
        """Preset is applied correctly."""
        logger = LoggerBuilder().with_preset("production").build()
        # Verify logger uses production preset settings
        # (would need to inspect logger internals)

    def test_preset_override(self):
        """Methods after preset override preset values."""
        logger = (
            LoggerBuilder()
            .with_preset("production")  # INFO level
            .with_level("DEBUG")        # Override to DEBUG
            .build()
        )
        # Verify logger uses DEBUG level

    def test_multiple_presets_raises_error(self):
        """Multiple presets raise ValueError."""
        with pytest.raises(ValueError, match="Preset already set"):
            LoggerBuilder().with_preset("dev").with_preset("production").build()

    def test_file_sink_requires_directory(self):
        """File sink without directory raises ValueError."""
        with pytest.raises(ValueError, match="requires directory"):
            LoggerBuilder().add_file("").build()

    def test_http_sink_requires_endpoint(self):
        """HTTP sink without endpoint raises ValueError."""
        with pytest.raises(ValueError, match="requires endpoint"):
            LoggerBuilder().add_http("").build()

    def test_string_formats_supported(self):
        """Builder supports Story 10.4 string formats."""
        logger = (
            LoggerBuilder()
            .add_file("./logs", max_bytes="10 MB", interval="daily")
            .with_batch_timeout("5s")
            .build()
        )
        # Verify string formats parsed correctly

    def test_settings_mapping_accuracy(self):
        """Builder config maps to Settings correctly."""
        builder = (
            LoggerBuilder()
            .with_level("INFO")
            .with_queue_size(5000)
            .add_file("./logs", max_bytes="10 MB")
        )
        logger = builder.build()

        # Verify Settings were created correctly
        # (would need to inspect logger internals or test behavior)


class TestAsyncLoggerBuilder:
    """Test AsyncLoggerBuilder class."""

    @pytest.mark.asyncio
    async def test_build_async_creates_async_logger(self):
        """build_async() creates async logger."""
        logger = await AsyncLoggerBuilder().with_level("INFO").build_async()
        assert logger is not None
        assert hasattr(logger, "info")
        # Verify it's async (awaitable methods)
        await logger.info("test")

    @pytest.mark.asyncio
    async def test_async_builder_same_api(self):
        """Async builder has same API as sync builder."""
        builder = AsyncLoggerBuilder()
        builder.with_level("INFO")
        builder.add_file("./logs")
        logger = await builder.build_async()
        assert logger is not None


class TestBuilderValidation:
    """Test builder validation."""

    def test_invalid_level_raises_error(self):
        """Invalid log level raises error on build."""
        with pytest.raises(ValueError):
            LoggerBuilder().with_level("INVALID").build()

    def test_invalid_preset_raises_error(self):
        """Invalid preset raises error on build."""
        with pytest.raises(ValueError, match="Invalid preset"):
            LoggerBuilder().with_preset("invalid").build()

    def test_file_sink_validation(self):
        """File sink validates required parameters."""
        # Valid
        LoggerBuilder().add_file("./logs").build()

        # Invalid - missing directory
        with pytest.raises(ValueError):
            LoggerBuilder().add_file("").build()


class TestBuilderSinkMethods:
    """Test sink configuration methods."""

    def test_add_file(self):
        """add_file() configures file sink."""
        logger = LoggerBuilder().add_file("./logs", max_bytes="50 MB").build()
        # Verify file sink configured

    def test_add_stdout(self):
        """add_stdout() configures stdout sink."""
        logger = LoggerBuilder().add_stdout(format="json").build()
        # Verify stdout sink configured

    def test_add_http(self):
        """add_http() configures HTTP sink."""
        logger = LoggerBuilder().add_http("https://logs.example.com").build()
        # Verify HTTP sink configured

    def test_multiple_sinks(self):
        """Multiple sinks can be added."""
        logger = (
            LoggerBuilder()
            .add_stdout()
            .add_file("./logs")
            .build()
        )
        # Verify both sinks configured


class TestBuilderIntegration:
    """Integration tests for builder."""

    def test_builder_logger_works(self):
        """Logger created by builder works correctly."""
        logger = (
            LoggerBuilder()
            .with_level("INFO")
            .with_name("test")
            .build()
        )
        logger.info("test message")
        # Verify log was emitted

    def test_builder_with_preset_works(self):
        """Builder with preset works correctly."""
        logger = LoggerBuilder().with_preset("dev").build()
        logger.debug("debug message")  # Dev preset allows DEBUG
        # Verify log was emitted
```

### Integration Tests

- Test: Builder logger works in real application
- Test: Builder with preset + overrides works correctly
- Test: Builder with multiple sinks works correctly
- Test: Builder validation prevents invalid configs
- Test: Async builder works in async context

## Definition of Done

Story is complete when ALL of the following are true:

### Code Complete

- [ ] All acceptance criteria met and verified
- [ ] All tasks completed
- [ ] Code follows project style guide
- [ ] No linting errors or warnings
- [ ] Type checking passes (mypy strict)

### Quality Assurance

- [ ] Unit tests: >90% coverage of new code
- [ ] Integration tests: all scenarios passing
- [ ] Regression tests: no existing functionality broken
- [ ] Performance tests: no regression vs baseline
- [ ] Manual testing completed (if applicable)

### Documentation

- [ ] User-facing docs updated
- [ ] API reference updated (if applicable)
- [ ] Code examples added/updated
- [ ] CHANGELOG.md updated
- [ ] Inline code documentation complete

### Review & Release

- [ ] Code review approved
- [ ] Documentation reviewed for clarity
- [ ] CI/CD pipeline passing
- [ ] Ready for merge to main branch

### Backwards Compatibility

- [ ] No breaking changes OR breaking changes documented with migration guide
- [ ] Existing tests still pass
- [ ] Deprecation warnings added (if applicable)

---

## Risks / Rollback / Monitoring

### Risks

- **Risk:** Builder diverges from Settings behavior over time

  - **Mitigation:** Keep builder implementation minimal and derived from Settings
  - **Mitigation:** Builder always creates Settings object (no separate logic)
  - **Mitigation:** Tests verify builder → Settings mapping accuracy

- **Risk:** Builder API becomes too complex

  - **Mitigation:** Keep it simple, focus on common use cases
  - **Mitigation:** Advanced configuration still uses Settings
  - **Mitigation:** Builder methods map 1:1 to Settings fields where possible

- **Risk:** Builder maintenance burden

  - **Mitigation:** Builder is optional (presets/Settings remain primary)
  - **Mitigation:** Can remove builder without breaking changes
  - **Mitigation:** Minimal implementation (thin wrapper)

- **Risk:** Users confused about when to use builder vs. Settings
  - **Mitigation:** Clear documentation on when to use each approach
  - **Mitigation:** Examples showing comparison

### Rollback Plan

- Keep builder implementation minimal and derived from Settings
- Builder is optional, Settings remains primary API
- Can remove builder if needed without breaking changes

### Success Metrics / Monitoring

- Track user adoption and feedback in docs issues
- Monitor builder usage vs Settings usage (if telemetry added)
- User feedback on API discoverability and ergonomics
- GitHub issues/questions about builder API
- Documentation views for builder vs. Settings pages

---

## Related Documents

- [Story 10.0: Series Overview](./10.0-series-overview.md)
- [Story 10.1: Configuration Presets](./10.1.configuration-presets.md) - ✅ Complete
- [Story 10.4: Human-Readable Config Strings](./10.4.human-readable-config-strings.md) - Builder uses string formats
- [Epic 10: Developer Experience](../prd/epic-10-DX-Experience.md)

## Value Proposition

### When to Use Builder

**Use builder when:**

- You prefer method chaining and IDE autocomplete
- You want progressive configuration (preset + overrides)
- You want readable, self-documenting configuration code
- You're configuring common options (not advanced Settings)

**Example:**

```python
# Builder: Readable, discoverable
logger = (
    LoggerBuilder()
    .with_preset("production")
    .add_file("./logs", max_bytes="50 MB")
    .with_redaction(fields=["password"])
    .build()
)
```

### When to Use Settings

**Use Settings when:**

- You need full control over all configuration options
- You're using environment variables (Settings reads them automatically)
- You need advanced plugin configuration
- You prefer explicit configuration objects

**Example:**

```python
# Settings: Full control, explicit
settings = Settings(
    core__log_level="INFO",
    core__batch_max_size=100,
    sink_config__rotating_file__directory="./logs",
    sink_config__rotating_file__max_bytes=52_428_800,
    redactor_config__field_mask__fields_to_mask=["password"],
)
logger = get_logger(settings=settings)
```

### When to Use Presets

**Use presets when:**

- You want the simplest possible setup
- Default preset behavior is sufficient
- You don't need customization

**Example:**

```python
# Preset: Simplest (1 line)
logger = get_logger(preset="production")
```

---

## Usage Examples

### Example 1: Simple Builder

```python
from fapilog import LoggerBuilder

# Simple logger with preset
logger = LoggerBuilder().with_preset("production").build()

# With overrides
logger = (
    LoggerBuilder()
    .with_preset("production")
    .with_level("DEBUG")  # Override preset level
    .build()
)
```

### Example 2: File Sink Configuration

```python
from fapilog import LoggerBuilder

logger = (
    LoggerBuilder()
    .with_level("INFO")
    .add_file(
        directory="./logs",
        max_bytes="50 MB",        # Story 10.4 string format
        interval="daily",          # Story 10.4 string format
        max_files=10,
        compress=True,
    )
    .build()
)
```

### Example 3: Multiple Sinks

```python
from fapilog import LoggerBuilder

logger = (
    LoggerBuilder()
    .with_preset("production")
    .add_stdout(format="json")
    .add_file("./logs", max_bytes="100 MB")
    .add_http(
        endpoint="https://logs.example.com",
        timeout="30s",             # Story 10.4 string format
        headers={"Authorization": "Bearer token"},
    )
    .build()
)
```

### Example 4: Security Configuration

```python
from fapilog import LoggerBuilder

logger = (
    LoggerBuilder()
    .with_preset("production")
    .with_redaction(
        fields=["password", "ssn", "api_key"],
        patterns=["secret.*", "token.*"],
    )
    .build()
)
```

### Example 5: Async Builder

```python
from fapilog import AsyncLoggerBuilder

async def setup_logging():
    logger = await (
        AsyncLoggerBuilder()
        .with_preset("fastapi")
        .add_file("./logs")
        .with_context(service="api", env="production")
        .build_async()
    )
    return logger
```

### Example 6: Comparison with Settings

```python
# Builder approach
logger = (
    LoggerBuilder()
    .with_level("INFO")
    .with_queue_size(5000)
    .add_file("./logs", max_bytes="10 MB")
    .build()
)

# Equivalent Settings approach
from fapilog import Settings, get_logger

settings = Settings(
    core__log_level="INFO",
    core__max_queue_size=5000,
    sink_config__rotating_file__directory="./logs",
    sink_config__rotating_file__max_bytes=10 * 1024 * 1024,
)
logger = get_logger(settings=settings)

# Both create the same logger configuration
```

## Change Log

| Date       | Change                                                    | Author |
| ---------- | --------------------------------------------------------- | ------ |
| 2025-01-10 | Migrated to new format                                    | System |
| 2025-01-11 | Expanded with API design, examples, and technical details | Review |
| 2025-01-12 | Fixed _deep_merge logic, added add_webhook/add_stdout_pretty, clarified mutable fluent pattern | Review |
