# Story 4.34: Plugin Lifecycle Management

**Status:** Complete  
**Priority:** High  
**Depends on:** None  
**Effort:** 1-2 days

---

## Problem Statement

The logger calls `start()` and `stop()` lifecycle methods on **sinks**, but NOT on **enrichers** or **redactors**. This means:

1. Enrichers that need initialization (DB connections, cache warming) don't get started
2. Enrichers that hold resources (connections, file handles) don't get stopped
3. Redactors with expensive initialization (regex compilation is already done in `__init__`, but custom redactors may need more)

### Current Behavior

**Sinks:** ✅ Lifecycle managed

```python
# In __init__.py:_make_sink_writer()
async def _sink_write(entry: dict) -> None:
    if hasattr(sink, "start") and not getattr(sink, "_started", False):
        await sink.start()
        sink._started = True
    await sink.write(entry)
```

**Enrichers:** ❌ Lifecycle NOT managed

```python
# In __init__.py:get_logger()
enrichers = _load_plugins("fapilog.enrichers", ...)
# Enrichers are passed directly to logger, start() never called

# In logger.py:_flush_batch()
entry = await enrich_parallel(entry, list(self._enrichers), ...)
# enrich() called, but start() was never called
```

**Redactors:** ❌ Lifecycle NOT managed

```python
# In __init__.py:get_logger()
redactors = _load_plugins("fapilog.redactors", ...)
logger._redactors = cast(list[_BaseRedactor], redactors)
# Redactors set but start() never called
```

### Impact

1. **Custom enrichers may fail**: If an enricher needs `start()` for initialization
2. **Resource leaks**: On shutdown, enricher/redactor resources are not released
3. **Inconsistent protocol expectations**: Protocols define lifecycle methods but they're not used

---

## Goals

1. Call `start()` on all enrichers and redactors during logger initialization
2. Call `stop()` on all enrichers and redactors during logger shutdown
3. Handle lifecycle errors gracefully (log warning, continue with remaining plugins)
4. Maintain backward compatibility (plugins without lifecycle methods still work)

---

## Design

### Lifecycle Call Order

**Startup:**

1. Load plugins
2. Call `start()` on enrichers (parallel or sequential)
3. Call `start()` on redactors (sequential - order may matter)
4. Call `start()` on sinks (existing behavior)
5. Start logger workers

**Shutdown:**

1. Stop logger workers (drain queue)
2. Call `stop()` on sinks (existing behavior)
3. Call `stop()` on redactors (reverse order)
4. Call `stop()` on enrichers (parallel or sequential)

### Error Handling

Lifecycle errors should be **contained** - a failing enricher shouldn't prevent others from starting:

```python
async def _start_plugins(plugins: list[Any], plugin_type: str) -> list[Any]:
    """Start plugins, returning only successfully started ones."""
    started = []
    for plugin in plugins:
        try:
            if hasattr(plugin, "start"):
                await plugin.start()
            started.append(plugin)
        except Exception as exc:
            diagnostics.warn(
                plugin_type,
                "plugin start failed",
                plugin=getattr(plugin, "name", type(plugin).__name__),
                error=str(exc),
            )
    return started
```

---

## Implementation Plan

### Phase 1: Add Lifecycle Helper Functions

**File: `src/fapilog/__init__.py`**

Add helper functions:

```python
async def _start_plugins(
    plugins: list[Any],
    plugin_type: str,
) -> list[Any]:
    """Start plugins, returning only successfully started ones."""
    started = []
    for plugin in plugins:
        try:
            if hasattr(plugin, "start"):
                await plugin.start()
            started.append(plugin)
        except Exception as exc:
            try:
                from .core import diagnostics as _diag
                _diag.warn(
                    plugin_type,
                    "plugin start failed",
                    plugin=getattr(plugin, "name", type(plugin).__name__),
                    error=str(exc),
                )
            except Exception:
                pass
    return started


async def _stop_plugins(plugins: list[Any], plugin_type: str) -> None:
    """Stop all plugins, containing errors."""
    for plugin in reversed(plugins):
        try:
            if hasattr(plugin, "stop"):
                await plugin.stop()
        except Exception as exc:
            try:
                from .core import diagnostics as _diag
                _diag.warn(
                    plugin_type,
                    "plugin stop failed",
                    plugin=getattr(plugin, "name", type(plugin).__name__),
                    error=str(exc),
                )
            except Exception:
                pass
```

