# Story 5.24: Sink Routing by Log Level

## Status: Complete

## Priority: Medium

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

## Dependencies: None

## Epic: Advanced Pipeline Configuration

---

## Context

Currently, fapilog uses a "fanout" architecture where **all log events are sent to all configured sinks**. This is simple and predictable, but many production scenarios require routing different events to different destinations:

- **Errors to a database** for queryable incident investigation
- **Debug logs to local files** for development/troubleshooting
- **Critical alerts to a webhook** for immediate notification
- **All logs to stdout** for container orchestrators
- **Audit events to tamper-evident storage** for compliance

Users today must implement custom "routing sinks" or use multiple logger instances. This story adds **native routing support** as a first-class feature.

### Use Cases

1. **Severity-based routing**: Errors → PostgreSQL, Info → stdout
2. **Multi-destination with overlap**: All logs → file, Errors also → PagerDuty
3. **Cost optimization**: Sample debug logs to cheap storage, all errors to premium observability
4. **Compliance**: Audit events → tamper-evident, operational logs → standard file
5. **Environment-specific**: Production errors → CloudWatch, development → stdout

---

## Goals

1. Enable declarative routing of log events to specific sinks based on level
2. Support overlapping rules (event can go to multiple sinks)
3. Maintain backward compatibility (no routing = fanout to all)
4. Provide both configuration-based and programmatic APIs
5. Keep performance overhead minimal for the common case

---

## Acceptance Criteria

### AC1: Routing Configuration Schema

- [ ] Add `sink_routing` section to Settings
- [ ] Support routing rules with level conditions
- [ ] Support wildcard/fallback rules
- [ ] Validate routing configuration at startup
- [ ] Emit warning if a configured sink has no matching rules

Configuration schema:

```python
class RoutingRule(BaseModel):
    """A single routing rule matching levels to sinks."""
    
    levels: list[str] = Field(
        description="Log levels this rule matches (e.g., ['ERROR', 'CRITICAL'])"
    )
    sinks: list[str] = Field(
        description="Sink names to route matching events to"
    )
    
    
class SinkRoutingSettings(BaseModel):
    """Sink routing configuration."""
    
    enabled: bool = Field(
        default=False,
        description="Enable level-based sink routing (False = fanout to all)"
    )
    rules: list[RoutingRule] = Field(
        default_factory=list,
        description="Ordered routing rules; first match wins unless overlap=True"
    )
    overlap: bool = Field(
        default=True,
        description="If True, event can match multiple rules; if False, first match only"
    )
    fallback_sinks: list[str] = Field(
        default_factory=list,
        description="Sinks to use when no rules match (empty = drop)"
    )
```

### AC2: Environment Variable Support

- [ ] Support `FAPILOG_SINK_ROUTING__ENABLED=true`
- [ ] Support JSON-encoded rules via `FAPILOG_SINK_ROUTING__RULES`
- [ ] Support comma-separated fallback via `FAPILOG_SINK_ROUTING__FALLBACK_SINKS`

Example:

```bash
export FAPILOG_SINK_ROUTING__ENABLED=true
export FAPILOG_SINK_ROUTING__RULES='[
  {"levels": ["ERROR", "CRITICAL"], "sinks": ["postgres", "webhook"]},
  {"levels": ["DEBUG", "INFO", "WARNING"], "sinks": ["stdout_json"]}
]'
export FAPILOG_SINK_ROUTING__FALLBACK_SINKS=rotating_file
```

### AC3: Router Implementation

- [ ] Create `RoutingSinkWriter` that replaces fanout writer when routing enabled
- [ ] Build level→sinks lookup table at startup for O(1) routing
- [ ] Support parallel writes to multiple matched sinks
- [ ] Integrate with existing circuit breaker infrastructure
- [ ] Preserve existing fanout behavior when routing disabled

### AC4: Programmatic API

- [ ] Allow passing routing config to `get_logger()` / `get_async_logger()`
- [ ] Provide `RoutingSink` class for manual sink composition
- [ ] Support dynamic rule updates (for hot-reload scenarios)

