# Story 5.15: Official Grafana Loki Sink

## Status: Complete

## Priority: High

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

## Dependencies: None

## Epic: Cloud Sink Examples (Story 5.5)

---

## Context

This story creates a Grafana Loki sink as the official **HTTP-based integration pattern** example. Loki is an open-source log aggregation system that pairs with Grafana for visualization, and its HTTP push API represents how many modern log aggregators work.

Combined with the AWS CloudWatch sink (SDK-based pattern), these two sinks provide complete coverage of the patterns users need to build their own cloud integrations.

### Why Loki?

| Factor               | Benefit                                       |
| -------------------- | --------------------------------------------- |
| **Fully testable**   | Run Loki in Docker for CI tests               |
| **Open source**      | Anyone can test locally, no accounts needed   |
| **HTTP-based**       | Demonstrates pure HTTP integration pattern    |
| **Growing adoption** | Default for Grafana Cloud, Kubernetes logging |
| **Simple API**       | Clean reference for building similar sinks    |

---

## Goals

1. Provide a production-ready Loki sink
2. Demonstrate HTTP-based async integration patterns
3. Enable CI testing via Docker
4. Document patterns for users building similar sinks (Datadog, Splunk, Elastic)

---

## Acceptance Criteria

### AC1: Sink Implementation

- [ ] Create `src/fapilog/plugins/sinks/contrib/loki.py`
- [ ] Implement full `BaseSink` protocol with lifecycle methods
- [ ] Support `write_serialized()` fast path
- [ ] Use httpx for async HTTP
- [ ] Support Loki's push API format
- [ ] Add label support for log streams

### AC2: Configuration

- [ ] `LokiSinkConfig` dataclass with all options
- [ ] Environment variable support (`FAPILOG_LOKI_*`)
- [ ] Settings integration in `sink_config.loki`
- [ ] Support for basic auth and bearer token auth

Configuration options:

```python
@dataclass
class LokiSinkConfig:
    url: str                          # Required (e.g., http://localhost:3100)
    tenant_id: str | None             # For multi-tenant Loki
    labels: dict[str, str]            # Static labels for all logs
    label_keys: list[str]             # Event keys to use as labels
    batch_size: int                   # Default: 100
    batch_timeout_seconds: float      # Default: 5.0
    timeout_seconds: float            # HTTP timeout
    auth_username: str | None         # Basic auth
    auth_password: str | None         # Basic auth
    auth_token: str | None            # Bearer token
```

### AC3: Docker/CI Testing

- [ ] Integration tests run against Loki in Docker
- [ ] GitHub Actions workflow with Loki service
- [ ] Tests cover: push, batching, labels, auth, error handling
- [ ] Tests verify payload format matches Loki API spec
- [ ] Local testing guide with docker-compose

### AC4: Loki API Compliance

- [ ] Implement `/loki/api/v1/push` endpoint format
- [ ] Correct timestamp format (nanoseconds)
- [ ] Support structured metadata (Loki 2.9+)
- [ ] Proper label formatting (no special characters)
- [ ] Handle Loki response codes correctly

### AC5: Error Handling

- [ ] Handle 429 (rate limit) with backoff
- [ ] Handle 400 (bad request) with diagnostic
- [ ] Handle 401/403 (auth) with clear message
- [ ] Handle connection errors gracefully
- [ ] Circuit breaker integration

### AC6: Registration and Discovery

- [ ] Register as `loki` in `fapilog.sinks` entry point group
- [ ] Loadable via `core.sinks = ["loki"]`
- [ ] `PLUGIN_METADATA` with correct metadata
- [ ] No additional dependencies (uses httpx from core)

### AC7: Documentation

- [ ] `docs/plugins/sinks/loki.md` - Full reference documentation
- [ ] Configuration examples (env vars, Settings object, programmatic)
- [ ] Label best practices
- [ ] Grafana dashboard integration tips
- [ ] Link to "Building HTTP-based Sinks" pattern guide

### AC8: Example Updates

- [ ] Add `examples/loki_logging/` with complete working example
- [ ] Example includes: FastAPI app, Docker Compose with Loki+Grafana, README
- [ ] Grafana dashboard JSON for fapilog logs

---

## Technical Design

### 1. Loki Push API Format