### Phase 2: Update get_async_logger

**File: `src/fapilog/__init__.py`**

```python
async def get_async_logger(
    name: str | None = None,
    *,
    settings: _Settings | None = None,
) -> AsyncLoggerFacade:
    cfg_source = settings or _Settings()
    sinks, enrichers, redactors, metrics = _build_pipeline(cfg_source)

    # Start enrichers and redactors
    enrichers = await _start_plugins(enrichers, "enricher")
    redactors = await _start_plugins(redactors, "redactor")

    sink_write, sink_write_serialized = _fanout_writer(sinks)

    logger = AsyncLoggerFacade(
        # ... existing params ...
        enrichers=cast(list[_BaseEnricher], enrichers),
        # ...
    )
    # ... rest of function ...
    logger._redactors = cast(list[_BaseRedactor], redactors)
    logger._sinks = sinks
    return logger
```

### Phase 3: Update Sync Logger Factory

For sync logger, we need to handle the async start in a sync context:

```python
def get_logger(
    name: str | None = None,
    *,
    settings: _Settings | None = None,
) -> SyncLoggerFacade:
    # ... existing setup ...

    # Start enrichers and redactors (sync wrapper)
    async def _do_start():
        nonlocal enrichers, redactors
        enrichers = await _start_plugins(enrichers, "enricher")
        redactors = await _start_plugins(redactors, "redactor")

    try:
        asyncio.get_running_loop()
        # Inside event loop - schedule for later
        # Plugins will be started on first use via lazy init
    except RuntimeError:
        # No loop - run sync
        asyncio.run(_do_start())

    # ... rest of function ...
```

### Phase 4: Add Stop to Logger Facades

**File: `src/fapilog/core/logger.py`**

Add stop calls to `stop_and_drain()`:

```python
async def stop_and_drain(self) -> DrainResult:
    # ... existing drain logic ...

    # Stop redactors (reverse order)
    for redactor in reversed(self._redactors):
        try:
            if hasattr(redactor, "stop"):
                await redactor.stop()
        except Exception:
            pass

    # Stop enrichers
    for enricher in reversed(self._enrichers):
        try:
            if hasattr(enricher, "stop"):
                await enricher.stop()
        except Exception:
            pass

    return DrainResult(...)
```

### Phase 5: Update runtime() Context Managers

Ensure `runtime()` and `runtime_async()` properly stop plugins on exit.

---

## Acceptance Criteria

- [x] `get_async_logger()` calls `start()` on all enrichers before returning
- [x] `get_async_logger()` calls `start()` on all redactors before returning
- [x] `get_logger()` calls `start()` on enrichers/redactors (sync-safe)
- [x] `stop_and_drain()` calls `stop()` on enrichers and redactors
- [x] Failed `start()` emits diagnostics and excludes plugin from active list
- [x] Failed `stop()` emits diagnostics but continues with other plugins
- [x] Existing tests pass (backward compatible)
- [x] New tests verify lifecycle calls

---

## Test Plan

```python
@pytest.mark.asyncio
async def test_enricher_lifecycle_called():
    """Enricher start/stop are called during logger lifecycle."""
    started = False
    stopped = False

    class TestEnricher:
        name = "test"

        async def start(self):
            nonlocal started
            started = True

        async def stop(self):
            nonlocal stopped
            stopped = True

        async def enrich(self, event):
            return {}

    # Inject into enricher list
    # ... test setup ...

    logger = await get_async_logger()
    assert started is True

    await logger.drain()
    assert stopped is True


@pytest.mark.asyncio
async def test_enricher_start_failure_contained():
    """Failed enricher start doesn't prevent logger creation."""
    class FailingEnricher:
        name = "failing"

        async def start(self):
            raise RuntimeError("start failed")

        async def enrich(self, event):
            return {}

    # Enricher should be excluded but logger should work
    # ... test assertions ...
```

---

## Files Changed

| File                                  | Change                                                  |
| ------------------------------------- | ------------------------------------------------------- |
| `src/fapilog/__init__.py`             | Add `_start_plugins`, `_stop_plugins`; update factories |
| `src/fapilog/core/logger.py`          | Add stop calls in `stop_and_drain()`                    |
| `tests/unit/test_plugin_lifecycle.py` | New test file                                           |