```python
from fapilog import get_logger, Settings
from fapilog.core.routing import RoutingRule

settings = Settings()
settings.sink_routing.enabled = True
settings.sink_routing.rules = [
    RoutingRule(levels=["ERROR", "CRITICAL"], sinks=["postgres"]),
    RoutingRule(levels=["DEBUG", "INFO", "WARNING"], sinks=["stdout_json"]),
]

logger = get_logger(settings=settings)
```

### AC5: Built-in RoutingSink Plugin

- [ ] Create `src/fapilog/plugins/sinks/routing.py`
- [ ] Implement `BaseSink` protocol with routing logic
- [ ] Support nested sink instantiation from config
- [ ] Register as `routing` in sink registry

```python
from fapilog.plugins.sinks.routing import RoutingSink, RoutingSinkConfig

config = RoutingSinkConfig(
    routes={
        "ERROR": ["postgres", "pagerduty"],
        "CRITICAL": ["postgres", "pagerduty"],
        "INFO": ["stdout_json"],
        "DEBUG": ["rotating_file"],
        "*": ["rotating_file"],  # fallback
    }
)

sink = RoutingSink(config)
logger = get_logger(sinks=[sink])
```

### AC6: Performance

- [ ] Level lookup must be O(1) using dict/set
- [ ] No overhead when routing disabled (use existing fanout)
- [ ] Benchmark: <1μs added latency per event for routing decision
- [ ] Parallel sink writes when multiple sinks matched

### AC7: Documentation

- [ ] Add `docs/user-guide/sink-routing.md` with examples
- [ ] Update `docs/core-concepts/sinks.md` to mention routing
- [ ] Add routing examples to README
- [ ] Document migration from custom routing solutions

### AC8: Testing

- [ ] Unit tests for routing logic
- [ ] Unit tests for configuration parsing
- [ ] Integration test with multiple real sinks
- [ ] Performance benchmark comparing routed vs fanout
- [ ] Test edge cases: empty rules, no matches, overlapping rules

---

## Technical Design

### 1. Settings Integration

```python
# src/fapilog/core/settings.py

class RoutingRule(BaseModel):
    """A routing rule mapping levels to sinks."""
    
    levels: list[str] = Field(
        default_factory=list,
        description="Log levels to match (case-insensitive)"
    )
    sinks: list[str] = Field(
        default_factory=list,
        description="Sink names to route to"
    )
    
    @field_validator("levels")
    @classmethod
    def normalize_levels(cls, v: list[str]) -> list[str]:
        return [lvl.upper() for lvl in v]


class SinkRoutingSettings(BaseModel):
    """Configuration for level-based sink routing."""
    
    enabled: bool = Field(
        default=False,
        description="Enable routing (False = legacy fanout to all sinks)"
    )
    rules: list[RoutingRule] = Field(
        default_factory=list,
        description="Routing rules in priority order"
    )
    overlap: bool = Field(
        default=True,
        description="Allow events to match multiple rules"
    )
    fallback_sinks: list[str] = Field(
        default_factory=list,
        description="Sinks for events matching no rules"
    )
    

class Settings(BaseSettings):
    # ... existing fields ...
    
    sink_routing: SinkRoutingSettings = Field(
        default_factory=SinkRoutingSettings,
        description="Level-based sink routing configuration"
    )
```

### 2. Routing Writer Implementation

