# Issue #32: メトリクス収集

**Phase:** 2c
**優先度:** 高
**依存関係:** #03 (@nodeデコレータ), #05 (pipeline関数)
**見積もり:** 3日

---

## 概要

ノードとパイプラインの実行時メトリクスを収集します。
実行時間、成功率、エラー率などを記録し、パフォーマンス分析を可能にします。

---

## TDD実装手順

### Step 1: メトリクス収集のテスト (Red)

```python
# tests/unit/metrics/test_metrics_collector.py
"""Tests for metrics collection."""
import pytest
import time


class TestMetricsCollector:
    """Test metrics collection functionality."""

    def test_collect_node_execution_time(self):
        """Should record node execution time."""
        from railway.core.decorators import node
        from railway.metrics import MetricsCollector

        collector = MetricsCollector()

        @node(metrics=collector)
        def slow_task(x: int) -> int:
            time.sleep(0.1)
            return x + 1

        result = slow_task(5)

        assert result == 6
        metrics = collector.get_metrics("slow_task")
        assert metrics["execution_count"] == 1
        assert 0.09 < metrics["avg_execution_time"] < 0.15
        assert metrics["success_count"] == 1
        assert metrics["error_count"] == 0

    def test_collect_node_errors(self):
        """Should record node errors."""
        from railway.core.decorators import node
        from railway.metrics import MetricsCollector

        collector = MetricsCollector()

        @node(metrics=collector, retry=False)
        def failing_task(x: int) -> int:
            raise ValueError("Test error")

        with pytest.raises(ValueError):
            failing_task(5)

        metrics = collector.get_metrics("failing_task")
        assert metrics["execution_count"] == 1
        assert metrics["error_count"] == 1
        assert metrics["success_rate"] == 0.0

    def test_collect_pipeline_metrics(self):
        """Should collect metrics for entire pipeline."""
        from railway.core.decorators import node
        from railway.core.pipeline import pipeline
        from railway.metrics import MetricsCollector

        collector = MetricsCollector()

        @node(metrics=collector)
        def step1(x: int) -> int:
            return x + 1

        @node(metrics=collector)
        def step2(x: int) -> int:
            return x * 2

        with collector.track_pipeline("test_pipeline"):
            result = pipeline(5, step1, step2)

        assert result == 12
        pipeline_metrics = collector.get_pipeline_metrics("test_pipeline")
        assert pipeline_metrics["execution_count"] == 1
        assert pipeline_metrics["total_steps"] == 2
        assert pipeline_metrics["success"] is True

    def test_export_metrics_json(self):
        """Should export metrics to JSON."""
        from railway.metrics import MetricsCollector
        import json

        collector = MetricsCollector()
        collector.record("test_node", execution_time=0.5, success=True)
        collector.record("test_node", execution_time=0.7, success=True)

        json_data = collector.export_json()
        data = json.loads(json_data)

        assert "nodes" in data
        assert "test_node" in data["nodes"]
        assert data["nodes"]["test_node"]["execution_count"] == 2


class TestMetricsIntegration:
    """Test metrics integration with decorators."""

    def test_node_decorator_auto_metrics(self):
        """Should automatically collect metrics when enabled."""
        from railway.core.decorators import node
        from railway.metrics import get_global_collector

        collector = get_global_collector()
        collector.clear()

        @node
        def task(x: int) -> int:
            return x + 1

        result = task(5)

        metrics = collector.get_metrics("task")
        assert metrics["execution_count"] >= 1
```

```bash
# 実行して失敗を確認 (Red)
pytest tests/unit/metrics/test_metrics_collector.py -v
# Expected: FAILED (MetricsCollector not implemented)
```

---

### Step 2: メトリクス収集の実装 (Green)

