# Story 4.30: Plugin API Standardization and Testing Documentation

**Status:** Draft  
**Priority:** High  
**Depends on:** 4.27 (Plugin Testing Utilities), 4.28 (Standardize Plugin Name)  
**Effort:** 1-2 days  

---

## Problem Statement

The plugin audit revealed two areas needing improvement:

### 1. Missing `name` Attribute in Protocol Definitions

The `BaseRedactor` protocol requires a `name: str` attribute, but the other protocols do not:

| Protocol | Has `name` Attribute | Plugins Use `name`? |
|----------|---------------------|---------------------|
| `BaseRedactor` | ✅ Required in Protocol | ✅ All redactors have it |
| `BaseSink` | ❌ Not in Protocol | ✅ All sinks have it on class |
| `BaseEnricher` | ❌ Not in Protocol | ✅ All enrichers have it on class |
| `BaseProcessor` | ❌ Not in Protocol | ✅ All processors have it on class |

**Impact:**
- Inconsistent protocol contracts confuse plugin authors
- Type checkers don't enforce `name` attribute for non-redactor plugins
- `get_plugin_name()` utility has to use fallback logic
- Testing validators check `name` for redactors only

### 2. Undocumented Testing Utilities

Story 4.27 created `fapilog.testing` module with comprehensive utilities:

- `MockSink`, `MockEnricher`, `MockRedactor`, `MockProcessor`
- `validate_sink()`, `validate_enricher()`, `validate_redactor()`, `validate_processor()`
- `validate_plugin_lifecycle()`
- `create_log_event()`, `create_batch_events()`, `create_sensitive_event()`

**However, there is NO user-facing documentation for these utilities.** Plugin authors have to read the source code to discover them.

### 3. `enable_enricher()`/`disable_enricher()` Documentation

While these methods ARE implemented on both `SyncLoggerFacade` and `AsyncLoggerFacade`, they could benefit from:

- More prominent documentation in the enricher guide
- API reference documentation
- Additional examples showing common patterns

---

## Goals

1. **Add `name: str` attribute to all base protocols** (`BaseSink`, `BaseEnricher`, `BaseProcessor`)
2. **Create comprehensive testing utilities documentation** at `docs/user-guide/testing-plugins.md`
3. **Improve enricher runtime control documentation**
4. **Update validators to check `name` for all plugin types**
5. **Add automated test for protocol consistency**

---

## Design

### Protocol Attribute Standardization

Add `name` as a required Protocol attribute to all plugin types:

```python
@runtime_checkable
class BaseSink(Protocol):
    """Authoring contract for sinks."""
    
    name: str  # NEW: Required plugin identifier
    
    async def start(self) -> None: ...
    async def stop(self) -> None: ...
    async def write(self, _entry: dict) -> None: ...
    async def health_check(self) -> bool: ...


@runtime_checkable
class BaseEnricher(Protocol):
    """Authoring contract for enrichers."""
    
    name: str  # NEW: Required plugin identifier
    
    async def start(self) -> None: ...
    async def stop(self) -> None: ...
    async def enrich(self, event: dict) -> dict: ...
    async def health_check(self) -> bool: ...


@runtime_checkable
class BaseProcessor(Protocol):
    """Authoring contract for processors."""
    
    name: str  # NEW: Required plugin identifier
    
    async def start(self) -> None: ...
    async def stop(self) -> None: ...
    async def process(self, view: memoryview) -> memoryview: ...
    async def health_check(self) -> bool: ...
```

### Backward Compatibility

All existing built-in plugins already have `name` class attributes:

```python
# Already present in all built-ins
class StdoutJsonSink:
    name = "stdout_json"
    
class RuntimeInfoEnricher:
    name = "runtime_info"
    
class ZeroCopyProcessor:
    name = "zero_copy"
```

**Risk:** Third-party plugins without `name` will fail `isinstance(plugin, BaseSink)` checks.

**Mitigation:** 
- The protocol checks are runtime-checkable, so missing `name` will cause the check to fail
- Add clear migration guidance in documentation
- Emit diagnostics warning when loading plugins without `name`

---

## Implementation Plan

### Phase 1: Update Protocol Definitions

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