```python
# src/fapilog/core/routing.py

from __future__ import annotations

import asyncio
from typing import Any, Callable, Awaitable

from .circuit_breaker import SinkCircuitBreaker, SinkCircuitBreakerConfig


class RoutingSinkWriter:
    """Routes log events to sinks based on log level.
    
    Builds an O(1) lookup table at construction time:
    
        level_to_sinks = {
            "DEBUG": [sink1],
            "INFO": [sink1],
            "WARNING": [sink1],
            "ERROR": [sink2, sink3],
            "CRITICAL": [sink2, sink3],
        }
    
    When write() is called, extracts level from entry and writes
    to all matching sinks (respecting circuit breakers).
    """
    
    def __init__(
        self,
        sinks: dict[str, Any],  # name -> sink instance
        rules: list[tuple[set[str], list[str]]],  # (levels, sink_names)
        fallback_sink_names: list[str],
        *,
        overlap: bool = True,
        parallel: bool = False,
        circuit_config: Any | None = None,
    ) -> None:
        self._sinks = sinks
        self._parallel = parallel
        self._overlap = overlap
        
        # Build level -> sink instances lookup
        self._level_to_sinks: dict[str, list[Any]] = {}
        self._fallback_sinks: list[Any] = [
            sinks[name] for name in fallback_sink_names if name in sinks
        ]
        
        for levels, sink_names in rules:
            sink_instances = [sinks[n] for n in sink_names if n in sinks]
            for level in levels:
                if level == "*":
                    continue  # handled via fallback
                if self._overlap:
                    existing = self._level_to_sinks.get(level, [])
                    self._level_to_sinks[level] = existing + sink_instances
                else:
                    # First match wins
                    if level not in self._level_to_sinks:
                        self._level_to_sinks[level] = sink_instances
        
        # Circuit breakers per sink
        self._breakers: dict[int, SinkCircuitBreaker] = {}
        if circuit_config and getattr(circuit_config, "enabled", False):
            for sink in sinks.values():
                name = getattr(sink, "name", type(sink).__name__)
                self._breakers[id(sink)] = SinkCircuitBreaker(name, circuit_config)
    
    def get_sinks_for_level(self, level: str) -> list[Any]:
        """Return list of sinks that should receive this level."""
        level = level.upper()
        sinks = self._level_to_sinks.get(level)
        if sinks is not None:
            return sinks
        return self._fallback_sinks
    
    async def write(self, entry: dict[str, Any]) -> None:
        """Route entry to appropriate sinks based on level."""
        level = entry.get("level", "INFO")
        target_sinks = self.get_sinks_for_level(level)
        
        if not target_sinks:
            return  # No matching sinks, drop silently
        
        if self._parallel and len(target_sinks) > 1:
            await self._write_parallel(entry, target_sinks)
        else:
            await self._write_sequential(entry, target_sinks)
    
    async def _write_sequential(
        self, entry: dict[str, Any], sinks: list[Any]
    ) -> None:
        for sink in sinks:
            await self._write_to_sink(sink, entry)
    
    async def _write_parallel(
        self, entry: dict[str, Any], sinks: list[Any]
    ) -> None:
        tasks = [self._write_to_sink(sink, entry) for sink in sinks]
        await asyncio.gather(*tasks, return_exceptions=True)
    
    async def _write_to_sink(self, sink: Any, entry: dict[str, Any]) -> None:
        breaker = self._breakers.get(id(sink))
        
        if breaker and not breaker.should_allow():
            return  # Circuit open, skip
        
        try:
            await sink.write(entry)
            if breaker:
                breaker.record_success()
        except Exception:
            if breaker:
                breaker.record_failure()
            # Contain error


def build_routing_writer(
    sinks: list[Any],
    routing_config: "SinkRoutingSettings",
    *,
    parallel: bool = False,
    circuit_config: Any | None = None,
) -> RoutingSinkWriter:
    """Build a routing writer from settings."""
    # Build name -> sink mapping
    sink_map = {getattr(s, "name", type(s).__name__): s for s in sinks}
    
    # Convert rules to internal format
    rules = [
        ({lvl.upper() for lvl in rule.levels}, rule.sinks)
        for rule in routing_config.rules
    ]
    
    return RoutingSinkWriter(
        sinks=sink_map,
        rules=rules,
        fallback_sink_names=routing_config.fallback_sinks,
        overlap=routing_config.overlap,
        parallel=parallel,
        circuit_config=circuit_config,
    )
```

### 3. Integration in __init__.py