```json
POST /loki/api/v1/push
{
  "streams": [
    {
      "stream": {
        "service": "myapp",
        "level": "info",
        "env": "production"
      },
      "values": [
        ["1609459200000000000", "log line 1"],
        ["1609459200000000001", "log line 2"]
      ]
    }
  ]
}
```

Key points:

- Timestamps are nanoseconds since epoch (string)
- Labels are key-value pairs (stream selectors)
- Values are `[timestamp, log_line]` tuples
- Logs with same labels should be batched together

### 2. Implementation

```python
# src/fapilog/plugins/sinks/contrib/loki.py

from __future__ import annotations

import asyncio
import json
import time
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any

import httpx

from ....core import diagnostics
from ....core.circuit_breaker import SinkCircuitBreaker, SinkCircuitBreakerConfig
from ....core.serialization import SerializedView


@dataclass
class LokiSinkConfig:
    """Configuration for Grafana Loki sink."""

    url: str = field(default_factory=lambda: os.getenv("FAPILOG_LOKI__URL", "http://localhost:3100"))
    tenant_id: str | None = field(default_factory=lambda: os.getenv("FAPILOG_LOKI__TENANT_ID"))

    # Labels
    labels: dict[str, str] = field(default_factory=lambda: {"service": "fapilog"})
    label_keys: list[str] = field(default_factory=lambda: ["level"])

    # Batching
    batch_size: int = 100
    batch_timeout_seconds: float = 5.0

    # HTTP settings
    timeout_seconds: float = 10.0
    max_retries: int = 3
    retry_base_delay: float = 0.5

    # Authentication
    auth_username: str | None = field(default_factory=lambda: os.getenv("FAPILOG_LOKI__AUTH_USERNAME"))
    auth_password: str | None = field(default_factory=lambda: os.getenv("FAPILOG_LOKI__AUTH_PASSWORD"))
    auth_token: str | None = field(default_factory=lambda: os.getenv("FAPILOG_LOKI__AUTH_TOKEN"))

    # Circuit breaker
    circuit_breaker_enabled: bool = True
    circuit_breaker_threshold: int = 5


class LokiSink:
    """Grafana Loki sink with batching and retry.

    This sink demonstrates the HTTP-based integration pattern:
    - Uses httpx.AsyncClient for non-blocking HTTP
    - Supports multiple authentication methods
    - Batches logs by label combination for efficiency
    - Handles rate limiting with backoff

    Use this as a reference for building sinks for other HTTP-based
    providers (Datadog, Splunk HEC, Elasticsearch, etc.).
    """

    name = "loki"

    def __init__(
        self,
        config: LokiSinkConfig | None = None,
        **kwargs: Any,
    ) -> None:
        if config is None:
            config = LokiSinkConfig(**kwargs)

        self._config = config
        self._client: httpx.AsyncClient | None = None
        self._batch: list[dict[str, Any]] = []
        self._batch_lock = asyncio.Lock()
        self._flush_task: asyncio.Task[None] | None = None
        self._circuit_breaker: SinkCircuitBreaker | None = None

        # Build push URL
        base = config.url.rstrip("/")
        self._push_url = f"{base}/loki/api/v1/push"

    async def start(self) -> None:
        # Build headers
        headers: dict[str, str] = {
            "Content-Type": "application/json",
        }

        if self._config.tenant_id:
            headers["X-Scope-OrgID"] = self._config.tenant_id

        if self._config.auth_token:
            headers["Authorization"] = f"Bearer {self._config.auth_token}"

        # Build auth
        auth = None
        if self._config.auth_username and self._config.auth_password:
            auth = httpx.BasicAuth(
                self._config.auth_username,
                self._config.auth_password,
            )

        self._client = httpx.AsyncClient(
            timeout=self._config.timeout_seconds,
            headers=headers,
            auth=auth,
        )

        if self._config.circuit_breaker_enabled:
            self._circuit_breaker = SinkCircuitBreaker(
                self.name,
                SinkCircuitBreakerConfig(
                    enabled=True,
                    failure_threshold=self._config.circuit_breaker_threshold,
                ),
            )

        # Start background flush task
        if self._config.batch_size > 1:
            self._flush_task = asyncio.create_task(self._flush_loop())

    async def stop(self) -> None:
        if self._flush_task:
            self._flush_task.cancel()
            try:
                await self._flush_task
            except asyncio.CancelledError:
                pass
            self._flush_task = None

        # Final flush
        await self._flush_batch()

        if self._client:
            await self._client.aclose()
            self._client = None

    async def write(self, entry: dict[str, Any]) -> None:
        if self._config.batch_size <= 1:
            await self._send_single(entry)
            return

        flush_now = False
        async with self._batch_lock:
            self._batch.append(entry)
            if len(self._batch) >= self._config.batch_size:
                flush_now = True

        if flush_now:
            await self._flush_batch()

    async def write_serialized(self, view: SerializedView) -> None:
        """Fast path for pre-serialized payloads."""
        try:
            # Loki expects the log line as a string
            # SerializedView is JSON, use directly as the log line
            log_line = bytes(view.data).decode("utf-8")
            # Create minimal entry with pre-serialized content
            entry = {"_raw": log_line, "level": "INFO"}
            await self.write(entry)
        except Exception:
            pass  # Contain errors

    async def _flush_loop(self) -> None:
        """Background flush based on timeout."""
        try:
            while True:
                await asyncio.sleep(self._config.batch_timeout_seconds)
                if self._batch:
                    await self._flush_batch()
        except asyncio.CancelledError:
            return

    async def _flush_batch(self) -> None:
        async with self._batch_lock:
            if not self._batch:
                return
            batch = self._batch[:]
            self._batch = []

        await self._send_batch(batch)

    async def _send_single(self, entry: dict[str, Any]) -> None:
        """Send a single entry immediately."""
        await self._send_batch([entry])

    async def _send_batch(self, entries: list[dict[str, Any]]) -> None:
        if not self._client:
            return

        if self._circuit_breaker and not self._circuit_breaker.should_allow():
            diagnostics.warn(
                "loki-sink",
                "circuit breaker open, dropping batch",
                batch_size=len(entries),
            )
            return

        # Group entries by labels for efficient Loki push
        streams = self._group_by_labels(entries)
        payload = {"streams": streams}

        await self._push_with_retry(payload, len(entries))

    def _group_by_labels(self, entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
        """Group entries by their label combination."""
        grouped: dict[str, list[tuple[str, str]]] = defaultdict(list)

        for entry in entries:
            # Build labels for this entry
            labels = dict(self._config.labels)
            for key in self._config.label_keys:
                if key in entry:
                    # Sanitize label value (Loki restrictions)
                    value = str(entry[key])
                    value = self._sanitize_label_value(value)
                    labels[key] = value

            # Create label key for grouping
            label_key = json.dumps(labels, sort_keys=True)

            # Format log line
            if "_raw" in entry:
                log_line = entry["_raw"]
            else:
                log_line = json.dumps(entry, default=str)

            # Timestamp in nanoseconds
            ts = entry.get("timestamp")
            if ts is None:
                ts_ns = str(int(time.time() * 1_000_000_000))
            elif isinstance(ts, (int, float)):
                ts_ns = str(int(ts * 1_000_000_000))
            else:
                ts_ns = str(int(time.time() * 1_000_000_000))

            grouped[label_key].append((ts_ns, log_line))

        # Build streams list
        streams = []
        for label_key, values in grouped.items():
            labels = json.loads(label_key)
            streams.append({
                "stream": labels,
                "values": values,
            })

        return streams

    def _sanitize_label_value(self, value: str) -> str:
        """Sanitize label value for Loki compatibility."""
        # Loki label values: alphanumeric, underscore, hyphen
        import re
        sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', value)
        return sanitized[:128]  # Limit length

    async def _push_with_retry(self, payload: dict[str, Any], entry_count: int) -> None:
        """Push to Loki with retry and backoff."""
        for attempt in range(self._config.max_retries):
            try:
                response = await self._client.post(
                    self._push_url,
                    json=payload,
                )

                if response.status_code == 204:
                    # Success
                    if self._circuit_breaker:
                        self._circuit_breaker.record_success()
                    return

                if response.status_code == 429:
                    # Rate limited - backoff and retry
                    delay = self._config.retry_base_delay * (2 ** attempt)
                    retry_after = response.headers.get("Retry-After")
                    if retry_after:
                        try:
                            delay = float(retry_after)
                        except ValueError:
                            pass

                    diagnostics.warn(
                        "loki-sink",
                        "rate limited, backing off",
                        delay=delay,
                        attempt=attempt + 1,
                    )
                    await asyncio.sleep(delay)
                    continue

                if response.status_code in (400, 401, 403):
                    # Client error - don't retry
                    if self._circuit_breaker:
                        self._circuit_breaker.record_failure()

                    diagnostics.warn(
                        "loki-sink",
                        "client error",
                        status_code=response.status_code,
                        response=response.text[:200],
                        entry_count=entry_count,
                    )
                    return

                # Other errors - retry
                if self._circuit_breaker:
                    self._circuit_breaker.record_failure()

                diagnostics.warn(
                    "loki-sink",
                    "push failed",
                    status_code=response.status_code,
                    attempt=attempt + 1,
                )

                if attempt < self._config.max_retries - 1:
                    delay = self._config.retry_base_delay * (2 ** attempt)
                    await asyncio.sleep(delay)

            except httpx.TimeoutException:
                if self._circuit_breaker:
                    self._circuit_breaker.record_failure()

                diagnostics.warn(
                    "loki-sink",
                    "timeout",
                    attempt=attempt + 1,
                    _rate_limit_key="loki-timeout",
                )

                if attempt < self._config.max_retries - 1:
                    await asyncio.sleep(self._config.retry_base_delay)

            except Exception as exc:
                if self._circuit_breaker:
                    self._circuit_breaker.record_failure()

                diagnostics.warn(
                    "loki-sink",
                    "exception",
                    error=str(exc),
                    attempt=attempt + 1,
                    _rate_limit_key="loki-error",
                )

                if attempt < self._config.max_retries - 1:
                    await asyncio.sleep(self._config.retry_base_delay)

    async def health_check(self) -> bool:
        if not self._client:
            return False

        if self._circuit_breaker and self._circuit_breaker.is_open:
            return False

        try:
            # Loki ready endpoint
            base = self._config.url.rstrip("/")
            response = await self._client.get(f"{base}/ready")
            return response.status_code == 200
        except Exception:
            return False


PLUGIN_METADATA = {
    "name": "loki",
    "version": "1.0.0",
    "plugin_type": "sink",
    "entry_point": "fapilog.plugins.sinks.contrib.loki:LokiSink",
    "description": "Grafana Loki sink with batching and label support.",
    "author": "Fapilog Core",
    "compatibility": {"min_fapilog_version": "0.4.0"},
    "api_version": "1.0",
    "dependencies": [],  # Uses httpx from core
}
```