```python
@runtime_checkable
class BaseSink(Protocol):
    """Authoring contract for sinks that emit finalized log entries.

    Expectations:
    - Async-first: methods are `async def` and must not block the event loop
    - Resilient: exceptions MUST be contained; never allow sink errors to crash
      the core pipeline
    - Deterministic output: each invocation of ``write`` produces one record

    Attributes:
        name: Unique identifier for this sink type (e.g., "stdout_json").
    """

    name: str  # Plugin identifier for discovery and configuration

    async def start(self) -> None:
        """Initialize resources for the sink."""

    async def stop(self) -> None:
        """Flush and release resources for the sink."""

    async def write(self, _entry: dict) -> None:
        """Emit a single structured JSON-serializable mapping."""
        ...

    async def health_check(self) -> bool:
        """Return True if the sink is healthy. Default: assume healthy."""
        return True
```

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

```python
@runtime_checkable
class BaseEnricher(Protocol):
    """Authoring contract for enrichers that augment events.

    Enrichers receive an event mapping and return a mapping of additional fields
    to be shallow-merged into the event.

    Attributes:
        name: Unique identifier for this enricher type (e.g., "runtime_info").
    """

    name: str  # Plugin identifier for discovery and configuration

    async def start(self) -> None:
        """Initialize resources for the enricher (optional)."""

    async def stop(self) -> None:
        """Release resources for the enricher (optional)."""

    async def enrich(self, event: dict) -> dict:
        """Return additional fields computed from the input event."""

    async def health_check(self) -> bool:
        """Return True if the enricher is healthy. Default: assume healthy."""
        return True
```

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

```python
@runtime_checkable
class BaseProcessor(Protocol):
    """Authoring contract for processors that transform serialized views.

    Processors operate on memoryview slices of serialized payloads.

    Attributes:
        name: Unique identifier for this processor type (e.g., "zero_copy").
    """

    name: str  # Plugin identifier for discovery and configuration

    async def start(self) -> None:
        """Initialize processor resources (optional)."""

    async def stop(self) -> None:
        """Release processor resources (optional)."""

    async def process(self, view: memoryview) -> memoryview:
        """Transform a single view and return the processed view."""

    async def process_many(self, views: Iterable[memoryview]) -> int:
        # Default implementation
        count = 0
        for v in views:
            _ = await self.process(v)
            count += 1
        return count

    async def health_check(self) -> bool:
        """Return True if the processor is healthy. Default: assume healthy."""
        return True
```

### Phase 2: Update Testing Validators

**File: `src/fapilog/testing/validators.py`**

Update `validate_sink()` to check for `name`:

```python
def validate_sink(sink: Any) -> ValidationResult:
    """Validate that a sink implements BaseSink protocol correctly."""
    errors: list[str] = []
    warnings: list[str] = []

    # NEW: Check name attribute
    if not hasattr(sink, "name"):
        errors.append("Missing required 'name' attribute")
    elif not isinstance(getattr(sink, "name", None), str):
        errors.append("'name' attribute must be a string")

    # Check required methods
    required_methods = ["start", "stop", "write"]
    for method_name in required_methods:
        if not hasattr(sink, method_name):
            errors.append(f"Missing required method: {method_name}")
            continue
        method = getattr(sink, method_name)
        if not asyncio.iscoroutinefunction(method):
            errors.append(f"{method_name} must be async")

    # ... rest of validation ...

    return ValidationResult(
        valid=len(errors) == 0,
        plugin_type="BaseSink",
        errors=errors,
        warnings=warnings,
    )
```

Apply similar changes to `validate_enricher()` and `validate_processor()`.

### Phase 3: Create Testing Documentation

**File: `docs/user-guide/testing-plugins.md` (NEW)**

```markdown
# Testing Plugins

fapilog provides testing utilities to help you develop and validate custom plugins.

## Installation

The testing utilities are included in the main `fapilog` package:

```python
from fapilog.testing import (
    # Mock plugins
    MockSink,
    MockEnricher,
    MockRedactor,
    MockProcessor,
    # Validators
    validate_sink,
    validate_enricher,
    validate_redactor,
    validate_processor,
    validate_plugin_lifecycle,
    # Factories
    create_log_event,
    create_batch_events,
    create_sensitive_event,
)
```

## Mock Plugins

### MockSink

A configurable mock sink that captures written events for inspection:

```python
from fapilog.testing import MockSink, MockSinkConfig

# Basic usage
sink = MockSink()
await sink.start()
await sink.write({"level": "INFO", "message": "test"})

assert len(sink.events) == 1
assert sink.events[0]["message"] == "test"