```python
# In src/fapilog/__init__.py

def get_logger(
    name: str | None = None,
    *,
    settings: _Settings | None = None,
    sinks: list[object] | None = None,
) -> SyncLoggerFacade:
    cfg_source = settings or _Settings()
    # ... existing setup ...
    
    # Build sink writer based on routing config
    if cfg_source.sink_routing.enabled and cfg_source.sink_routing.rules:
        from .core.routing import build_routing_writer
        
        routing_writer = build_routing_writer(
            built_sinks,
            cfg_source.sink_routing,
            parallel=cfg_source.core.sink_parallel_writes,
            circuit_config=circuit_config,
        )
        sink_write = routing_writer.write
        sink_write_serialized = None  # Routing doesn't support fast path yet
    else:
        # Legacy fanout behavior
        sink_write, sink_write_serialized = _fanout_writer(
            built_sinks,
            parallel=cfg_source.core.sink_parallel_writes,
            circuit_config=circuit_config,
        )
    
    # ... rest of setup ...
```

### 4. RoutingSink Plugin

```python
# src/fapilog/plugins/sinks/routing.py

from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from typing import Any

from ...core import diagnostics
from ..loader import load_plugin


@dataclass
class RoutingSinkConfig:
    """Configuration for the routing sink."""
    
    routes: dict[str, list[str]] = field(default_factory=dict)
    """Mapping of level -> sink names. Use '*' for fallback."""
    
    sink_configs: dict[str, dict[str, Any]] = field(default_factory=dict)
    """Per-sink configuration by name."""
    
    parallel: bool = False
    """Write to multiple sinks in parallel."""


class RoutingSink:
    """A sink that routes events to other sinks based on log level.
    
    This is a convenience wrapper for users who want routing without
    using the global sink_routing settings.
    
    Example:
        config = RoutingSinkConfig(
            routes={
                "ERROR": ["postgres"],
                "CRITICAL": ["postgres", "pagerduty"],
                "INFO": ["stdout_json"],
                "*": ["rotating_file"],
            }
        )
        sink = RoutingSink(config)
    """
    
    name = "routing"
    
    def __init__(
        self,
        config: RoutingSinkConfig | None = None,
        **kwargs: Any,
    ) -> None:
        if config is None:
            config = RoutingSinkConfig(**kwargs)
        
        self._config = config
        self._sinks: dict[str, Any] = {}
        self._level_to_sinks: dict[str, list[Any]] = {}
        self._fallback_sinks: list[Any] = []
    
    async def start(self) -> None:
        # Collect all unique sink names
        all_sink_names: set[str] = set()
        for sinks in self._config.routes.values():
            all_sink_names.update(sinks)
        
        # Load and start each sink
        for name in all_sink_names:
            try:
                sink_cfg = self._config.sink_configs.get(name, {})
                sink = load_plugin("fapilog.sinks", name, sink_cfg)
                if hasattr(sink, "start"):
                    await sink.start()
                self._sinks[name] = sink
            except Exception as exc:
                diagnostics.warn(
                    "routing-sink",
                    "failed to load sink",
                    sink=name,
                    error=str(exc),
                )
        
        # Build routing table
        for level, sink_names in self._config.routes.items():
            sink_instances = [
                self._sinks[n] for n in sink_names if n in self._sinks
            ]
            if level == "*":
                self._fallback_sinks = sink_instances
            else:
                self._level_to_sinks[level.upper()] = sink_instances
    
    async def stop(self) -> None:
        for sink in self._sinks.values():
            try:
                if hasattr(sink, "stop"):
                    await sink.stop()
            except Exception:
                pass
    
    async def write(self, entry: dict[str, Any]) -> None:
        level = entry.get("level", "INFO").upper()
        targets = self._level_to_sinks.get(level, self._fallback_sinks)
        
        if not targets:
            return
        
        if self._config.parallel and len(targets) > 1:
            await asyncio.gather(
                *[self._write_one(s, entry) for s in targets],
                return_exceptions=True,
            )
        else:
            for sink in targets:
                await self._write_one(sink, entry)
    
    async def _write_one(self, sink: Any, entry: dict[str, Any]) -> None:
        try:
            await sink.write(entry)
        except Exception:
            pass  # Contain errors
    
    async def health_check(self) -> bool:
        if not self._sinks:
            return False
        
        for sink in self._sinks.values():
            if hasattr(sink, "health_check"):
                if not await sink.health_check():
                    return False
        return True


PLUGIN_METADATA = {
    "name": "routing",
    "version": "1.0.0",
    "plugin_type": "sink",
    "entry_point": "fapilog.plugins.sinks.routing:RoutingSink",
    "description": "Routes log events to different sinks based on log level.",
    "author": "Fapilog Core",
    "compatibility": {"min_fapilog_version": "0.4.0"},
    "api_version": "1.0",
    "dependencies": [],
}
```