### 3. Settings Integration

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

class LokiSinkSettings(BaseModel):
    url: str = Field(default="http://localhost:3100")
    tenant_id: str | None = Field(default=None)
    labels: dict[str, str] = Field(default_factory=lambda: {"service": "fapilog"})
    label_keys: list[str] = Field(default_factory=lambda: ["level"])
    batch_size: int = Field(default=100, ge=1)
    batch_timeout_seconds: float = Field(default=5.0, gt=0)
    timeout_seconds: float = Field(default=10.0, gt=0)
    auth_username: str | None = Field(default=None)
    auth_password: str | None = Field(default=None)
    auth_token: str | None = Field(default=None)

class SinkConfigSettings(BaseModel):
    # ... existing ...
    loki: LokiSinkSettings = Field(default_factory=LokiSinkSettings)
```

### 4. Docker CI Testing

```yaml
# .github/workflows/test-loki-sink.yml
name: Loki Sink Tests

on:
  push:
    paths:
      - "src/fapilog/plugins/sinks/contrib/loki.py"
      - "tests/integration/test_loki_*.py"
  pull_request:
    paths:
      - "src/fapilog/plugins/sinks/contrib/loki.py"
      - "tests/integration/test_loki_*.py"

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      loki:
        image: grafana/loki:latest
        ports:
          - 3100:3100
        options: >-
          --health-cmd "wget --quiet --tries=1 --output-document=/dev/null http://localhost:3100/ready || exit 1"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: |
          pip install -e ".[test]"

      - name: Wait for Loki
        run: |
          timeout 60 bash -c 'until curl -s http://localhost:3100/ready | grep -q "ready"; do sleep 2; done'

      - name: Run Loki tests
        run: |
          pytest tests/integration/test_loki_sink.py -v
        env:
          LOKI_URL: http://localhost:3100