# With failure simulation
config = MockSinkConfig(
    fail_after=5,  # Fail after 5 writes
    fail_with=IOError("connection lost"),
    latency_seconds=0.1,  # Simulate network latency
)
sink = MockSink(config)

# Assertion helpers
sink.assert_event_count(1)
sink.assert_event_contains(0, level="INFO", message="test")

# Reset for reuse
sink.reset()
```

### MockEnricher

A mock enricher that adds configurable fields:

```python
from fapilog.testing import MockEnricher, MockEnricherConfig

config = MockEnricherConfig(
    fields_to_add={"service": "test", "version": "1.0.0"},
    latency_seconds=0.05,
)
enricher = MockEnricher(config)

result = await enricher.enrich({"message": "hello"})
assert result == {"service": "test", "version": "1.0.0"}
```

### MockRedactor

A mock redactor that masks specified fields:

```python
from fapilog.testing import MockRedactor, MockRedactorConfig

config = MockRedactorConfig(
    fields_to_mask=["user.password", "auth.token"],
    mask_string="***REDACTED***",
)
redactor = MockRedactor(config)

event = {"user": {"name": "alice", "password": "secret"}}
result = await redactor.redact(event)
assert result["user"]["password"] == "***REDACTED***"
```

### MockProcessor

A mock processor for testing memoryview operations:

```python
from fapilog.testing import MockProcessor

processor = MockProcessor()
view = memoryview(b'{"message": "test"}')
result = await processor.process(view)

assert processor.call_count == 1
```

## Protocol Validators

Use validators to verify your plugins implement protocols correctly:

```python
from fapilog.testing import validate_sink, validate_enricher

class MyCustomSink:
    name = "my-sink"
    
    async def start(self) -> None:
        pass
    
    async def stop(self) -> None:
        pass
    
    async def write(self, entry: dict) -> None:
        print(entry)


def test_my_sink_protocol():
    sink = MyCustomSink()
    result = validate_sink(sink)
    
    assert result.valid, f"Validation failed: {result.errors}"
    assert not result.warnings
```

### validate_plugin_lifecycle

Test that lifecycle methods work correctly:

```python
import pytest
from fapilog.testing import validate_plugin_lifecycle

@pytest.mark.asyncio
async def test_my_sink_lifecycle():
    sink = MyCustomSink()
    result = await validate_plugin_lifecycle(sink)
    
    assert result.valid
    # Checks:
    # - start() doesn't raise
    # - stop() doesn't raise
    # - stop() is idempotent (can be called twice)
```

## Test Data Factories

Generate realistic test data for your tests:

```python
from fapilog.testing import (
    create_log_event,
    create_batch_events,
    create_sensitive_event,
)

# Single event with defaults
event = create_log_event()
# {"level": "INFO", "message": "Test message 1234", ...}

# With custom values
event = create_log_event(
    level="ERROR",
    message="Something went wrong",
    user_id="user-123",
)

# Batch of events
events = create_batch_events(count=100, level="DEBUG")

# Event with sensitive data (for redaction testing)
sensitive = create_sensitive_event()
# Contains: password, ssn, card_number, api_key, etc.
```

## Testing Patterns

### Testing a Custom Sink

```python
import pytest
from fapilog.testing import MockSink, validate_sink, create_log_event


class MyDatabaseSink:
    name = "my-database"
    
    def __init__(self, connection_string: str):
        self._conn_str = connection_string
        self._client = None
    
    async def start(self) -> None:
        self._client = await connect(self._conn_str)
    
    async def stop(self) -> None:
        if self._client:
            await self._client.close()
    
    async def write(self, entry: dict) -> None:
        await self._client.insert(entry)
    
    async def health_check(self) -> bool:
        return self._client is not None and self._client.is_connected()


def test_protocol_compliance():
    sink = MyDatabaseSink("sqlite:///:memory:")
    result = validate_sink(sink)
    result.raise_if_invalid()


@pytest.mark.asyncio
async def test_write_events():
    sink = MyDatabaseSink("sqlite:///:memory:")
    await sink.start()
    
    event = create_log_event(level="INFO", message="test")
    await sink.write(event)
    
    # Verify event was written
    # ...
    
    await sink.stop()
```

### Testing a Custom Enricher

```python
import pytest
from fapilog.testing import validate_enricher, create_log_event


class MyEnricher:
    name = "my-enricher"
    
    async def start(self) -> None:
        pass
    
    async def stop(self) -> None:
        pass
    
    async def enrich(self, event: dict) -> dict:
        return {"enriched_at": time.time()}