### 5. Test Implementation

```python
# tests/unit/test_sink_routing.py

import pytest
from unittest.mock import AsyncMock, MagicMock


class TestRoutingSinkWriter:
    async def test_routes_error_to_correct_sink(self):
        from fapilog.core.routing import RoutingSinkWriter
        
        error_sink = AsyncMock()
        error_sink.write = AsyncMock()
        
        info_sink = AsyncMock()
        info_sink.write = AsyncMock()
        
        writer = RoutingSinkWriter(
            sinks={"error_sink": error_sink, "info_sink": info_sink},
            rules=[
                ({"ERROR", "CRITICAL"}, ["error_sink"]),
                ({"INFO", "DEBUG"}, ["info_sink"]),
            ],
            fallback_sink_names=[],
        )
        
        await writer.write({"level": "ERROR", "message": "test"})
        
        error_sink.write.assert_called_once()
        info_sink.write.assert_not_called()
    
    async def test_routes_info_to_correct_sink(self):
        from fapilog.core.routing import RoutingSinkWriter
        
        error_sink = AsyncMock()
        info_sink = AsyncMock()
        
        writer = RoutingSinkWriter(
            sinks={"error_sink": error_sink, "info_sink": info_sink},
            rules=[
                ({"ERROR", "CRITICAL"}, ["error_sink"]),
                ({"INFO", "DEBUG"}, ["info_sink"]),
            ],
            fallback_sink_names=[],
        )
        
        await writer.write({"level": "INFO", "message": "test"})
        
        error_sink.write.assert_not_called()
        info_sink.write.assert_called_once()
    
    async def test_overlap_sends_to_multiple_sinks(self):
        from fapilog.core.routing import RoutingSinkWriter
        
        sink1 = AsyncMock()
        sink2 = AsyncMock()
        
        writer = RoutingSinkWriter(
            sinks={"sink1": sink1, "sink2": sink2},
            rules=[
                ({"ERROR"}, ["sink1"]),
                ({"ERROR"}, ["sink2"]),  # Overlap
            ],
            fallback_sink_names=[],
            overlap=True,
        )
        
        await writer.write({"level": "ERROR", "message": "test"})
        
        sink1.write.assert_called_once()
        sink2.write.assert_called_once()
    
    async def test_no_overlap_uses_first_match(self):
        from fapilog.core.routing import RoutingSinkWriter
        
        sink1 = AsyncMock()
        sink2 = AsyncMock()
        
        writer = RoutingSinkWriter(
            sinks={"sink1": sink1, "sink2": sink2},
            rules=[
                ({"ERROR"}, ["sink1"]),
                ({"ERROR"}, ["sink2"]),
            ],
            fallback_sink_names=[],
            overlap=False,
        )
        
        await writer.write({"level": "ERROR", "message": "test"})
        
        sink1.write.assert_called_once()
        sink2.write.assert_not_called()
    
    async def test_fallback_when_no_match(self):
        from fapilog.core.routing import RoutingSinkWriter
        
        error_sink = AsyncMock()
        fallback_sink = AsyncMock()
        
        writer = RoutingSinkWriter(
            sinks={"error_sink": error_sink, "fallback": fallback_sink},
            rules=[
                ({"ERROR"}, ["error_sink"]),
            ],
            fallback_sink_names=["fallback"],
        )
        
        await writer.write({"level": "WARNING", "message": "test"})
        
        error_sink.write.assert_not_called()
        fallback_sink.write.assert_called_once()
    
    async def test_drops_when_no_match_and_no_fallback(self):
        from fapilog.core.routing import RoutingSinkWriter
        
        error_sink = AsyncMock()
        
        writer = RoutingSinkWriter(
            sinks={"error_sink": error_sink},
            rules=[
                ({"ERROR"}, ["error_sink"]),
            ],
            fallback_sink_names=[],
        )
        
        # Should not raise
        await writer.write({"level": "INFO", "message": "test"})
        
        error_sink.write.assert_not_called()
    
    async def test_level_case_insensitive(self):
        from fapilog.core.routing import RoutingSinkWriter
        
        sink = AsyncMock()
        
        writer = RoutingSinkWriter(
            sinks={"sink": sink},
            rules=[
                ({"ERROR"}, ["sink"]),
            ],
            fallback_sink_names=[],
        )
        
        await writer.write({"level": "error", "message": "test"})
        sink.write.assert_called_once()
        
        sink.reset_mock()
        await writer.write({"level": "Error", "message": "test"})
        sink.write.assert_called_once()


class TestRoutingSink:
    async def test_full_lifecycle(self):
        from fapilog.plugins.sinks.routing import RoutingSink, RoutingSinkConfig
        from fapilog.testing import MockSink
        
        config = RoutingSinkConfig(
            routes={
                "ERROR": ["mock"],
                "*": ["mock"],
            }
        )
        
        sink = RoutingSink(config)
        await sink.start()
        
        assert await sink.health_check() is True
        
        await sink.write({"level": "ERROR", "message": "test"})
        await sink.stop()
```