```python
# railway/metrics/__init__.py
"""Metrics collection module."""

from .collector import MetricsCollector, get_global_collector, reset_global_collector

__all__ = ["MetricsCollector", "get_global_collector", "reset_global_collector"]


# railway/metrics/collector.py
"""Metrics collector implementation."""

from dataclasses import dataclass, field
from typing import Dict, List, Optional
from datetime import datetime
import time
import json
from contextlib import contextmanager
from threading import Lock


@dataclass
class NodeMetrics:
    """Metrics for a single node."""
    execution_count: int = 0
    success_count: int = 0
    error_count: int = 0
    total_execution_time: float = 0.0
    execution_times: List[float] = field(default_factory=list)
    last_execution: Optional[datetime] = None

    @property
    def avg_execution_time(self) -> float:
        """Calculate average execution time."""
        if self.execution_count == 0:
            return 0.0
        return self.total_execution_time / self.execution_count

    @property
    def success_rate(self) -> float:
        """Calculate success rate."""
        if self.execution_count == 0:
            return 0.0
        return self.success_count / self.execution_count

    @property
    def error_rate(self) -> float:
        """Calculate error rate."""
        if self.execution_count == 0:
            return 0.0
        return self.error_count / self.execution_count


@dataclass
class PipelineMetrics:
    """Metrics for a pipeline execution."""
    execution_count: int = 0
    success_count: int = 0
    total_steps: int = 0
    avg_execution_time: float = 0.0
    last_execution: Optional[datetime] = None


class MetricsCollector:
    """
    Collect and manage execution metrics.

    Thread-safe metrics collection for nodes and pipelines.
    """

    def __init__(self):
        """Initialize metrics collector."""
        self._node_metrics: Dict[str, NodeMetrics] = {}
        self._pipeline_metrics: Dict[str, PipelineMetrics] = {}
        self._lock = Lock()

    def record(
        self,
        node_name: str,
        execution_time: float,
        success: bool,
        error: Optional[Exception] = None
    ) -> None:
        """
        Record a node execution.

        Args:
            node_name: Name of the node
            execution_time: Execution time in seconds
            success: Whether execution succeeded
            error: Exception if failed
        """
        with self._lock:
            if node_name not in self._node_metrics:
                self._node_metrics[node_name] = NodeMetrics()

            metrics = self._node_metrics[node_name]
            metrics.execution_count += 1
            metrics.total_execution_time += execution_time
            metrics.execution_times.append(execution_time)
            metrics.last_execution = datetime.now()

            if success:
                metrics.success_count += 1
            else:
                metrics.error_count += 1

    def get_metrics(self, node_name: str) -> Dict:
        """
        Get metrics for a specific node.

        Args:
            node_name: Name of the node

        Returns:
            Dictionary with metrics
        """
        with self._lock:
            if node_name not in self._node_metrics:
                return {
                    "execution_count": 0,
                    "success_count": 0,
                    "error_count": 0,
                    "avg_execution_time": 0.0,
                    "success_rate": 0.0,
                }

            metrics = self._node_metrics[node_name]
            return {
                "execution_count": metrics.execution_count,
                "success_count": metrics.success_count,
                "error_count": metrics.error_count,
                "avg_execution_time": metrics.avg_execution_time,
                "success_rate": metrics.success_rate,
                "error_rate": metrics.error_rate,
                "last_execution": metrics.last_execution.isoformat() if metrics.last_execution else None,
            }

    @contextmanager
    def track_pipeline(self, pipeline_name: str):
        """
        Context manager to track pipeline execution.

        Args:
            pipeline_name: Name of the pipeline

        Example:
            with collector.track_pipeline("my_pipeline"):
                result = pipeline(...)
        """
        start_time = time.time()
        success = False

        try:
            yield
            success = True
        finally:
            execution_time = time.time() - start_time
            self._record_pipeline(pipeline_name, execution_time, success)

    def _record_pipeline(
        self,
        pipeline_name: str,
        execution_time: float,
        success: bool
    ) -> None:
        """Record pipeline execution."""
        with self._lock:
            if pipeline_name not in self._pipeline_metrics:
                self._pipeline_metrics[pipeline_name] = PipelineMetrics()

            metrics = self._pipeline_metrics[pipeline_name]
            metrics.execution_count += 1
            if success:
                metrics.success_count += 1
            metrics.last_execution = datetime.now()

    def get_pipeline_metrics(self, pipeline_name: str) -> Dict:
        """Get metrics for a pipeline."""
        with self._lock:
            if pipeline_name not in self._pipeline_metrics:
                return {"execution_count": 0}

            metrics = self._pipeline_metrics[pipeline_name]
            return {
                "execution_count": metrics.execution_count,
                "success_count": metrics.success_count,
                "total_steps": metrics.total_steps,
                "success": metrics.success_count > 0,
                "last_execution": metrics.last_execution.isoformat() if metrics.last_execution else None,
            }

    def export_json(self) -> str:
        """
        Export all metrics as JSON.

        Returns:
            JSON string with all metrics
        """
        with self._lock:
            data = {
                "nodes": {
                    name: self.get_metrics(name)
                    for name in self._node_metrics.keys()
                },
                "pipelines": {
                    name: self.get_pipeline_metrics(name)
                    for name in self._pipeline_metrics.keys()
                },
                "timestamp": datetime.now().isoformat(),
            }
            return json.dumps(data, indent=2)

    def clear(self) -> None:
        """Clear all metrics."""
        with self._lock:
            self._node_metrics.clear()
            self._pipeline_metrics.clear()


# Global metrics collector instance
_global_collector: Optional[MetricsCollector] = None
_global_collector_lock = Lock()


def get_global_collector() -> MetricsCollector:
    """
    Get or create the global metrics collector.

    Returns:
        Global MetricsCollector instance
    """
    global _global_collector

    with _global_collector_lock:
        if _global_collector is None:
            _global_collector = MetricsCollector()
        return _global_collector


def reset_global_collector() -> None:
    """Reset the global metrics collector (for testing)."""
    global _global_collector

    with _global_collector_lock:
        _global_collector = None
```

```bash
# 実行して成功を確認 (Green)
pytest tests/unit/metrics/test_metrics_collector.py -v
# Expected: PASSED
```

---

## 完了条件

- [ ] `MetricsCollector` クラスの実装
- [ ] ノード実行メトリクス収集
- [ ] パイプラインメトリクス収集
- [ ] スレッドセーフな実装
- [ ] JSON エクスポート
- [ ] グローバルコレクター
- [ ] @node デコレータとの統合
- [ ] テスト (10テスト以上)
- [ ] テストカバレッジ 90%以上
- [ ] ドキュメント更新

---

## 収集するメトリクス

### ノードレベル
- 実行回数
- 成功回数
- エラー回数
- 実行時間 (平均、最小、最大)
- 成功率
- エラー率
- 最終実行時刻

### パイプラインレベル
- 実行回数
- 成功回数
- 総ステップ数
- 平均実行時間
- 最終実行時刻

---

## 関連Issue

- Issue #35: メトリクスエクスポート (Prometheus連携)
- Issue #33: WebUI基本実装 (メトリクス表示)

---

## 使用例

```python
from railway.core.decorators import node
from railway.metrics import get_global_collector

collector = get_global_collector()

@node
def process_data(data: dict) -> dict:
    # Processing...
    return data

# Metrics are automatically collected
metrics = collector.get_metrics("process_data")
print(f"Success rate: {metrics['success_rate']:.2%}")
print(f"Avg time: {metrics['avg_execution_time']:.3f}s")
```