def test_protocol_compliance():
    enricher = MyEnricher()
    result = validate_enricher(enricher)
    assert result.valid


@pytest.mark.asyncio
async def test_enrichment():
    enricher = MyEnricher()
    event = create_log_event()
    
    additions = await enricher.enrich(event)
    
    assert "enriched_at" in additions
    assert isinstance(additions["enriched_at"], float)
```

### Testing Redaction

```python
from fapilog.testing import create_sensitive_event


@pytest.mark.asyncio
async def test_redacts_sensitive_fields():
    redactor = MyRedactor(fields=["password", "ssn"])
    event = create_sensitive_event()
    
    result = await redactor.redact(event)
    
    assert result["user"]["password"] != "supersecret123"
    assert result["user"]["ssn"] != "123-45-6789"
```

## Best Practices

1. **Always validate protocol compliance** before testing behavior
2. **Use mock plugins** to test integration without external dependencies
3. **Use factories** for consistent, realistic test data
4. **Test lifecycle methods** to ensure proper resource management
5. **Test error handling** by configuring mocks to fail
6. **Test idempotency** - call `stop()` twice to verify it doesn't break
```

### Phase 4: Update Index and Navigation

**File: `docs/user-guide/index.md`**

Add to toctree:

```markdown
```{toctree}
:maxdepth: 2
:caption: User Guide

using-logger
configuration
context-enrichment
redactors
testing-plugins  # NEW
...
```
```

### Phase 5: Enhance Enricher Documentation

**File: `docs/user-guide/context-enrichment.md`**

Add a new section:

```markdown
## Runtime Enricher Control

You can dynamically enable or disable enrichers at runtime:

### Disabling an Enricher

```python
from fapilog import get_logger

logger = get_logger()

# Disable the context_vars enricher by name
logger.disable_enricher("context_vars")

# Logs after this point won't include context variables
logger.info("No context vars here")
```

### Enabling an Enricher

```python
from fapilog import get_logger
from fapilog.plugins.enrichers import RuntimeInfoEnricher

logger = get_logger()

# Add a new enricher instance at runtime
logger.enable_enricher(RuntimeInfoEnricher())

# Logs after this point include runtime info
logger.info("Now with runtime info")
```

### Common Patterns

**Conditional enrichment based on environment:**

```python
import os
from fapilog import get_logger
from fapilog.plugins.enrichers import RuntimeInfoEnricher

logger = get_logger()

if os.getenv("ENABLE_DETAILED_LOGGING"):
    logger.enable_enricher(RuntimeInfoEnricher())
```

**Temporarily disable enrichers for performance:**

```python
from contextlib import contextmanager

@contextmanager
def without_enricher(logger, name: str):
    logger.disable_enricher(name)
    try:
        yield
    finally:
        # Note: Re-enabling requires the instance, so this pattern
        # is best for temporary disabling only
        pass

# Usage
with without_enricher(logger, "context_vars"):
    for item in large_batch:
        logger.debug("processing", item_id=item.id)
```

### Important Notes

- `enable_enricher()` adds the enricher if no enricher with the same `name` exists
- `disable_enricher()` removes all enrichers matching the given name
- Changes take effect for subsequent log calls (not retroactively)
- Enrichers must have a `name` attribute to be enabled/disabled by name
```

---

## Acceptance Criteria

### Must Have

- [ ] `name: str` attribute added to `BaseSink` protocol
- [ ] `name: str` attribute added to `BaseEnricher` protocol
- [ ] `name: str` attribute added to `BaseProcessor` protocol
- [ ] `validate_sink()` checks for `name` attribute
- [ ] `validate_enricher()` checks for `name` attribute
- [ ] `validate_processor()` checks for `name` attribute
- [ ] New documentation: `docs/user-guide/testing-plugins.md`
- [ ] Testing docs added to navigation/index
- [ ] All existing tests pass
- [ ] 90%+ coverage maintained

### Should Have

- [ ] Enhanced enricher runtime control documentation
- [ ] Migration guidance for third-party plugins without `name`
- [ ] Protocol consistency test

### Out of Scope

- Adding new mock plugin types
- Adding integration test helpers
- Creating pytest fixtures

---

## Test Plan

### New Unit Tests

**File: `tests/unit/test_protocol_name_attribute.py`**