---

## Documentation

### docs/user-guide/sink-routing.md

```markdown
# Sink Routing by Log Level

Route different log levels to different sinks for cost optimization,
compliance requirements, or operational convenience.

## Quick Start

```bash
# Enable routing and define rules
export FAPILOG_SINK_ROUTING__ENABLED=true
export FAPILOG_SINK_ROUTING__RULES='[
  {"levels": ["ERROR", "CRITICAL"], "sinks": ["postgres"]},
  {"levels": ["DEBUG", "INFO", "WARNING"], "sinks": ["stdout_json"]}
]'
```

```python
from fapilog import runtime

with runtime() as logger:
    logger.info("Goes to stdout")      # → stdout_json
    logger.error("Goes to database")   # → postgres
```

## Configuration

### Via Settings

```python
from fapilog import Settings, get_logger
from fapilog.core.settings import RoutingRule

settings = Settings()
settings.sink_routing.enabled = True
settings.sink_routing.rules = [
    RoutingRule(levels=["ERROR", "CRITICAL"], sinks=["postgres", "pagerduty"]),
    RoutingRule(levels=["INFO", "WARNING"], sinks=["stdout_json"]),
    RoutingRule(levels=["DEBUG"], sinks=["rotating_file"]),
]
settings.sink_routing.fallback_sinks = ["rotating_file"]

logger = get_logger(settings=settings)
```

### Via Environment Variables

| Variable | Description | Example |
|----------|-------------|---------|
| `FAPILOG_SINK_ROUTING__ENABLED` | Enable routing | `true` |
| `FAPILOG_SINK_ROUTING__RULES` | JSON array of rules | See below |
| `FAPILOG_SINK_ROUTING__OVERLAP` | Allow multi-sink | `true` |
| `FAPILOG_SINK_ROUTING__FALLBACK_SINKS` | Fallback sink names | `rotating_file` |

Rules JSON format:

```json
[
  {"levels": ["ERROR", "CRITICAL"], "sinks": ["postgres", "webhook"]},
  {"levels": ["INFO"], "sinks": ["stdout_json"]}
]
```

## Use Cases

### Errors to Database, Info to Stdout

```yaml
sink_routing:
  enabled: true
  rules:
    - levels: [ERROR, CRITICAL]
      sinks: [postgres]
    - levels: [DEBUG, INFO, WARNING]
      sinks: [stdout_json]
