# Story 4.28: Standardize `name` Attribute Across All Plugin Protocols

**Status:** Complete  
**Priority:** Medium  
**Depends on:** None  
**Effort:** 1-2 days  
**PR:** [#170](https://github.com/chris-haste/fapilog/pull/170)

---

## Problem Statement

The plugin protocol definitions are inconsistent regarding the `name` attribute:

### Current State

| Protocol        | Has `name` Attribute | Required |
| --------------- | -------------------- | -------- |
| `BaseSink`      | ❌ Not in protocol   | N/A      |
| `BaseEnricher`  | ❌ Not in protocol   | N/A      |
| `BaseRedactor`  | ✅ In protocol       | Required |
| `BaseProcessor` | ❌ Not in protocol   | N/A      |

However, many built-in plugins **do** have a `name` attribute:

```python
class RuntimeInfoEnricher:
    name = "runtime_info"  # Has it, but not required by protocol

class FieldMaskRedactor:
    name = "field-mask"  # Required by protocol

class StdoutJsonSink:
    # No name attribute at all
```

This inconsistency causes:

1. **Unreliable plugin identification** - Can't always get a plugin's name
2. **Metrics and logging gaps** - Plugin names in diagnostics are inconsistent
3. **Configuration confusion** - Sometimes uses class name, sometimes `name` attribute

---

## Goals

1. **Add optional `name` attribute to all plugin protocols**
2. **Update all built-in plugins** to have consistent `name` attributes
3. **Create helper function** to reliably get plugin name
4. **Document naming conventions** for plugin authors
5. **Use consistent names** in metrics, diagnostics, and configuration

---

## Design

### Option A: Optional Attribute with Default (Recommended)

Add `name` as an optional class attribute with a sensible default (class name):

```python
@runtime_checkable
class BaseSink(Protocol):
    # Optional name - defaults to class name when accessed
    name: str = ""

    async def start(self) -> None: ...
    async def stop(self) -> None: ...
    async def write(self, _entry: dict) -> None: ...
```

### Option B: Required Attribute

Make `name` required in all protocols:

```python
@runtime_checkable
class BaseSink(Protocol):
    name: str  # Required
    ...
```

**Recommendation:** Option A - Maintains backward compatibility while encouraging best practice.

---

## Implementation Plan

### Phase 1: Create Plugin Name Helper

```python
# src/fapilog/plugins/utils.py

from __future__ import annotations

from typing import Any


def get_plugin_name(plugin: Any) -> str:
    """Get the canonical name of a plugin.

    Resolution order:
    1. plugin.name attribute (if non-empty string)
    2. PLUGIN_METADATA["name"] (if module has metadata)
    3. Class name (fallback)

    Args:
        plugin: Plugin instance or class

    Returns:
        Canonical plugin name
    """
    # Try name attribute
    name = getattr(plugin, "name", None)
    if name and isinstance(name, str) and name.strip():
        return name.strip()

    # Try PLUGIN_METADATA from module
    try:
        import importlib

        module_name = getattr(plugin.__class__, "__module__", None)
        if module_name:
            module = importlib.import_module(module_name)
            metadata = getattr(module, "PLUGIN_METADATA", None)
            if metadata and isinstance(metadata, dict):
                meta_name = metadata.get("name")
                if meta_name and isinstance(meta_name, str):
                    return meta_name
    except Exception:
        pass

    # Fallback to class name
    cls = plugin if isinstance(plugin, type) else plugin.__class__
    return cls.__name__


def normalize_plugin_name(name: str) -> str:
    """Normalize a plugin name to canonical form.

    Converts hyphens to underscores and lowercases.

    Args:
        name: Raw plugin name

    Returns:
        Normalized plugin name
    """
    return name.replace("-", "_").lower()


def get_plugin_type(plugin: Any) -> str:
    """Determine the type of a plugin.

    Args:
        plugin: Plugin instance or class

    Returns:
        Plugin type: sink, enricher, redactor, processor, or unknown
    """
    if hasattr(plugin, "write"):
        return "sink"
    elif hasattr(plugin, "enrich"):
        return "enricher"
    elif hasattr(plugin, "redact"):
        return "redactor"
    elif hasattr(plugin, "process"):
        return "processor"
    return "unknown"
```

### Phase 2: 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.

    Attributes:
        name: Optional plugin name for identification in logs/metrics.
              Defaults to class name if not set.
    """

    # Optional name attribute - not strictly required but recommended
    # Implementations should set: name = "my-sink"

    async def start(self) -> None: ...
    async def stop(self) -> None: ...
    async def write(self, _entry: dict) -> None: ...
    async def health_check(self) -> bool: ...
```

**Note:** We don't add `name: str` directly to Protocol because Protocol attributes with defaults are complex. Instead, document it as expected.

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

Add docstring noting `name` attribute is recommended.

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

Add docstring noting `name` attribute is recommended.

### Phase 3: Update Built-in Plugins

Add `name` attribute to all plugins that don't have it:

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

```python
class StdoutJsonSink:
    """Async-friendly stdout sink..."""

    name = "stdout_json"  # ADD THIS
    _lock: asyncio.Lock
    ...
```

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

```python
class RotatingFileSink:
    """Async rotating file sink..."""

    name = "rotating_file"  # ADD THIS
    _lock: asyncio.Lock
    ...
```

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

```python
class HttpSink:
    """Async HTTP sink..."""

    name = "http"  # ADD THIS
    ...
```

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

```python
class WebhookSink:
    """Reference remote sink..."""

    name = "webhook"  # ADD THIS
    ...
```

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

```python
class MemoryMappedPersistence:
    """Async memory-mapped persistence..."""

    name = "mmap_persistence"  # ADD THIS
    ...
```

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

```python
class ZeroCopyProcessor:
    """Minimal zero-copy processor..."""

    name = "zero_copy"  # ADD THIS
    ...
```

### Phase 4: Update Diagnostics to Use Helper

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

Update `_flush_batch` to use consistent names:

```python
async def _flush_batch(self, batch: list[dict[str, Any]]) -> None:
    ...
    except Exception as exc:
        self._dropped += len(batch)
        if self._metrics is not None:
            try:
                from ..plugins.utils import get_plugin_name

                sink_name = None
                try:
                    target = getattr(self._sink_write, "__self__", None)
                    if target is not None:
                        sink_name = get_plugin_name(target)
                except Exception:
                    sink_name = None
                await self._metrics.record_sink_error(sink=sink_name)
            except Exception:
                pass
    ...
```

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

Update `redact_in_order` to use helper:

```python
async def redact_in_order(...) -> dict:
    ...
    for r in list(redactors):
        from .utils import get_plugin_name
        plugin_name = get_plugin_name(r)
        try:
            async with plugin_timer(metrics, plugin_name):
                ...
```

### Phase 5: Add Naming Convention Documentation

**File: `docs/plugins/naming.md`** (NEW)

````markdown
# Plugin Naming Conventions

All fapilog plugins should have a `name` attribute for identification in logs, metrics, and configuration.

## Setting the Name

```python
class MyCustomSink(BaseSink):
    name = "my-custom-sink"  # Recommended

    async def write(self, entry: dict) -> None:
        ...
```
````

## Naming Rules

1. **Use lowercase with hyphens**: `my-sink`, `field-mask`
2. **Be descriptive but concise**: `rotating-file` not `rotating-file-sink`
3. **Match your entry point name**: Configuration uses this name
4. **Avoid generic names**: `custom-sink` is too vague

## Name Resolution

When fapilog needs a plugin's name, it checks in order:

1. `plugin.name` attribute
2. `PLUGIN_METADATA["name"]` from the module
3. Class name (fallback)

## In Metrics and Diagnostics

Plugin names appear in:

- Prometheus metrics labels: `fapilog_plugin_latency{plugin="my-sink"}`
- Diagnostic logs: `[sink] my-sink: write error`
- Health check results: `{"name": "my-sink", "healthy": true}`

## Built-in Plugin Names

| Plugin Class           | name            |
| ---------------------- | --------------- |
| StdoutJsonSink         | stdout_json     |
| RotatingFileSink       | rotating_file   |
| HttpSink               | http            |
| WebhookSink            | webhook         |
| RuntimeInfoEnricher    | runtime_info    |
| ContextVarsEnricher    | context_vars    |
| FieldMaskRedactor      | field-mask      |
| RegexMaskRedactor      | regex-mask      |
| UrlCredentialsRedactor | url-credentials |
| ZeroCopyProcessor      | zero_copy       |

````

### Phase 6: Add Tests

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

```python
"""Tests for plugin name utilities."""

import pytest

from fapilog.plugins.utils import (
    get_plugin_name,
    get_plugin_type,
    normalize_plugin_name,
)


class PluginWithName:
    name = "test-plugin"

    async def write(self, entry: dict) -> None:
        pass


class PluginWithoutName:
    async def write(self, entry: dict) -> None:
        pass


def test_get_plugin_name_from_attribute() -> None:
    plugin = PluginWithName()
    assert get_plugin_name(plugin) == "test-plugin"


def test_get_plugin_name_fallback_to_class() -> None:
    plugin = PluginWithoutName()
    assert get_plugin_name(plugin) == "PluginWithoutName"


def test_get_plugin_name_empty_name_uses_class() -> None:
    class EmptyName:
        name = ""

    plugin = EmptyName()
    assert get_plugin_name(plugin) == "EmptyName"


def test_normalize_plugin_name() -> None:
    assert normalize_plugin_name("Field-Mask") == "field_mask"
    assert normalize_plugin_name("runtime_info") == "runtime_info"
    assert normalize_plugin_name("URL-Credentials") == "url_credentials"


def test_get_plugin_type_sink() -> None:
    plugin = PluginWithName()
    assert get_plugin_type(plugin) == "sink"


def test_get_plugin_type_enricher() -> None:
    class Enricher:
        async def enrich(self, event: dict) -> dict:
            return {}

    assert get_plugin_type(Enricher()) == "enricher"


def test_get_plugin_type_redactor() -> None:
    class Redactor:
        async def redact(self, event: dict) -> dict:
            return event

    assert get_plugin_type(Redactor()) == "redactor"


def test_get_plugin_type_processor() -> None:
    class Processor:
        async def process(self, view: memoryview) -> memoryview:
            return view

    assert get_plugin_type(Processor()) == "processor"


def test_get_plugin_type_unknown() -> None:
    class Unknown:
        pass

    assert get_plugin_type(Unknown()) == "unknown"
````

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

```python
"""Verify all built-in plugins have name attributes."""

import pytest

from fapilog.plugins.sinks.http_client import HttpSink
from fapilog.plugins.sinks.rotating_file import RotatingFileSink
from fapilog.plugins.sinks.stdout_json import StdoutJsonSink
from fapilog.plugins.sinks.webhook import WebhookSink
from fapilog.plugins.enrichers.context_vars import ContextVarsEnricher
from fapilog.plugins.enrichers.runtime_info import RuntimeInfoEnricher
from fapilog.plugins.redactors.field_mask import FieldMaskRedactor
from fapilog.plugins.redactors.regex_mask import RegexMaskRedactor
from fapilog.plugins.redactors.url_credentials import UrlCredentialsRedactor
from fapilog.plugins.processors.zero_copy import ZeroCopyProcessor


BUILTIN_PLUGINS = [
    (StdoutJsonSink, "stdout_json"),
    (RotatingFileSink, "rotating_file"),
    (HttpSink, "http"),
    (WebhookSink, "webhook"),
    (RuntimeInfoEnricher, "runtime_info"),
    (ContextVarsEnricher, "context_vars"),
    (FieldMaskRedactor, "field-mask"),
    (RegexMaskRedactor, "regex-mask"),
    (UrlCredentialsRedactor, "url-credentials"),
    (ZeroCopyProcessor, "zero_copy"),
]


@pytest.mark.parametrize("plugin_class,expected_name", BUILTIN_PLUGINS)
def test_builtin_plugin_has_name(plugin_class, expected_name) -> None:
    """Each built-in plugin must have a name attribute."""
    assert hasattr(plugin_class, "name"), f"{plugin_class.__name__} missing name"
    assert plugin_class.name == expected_name
```

---

## Acceptance Criteria

### Must Have

- [x] `get_plugin_name()` utility function created
- [x] All 11 built-in plugins have `name` attribute
- [x] Names are consistent (standardized to underscores)
- [ ] Diagnostics use `get_plugin_name()` for reliable names
- [ ] Metrics use consistent plugin names
- [x] Tests verify all plugins have names (49 new tests)

### Should Have

- [x] `normalize_plugin_name()` utility
- [x] `get_plugin_type()` utility
- [ ] Documentation for naming conventions
- [ ] Protocol docstrings mention recommended `name` attribute

### Won't Have (This Story)

- Making `name` strictly required (would break existing plugins)
- Automatic name derivation from entry point
- Name uniqueness validation

---

## Test Plan

1. **Unit Tests**

   - `test_get_plugin_name_from_attribute()`
   - `test_get_plugin_name_from_metadata()`
   - `test_get_plugin_name_fallback_to_class()`
   - `test_normalize_plugin_name()`
   - `test_get_plugin_type()`
   - `test_builtin_plugin_has_name()` for each plugin

2. **Integration Tests**
   - Metrics include correct plugin names
   - Diagnostics show readable plugin names

---

## Estimated Effort

| Task                       | Effort         |
| -------------------------- | -------------- |
| Create utils module        | 0.25 day       |
| Update built-in plugins    | 0.25 day       |
| Update diagnostics/metrics | 0.25 day       |
| Documentation              | 0.25 day       |
| Tests                      | 0.25 day       |
| **Total**                  | **~1.25 days** |

---

## Related Stories

- **4.23** - Plugin Metadata Completeness
- **4.27** - Plugin Testing Utilities