```

### 5. Test Implementation

```python
# tests/integration/test_loki_sink.py

import os
import time
import pytest
import httpx

LOKI_URL = os.getenv("LOKI_URL", "http://localhost:3100")


@pytest.fixture
async def loki_client():
    async with httpx.AsyncClient() as client:
        yield client


class TestLokiSink:
    async def test_sends_logs_successfully(self, loki_client):
        from fapilog.plugins.sinks.contrib.loki import LokiSink, LokiSinkConfig

        config = LokiSinkConfig(
            url=LOKI_URL,
            labels={"service": "fapilog-test", "env": "test"},
            batch_size=1,
        )
        sink = LokiSink(config)
        await sink.start()

        test_message = f"test-{int(time.time())}"
        await sink.write({"level": "INFO", "message": test_message})
        await sink.stop()

        # Query Loki to verify
        await asyncio.sleep(1)  # Give Loki time to index

        query_url = f"{LOKI_URL}/loki/api/v1/query"
        response = await loki_client.get(
            query_url,
            params={"query": '{service="fapilog-test"}'},
        )

        assert response.status_code == 200
        data = response.json()

        # Verify log was ingested
        assert "data" in data
        # Note: Exact verification depends on Loki query response format

    async def test_batches_by_labels(self):
        from fapilog.plugins.sinks.contrib.loki import LokiSink, LokiSinkConfig

        config = LokiSinkConfig(
            url=LOKI_URL,
            labels={"service": "fapilog-test"},
            label_keys=["level"],
            batch_size=10,
        )
        sink = LokiSink(config)
        await sink.start()

        # Send logs with different levels
        for i in range(5):
            await sink.write({"level": "INFO", "message": f"info {i}"})
            await sink.write({"level": "ERROR", "message": f"error {i}"})

        await sink.stop()
        # Logs should be grouped into 2 streams (INFO and ERROR)

    async def test_health_check(self):
        from fapilog.plugins.sinks.contrib.loki import LokiSink, LokiSinkConfig

        config = LokiSinkConfig(url=LOKI_URL)
        sink = LokiSink(config)
        await sink.start()

        assert await sink.health_check() is True

        await sink.stop()

    async def test_handles_connection_error_gracefully(self):
        from fapilog.plugins.sinks.contrib.loki import LokiSink, LokiSinkConfig

        config = LokiSinkConfig(
            url="http://nonexistent:3100",
            batch_size=1,
            max_retries=1,
            timeout_seconds=1,
        )
        sink = LokiSink(config)
        await sink.start()

        # Should not raise
        await sink.write({"level": "INFO", "message": "test"})
        await sink.stop()

    async def test_label_sanitization(self):
        from fapilog.plugins.sinks.contrib.loki import LokiSink, LokiSinkConfig

        config = LokiSinkConfig(
            url=LOKI_URL,
            labels={"service": "test"},
            label_keys=["user_input"],
            batch_size=1,
        )
        sink = LokiSink(config)
        await sink.start()

        # Label value with special characters should be sanitized
        await sink.write({
            "level": "INFO",
            "message": "test",
            "user_input": "hello@world.com!",  # Should become hello_world_com_
        })
        await sink.stop()