```

### Multi-Destination (Overlap)

Send errors to multiple destinations:

```yaml
sink_routing:
  enabled: true
  overlap: true
  rules:
    - levels: [ERROR, CRITICAL]
      sinks: [postgres, pagerduty_webhook, cloudwatch]
    - levels: [INFO, WARNING]
      sinks: [stdout_json]
    - levels: [DEBUG]
      sinks: [rotating_file]
```

### Everything + Errors to Special Storage

```yaml
sink_routing:
  enabled: true
  overlap: true
  rules:
    - levels: [ERROR, CRITICAL]
      sinks: [postgres]  # Errors also go here
    - levels: [DEBUG, INFO, WARNING, ERROR, CRITICAL]
      sinks: [rotating_file]  # Everything goes here
```

### Fallback for Unmatched Levels

```yaml
sink_routing:
  enabled: true
  rules:
    - levels: [ERROR]
      sinks: [postgres]
  fallback_sinks: [stdout_json]  # INFO, DEBUG, WARNING go here
```

## RoutingSink Plugin

For manual composition without global settings:

```python
from fapilog import get_logger
from fapilog.plugins.sinks.routing import RoutingSink, RoutingSinkConfig

config = RoutingSinkConfig(
    routes={
        "ERROR": ["postgres"],
        "CRITICAL": ["postgres", "pagerduty"],
        "INFO": ["stdout_json"],
        "*": ["rotating_file"],  # Fallback
    },
    sink_configs={
        "postgres": {"table_name": "error_logs"},
        "pagerduty": {"endpoint": "https://..."},
    },
)

routing_sink = RoutingSink(config)
logger = get_logger(sinks=[routing_sink])
```

## Performance

- Level lookup is O(1) using dictionary
- No overhead when routing disabled
- Parallel writes supported via `sink_parallel_writes`

## Comparison with Filters

| Feature | Filters | Routing |
|---------|---------|---------|
| Drop events | ✅ | ❌ |
| Transform events | ✅ | ❌ |
| Route to specific sinks | ❌ | ✅ |
| Run before enrichment | ✅ | ❌ |
| Run at sink emission | ❌ | ✅ |

Use filters to **reduce** events; use routing to **direct** events.
```

---

## Migration Notes

Users with custom routing implementations can migrate:

**Before (custom RoutingSink):**

```python
class MyRoutingSink:
    def __init__(self):
        self.error_sink = PostgresSink(...)
        self.default_sink = StdoutJsonSink()
    
    async def write(self, entry):
        if entry.get("level") in ("ERROR", "CRITICAL"):
            await self.error_sink.write(entry)
        else:
            await self.default_sink.write(entry)
```

**After (native routing):**

```python
settings = Settings()
settings.sink_routing.enabled = True
settings.sink_routing.rules = [
    RoutingRule(levels=["ERROR", "CRITICAL"], sinks=["postgres"]),
]
settings.sink_routing.fallback_sinks = ["stdout_json"]
settings.core.sinks = ["postgres", "stdout_json"]

logger = get_logger(settings=settings)
```

---

## Success Metrics

- [ ] Routing logic has 100% test coverage
- [ ] Performance benchmark shows <1μs routing overhead
- [ ] Documentation includes all configuration options
- [ ] Backward compatibility maintained (disabled by default)
- [ ] Example application demonstrates common patterns

---

## Future Considerations

- **Field-based routing**: Route based on any field, not just level
- **Regex routing**: Route based on message pattern matching
- **Dynamic routing**: Update rules at runtime without restart
- **Metrics per route**: Track events routed to each sink
- **Conditional sinks**: Only activate sink if certain conditions met

---

## Related Stories

- Story 4.35: Sink Fault Isolation / Circuit Breaker
- Story 5.6: HTTP Sink Batching
- Story 5.23: PostgreSQL Database Sink