```python
"""Verify all protocols require name attribute."""

import pytest
from fapilog.plugins import BaseSink, BaseEnricher, BaseProcessor, BaseRedactor


def test_base_sink_has_name_in_annotations():
    """BaseSink protocol should require name attribute."""
    assert "name" in BaseSink.__protocol_attrs__


def test_base_enricher_has_name_in_annotations():
    """BaseEnricher protocol should require name attribute."""
    assert "name" in BaseEnricher.__protocol_attrs__


def test_base_processor_has_name_in_annotations():
    """BaseProcessor protocol should require name attribute."""
    assert "name" in BaseProcessor.__protocol_attrs__


def test_base_redactor_has_name_in_annotations():
    """BaseRedactor protocol should require name attribute."""
    assert "name" in BaseRedactor.__protocol_attrs__


class _SinkWithName:
    name = "test"
    async def start(self) -> None: pass
    async def stop(self) -> None: pass
    async def write(self, entry: dict) -> None: pass
    async def health_check(self) -> bool: return True


class _SinkWithoutName:
    async def start(self) -> None: pass
    async def stop(self) -> None: pass
    async def write(self, entry: dict) -> None: pass
    async def health_check(self) -> bool: return True


def test_isinstance_check_with_name():
    """Plugin with name should satisfy protocol."""
    sink = _SinkWithName()
    assert isinstance(sink, BaseSink)


def test_isinstance_check_without_name():
    """Plugin without name should NOT satisfy protocol."""
    sink = _SinkWithoutName()
    assert not isinstance(sink, BaseSink)
```

**File: `tests/unit/test_validator_checks_name.py`**

```python
"""Verify validators check for name attribute."""

import pytest
from fapilog.testing import validate_sink, validate_enricher, validate_processor


class _NamelessSink:
    async def start(self) -> None: pass
    async def stop(self) -> None: pass
    async def write(self, entry: dict) -> None: pass


class _NamedSink:
    name = "test"
    async def start(self) -> None: pass
    async def stop(self) -> None: pass
    async def write(self, entry: dict) -> None: pass


def test_validate_sink_rejects_nameless():
    result = validate_sink(_NamelessSink())
    assert not result.valid
    assert any("name" in e.lower() for e in result.errors)


def test_validate_sink_accepts_named():
    result = validate_sink(_NamedSink())
    assert result.valid
```

---

## Files Changed

### Modified Files

| File | Changes |
|------|---------|
| `src/fapilog/plugins/sinks/__init__.py` | Add `name: str` to `BaseSink` protocol |
| `src/fapilog/plugins/enrichers/__init__.py` | Add `name: str` to `BaseEnricher` protocol |
| `src/fapilog/plugins/processors/__init__.py` | Add `name: str` to `BaseProcessor` protocol |
| `src/fapilog/testing/validators.py` | Update all validators to check `name` |
| `docs/user-guide/context-enrichment.md` | Add runtime control section |
| `docs/user-guide/index.md` | Add testing-plugins to toctree |

### New Files

| File | Purpose |
|------|---------|
| `docs/user-guide/testing-plugins.md` | Comprehensive testing utilities documentation |
| `tests/unit/test_protocol_name_attribute.py` | Verify protocol `name` requirement |
| `tests/unit/test_validator_checks_name.py` | Verify validators check `name` |

---

## Risks & Mitigations

| Risk | Impact | Mitigation |
|------|--------|------------|
| Breaking third-party plugins | High | Clear migration docs, emit warnings |
| Protocol `isinstance` checks fail | Medium | Plugins already have `name` in practice |
| Documentation drift | Low | Link docs to source, add tests |

---

## Estimated Effort

| Task | Effort |
|------|--------|
| Update 3 protocol definitions | 30 min |
| Update 3 validators | 30 min |
| Create testing-plugins.md documentation | 2 hours |
| Update context-enrichment.md | 30 min |
| Add to navigation/index | 10 min |
| Create protocol tests | 30 min |
| Create validator tests | 30 min |
| Run full test suite, fix issues | 30 min |
| **Total** | **~5 hours** |

---

## Related Stories

- **4.27** - Created the testing utilities being documented
- **4.28** - Previous work on standardizing plugin names
- **4.29** - Plugin consistency improvements

---

## Definition of Done

- [ ] All acceptance criteria met
- [ ] All new code has tests
- [ ] All tests pass (`pytest tests/`)
- [ ] Coverage ≥ 90%
- [ ] No linting errors (`ruff check src/`)
- [ ] Documentation builds without warnings
- [ ] PR reviewed and approved