```

---

## Documentation

### docs/plugins/sinks/loki.md

````markdown
# Grafana Loki Sink

Send structured logs to Grafana Loki with automatic batching,
label support, and retry.

## Quick Start

```python
import fapilog

# Configure via environment
# FAPILOG_LOKI__URL=http://localhost:3100

with fapilog.runtime() as logger:
    logger.info("Hello Loki!")
```
````

## Configuration

### Environment Variables

| Variable                      | Description         | Default                 |
| ----------------------------- | ------------------- | ----------------------- |
| `FAPILOG_LOKI__URL`           | Loki push URL       | `http://localhost:3100` |
| `FAPILOG_LOKI__TENANT_ID`     | Multi-tenant org ID | None                    |
| `FAPILOG_LOKI__AUTH_TOKEN`    | Bearer token        | None                    |
| `FAPILOG_LOKI__AUTH_USERNAME` | Basic auth username | None                    |
| `FAPILOG_LOKI__AUTH_PASSWORD` | Basic auth password | None                    |

### Labels

Labels are key-value pairs that identify log streams in Loki.

**Static labels** (same for all logs):

```python
config = LokiSinkConfig(
    labels={"service": "myapp", "env": "production"}
)
```

**Dynamic labels from events**:

```python
config = LokiSinkConfig(
    label_keys=["level", "component"]  # Extract from each log event
)
```

### Label Best Practices

1. **Keep cardinality low** - Don't use high-cardinality values as labels

   - ✅ Good: `level`, `service`, `env`
   - ❌ Bad: `user_id`, `request_id`, `timestamp`

2. **Use static labels for filtering** - Dynamic labels from events
   should be limited

3. **Label values are sanitized** - Special characters become underscores

## Local Development

Run Loki locally with Docker:

```bash
docker run -d -p 3100:3100 grafana/loki:latest
```

Test the connection:

```bash
curl http://localhost:3100/ready
# Should return "ready"
```

## Grafana Integration

Query fapilog logs in Grafana:

```
{service="myapp"} |= "error"
```

### Example Dashboard

See `examples/loki_logging/dashboard.json` for a pre-built
Grafana dashboard for fapilog logs.

## Authentication

### Bearer Token (Grafana Cloud)

```bash
export FAPILOG_LOKI__URL=https://logs-prod-us-central1.grafana.net
export FAPILOG_LOKI__TENANT_ID=123456
export FAPILOG_LOKI__AUTH_TOKEN=glc_xxx
```

### Basic Auth

```bash
export FAPILOG_LOKI__AUTH_USERNAME=admin
export FAPILOG_LOKI__AUTH_PASSWORD=secret
```

## Building Similar Sinks

This sink demonstrates the **HTTP-based integration pattern**:

1. Use `httpx.AsyncClient` for non-blocking HTTP
2. Build authentication headers at startup
3. Batch requests for efficiency
4. Handle rate limiting (429) with backoff
5. Sanitize user input before using in API calls

See [Building HTTP-Based Sinks](../patterns/http-sinks.md) for a detailed guide.

```

---

## Example Application

### examples/loki_logging/

```

examples/loki_logging/
├── README.md
├── docker-compose.yml # Loki + Grafana
├── main.py # FastAPI app
├── loki-config.yml # Loki configuration
├── dashboard.json # Grafana dashboard
└── requirements.txt

````

**docker-compose.yml:**
```yaml
version: '3.8'
services:
  loki:
    image: grafana/loki:latest
    ports:
      - "3100:3100"
    volumes:
      - ./loki-config.yml:/etc/loki/local-config.yaml
    command: -config.file=/etc/loki/local-config.yaml

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - ./provisioning:/etc/grafana/provisioning

  app:
    build: .
    ports:
      - "8000:8000"
    environment:
      - FAPILOG_LOKI__URL=http://loki:3100
    depends_on:
      - loki
````

**README.md:**

````markdown
# Loki Logging Example

This example demonstrates fapilog logging to Grafana Loki
with visualization in Grafana.

## Quick Start

```bash
docker-compose up -d
```
````

Then:

- App: http://localhost:8000
- Grafana: http://localhost:3000 (pre-configured Loki datasource)

## Generate Logs

```bash
curl http://localhost:8000/
curl http://localhost:8000/error
```

## View in Grafana

1. Open http://localhost:3000
2. Go to Explore
3. Select Loki datasource
4. Query: `{service="fapilog-example"}`

```

---

## Migration Notes

- This is a new sink, no migration needed
- Users can remove custom Loki implementations and use the official one

---

## Success Metrics

- [ ] All Docker integration tests pass
- [ ] Documentation complete with Grafana examples
- [ ] Example application works with docker-compose
- [ ] Labels correctly group log streams
- [ ] Health check correctly reports status

---

## Related Stories

- Story 5.5: Cloud Sink Examples (parent epic)
- Story 5.14: AWS CloudWatch Sink (SDK-based pattern counterpart)
- Story 5.13: SizeGuardProcessor (recommended for large payloads)

```
