orchestrata_ultimate.orchestrator.intent_metrics

Reference › orchestrata_ultimate › orchestrator › intent_metrics

The monitoring layer for intent processing: counters, gauges and latency histograms, kept in memory and optionally persisted to Redis. intent_broker takes an IntentMetricsCollector as an optional collaborator. The module depends on nothing internal but the traceability decorator.

The data model is MetricType (counter, gauge, histogram), Metric, MetricValue, HistogramBucket and IntentMetricEvent. The collector's constructor takes an optional async Redis client, a retention period (30 days) and a key prefix (intent_metrics). _init_metrics registers intents_total, intents_failed_total, the gauge intents_in_progress, and histograms for total, parsing, validation and routing latency in milliseconds. record_intent_processed labels a counter and the histograms by action, target, requester and status, adds the failure counter with an error type when the status is failed, keeps the event in a buffer of the last 10,000 events, persists it, and checks alerts. Queries include get_metrics_summary, get_metrics_by_dimension over a time window, get_aggregated_metrics, and percentile helpers. export_prometheus_format and export_json produce output for scraping. set_alert_threshold registers a callback, get_active_alerts and clear_alerts manage fired alerts, and cleanup_old_metrics applies retention. MetricsMiddleware.wrap_intent_processing times a processing call and records the result.

At a glance

Source src/orchestrata_ultimate/orchestrator/intent_metrics.py
Lines of code 673
Classes 7
Functions 0
Imports (internal) orchestrata_ultimate.core.base.traces
Imported by orchestrata_ultimate.orchestrator.intent_broker
Tests that import it tests/generated_orchestrator/test_intent_metrics_generated.py, tests/orchestrator/test_intent_metrics_behavioral.py, tests/orchestrator/test_intent_metrics_extended.py

Class diagram

Class diagram orchestrata_ultimate.orchestrator.intent_metrics

Dependency diagram

Dependency diagram orchestrata_ultimate.orchestrator.intent_metrics

API

Classes

MetricType (enum)

Bases: Enum · Decorators: traces(spec='003', story='US-009', reqs=['REQ-003-US-009-014']) · Line 33

Type of metric.

Member Value
COUNTER 'counter'
GAUGE 'gauge'
HISTOGRAM 'histogram'
SUMMARY 'summary'

MetricValue (dataclass)

Decorators: traces(spec='003', story='US-009', reqs=['REQ-003-US-009-014']), dataclass · Line 44

A single metric value with timestamp.

Attribute Type Default
value float
timestamp datetime field(default_factory=datetime.now)
labels dict[str, str] field(default_factory=dict)

HistogramBucket (dataclass)

Decorators: traces(spec='003', story='US-009', reqs=['REQ-003-US-009-014']), dataclass · Line 54

Histogram bucket for latency distribution.

Attribute Type Default
upper_bound float
count int 0

Metric (dataclass)

Decorators: traces(spec='003', story='US-009', reqs=['REQ-003-US-009-014']), dataclass · Line 63

Metric definition and values.

Attribute Type Default
name str
type MetricType
description str
values list[MetricValue] field(default_factory=list)
buckets list[HistogramBucket] | None None
sum_value float 0.0
count_value int 0

IntentMetricEvent (dataclass)

Decorators: traces(spec='003', story='US-009', reqs=['REQ-003-US-009-012']), dataclass · Line 77

Metric event for an intent processing.

Attribute Type Default
intent_id str
action str
target str
requester str
status str
latency_ms float
parsing_ms float 0.0
validation_ms float 0.0
routing_ms float 0.0
timestamp datetime field(default_factory=datetime.now)
error_type str | None None

IntentMetricsCollector

Decorators: traces(spec='003', story='US-009', reqs=['REQ-003-US-009-011']) · Line 94

Collector for intent processing metrics.

Method Signature Summary
__init__ __init__(redis_client: redis.asyncio.Redis | None = None, retention_days: int = 30, key_prefix: str = 'intent_metrics') Initialize the metrics collector.
_init_metrics _init_metrics() -> None Initialize all metrics.
record_intent_processed record_intent_processed(intent_id: str, action: str, target: str, requester: str, status: str, latency_ms: float, parsing_ms: float = 0.0, validation_ms: float = 0.0, routing_ms: float = 0.0, error_type: str | None = None) -> None Record metrics for a processed intent.
_increment_counter _increment_counter(metric_name: str, labels: dict[str, str]) -> None Increment a counter metric.
_observe_histogram _observe_histogram(metric_name: str, value: float, labels: dict[str, str]) -> None Observe a value in a histogram.
_build_event_data _build_event_data(event: IntentMetricEvent) -> dict Build serializable dict from event.
_persist_event async _persist_event(event: IntentMetricEvent) -> None Persist event to Redis asynchronously.
_persist_event_sync _persist_event_sync(event: IntentMetricEvent) -> None Persist event to Redis synchronously.
get_metrics_summary get_metrics_summary() -> dict[str, Any] Get summary of all metrics.
get_metrics_by_dimension get_metrics_by_dimension(dimension: str, time_window_minutes: int = 60) -> dict[str, dict[str, Any]] Get metrics broken down by dimension.
_get_gauge_value _get_gauge_value(metric_name: str) -> float Get current gauge value.
_get_percentile _get_percentile(metric_name: str, percentile: float) -> float Get percentile value for a histogram metric.
_calculate_percentile _calculate_percentile(values: list[float], percentile: float) -> float Calculate percentile from values using linear interpolation.
get_aggregated_metrics get_aggregated_metrics(window_minutes: int = 5) -> list[dict[str, Any]] Get time-windowed aggregated metrics.
export_prometheus_format export_prometheus_format() -> str Export metrics in Prometheus exposition format.
export_json export_json() -> dict[str, Any] Export metrics as JSON.
set_alert_threshold set_alert_threshold(metric_name: str, threshold: float, callback: Callable[[str, float], None]) -> None Set alert threshold for a metric.
_check_alerts _check_alerts(latency_ms: float, status: str) -> None Check if any alert thresholds are breached.
get_active_alerts get_active_alerts() -> list[dict[str, Any]] Get list of active alerts.
clear_alerts clear_alerts() -> None Clear all alerts.
cleanup_old_metrics async cleanup_old_metrics() -> None Clean up metrics older than retention period.

MetricsMiddleware

Decorators: traces(spec='003', story='US-009', reqs=['REQ-003-US-009-012']) · Line 599

Middleware for automatic metrics collection.

Method Signature Summary
__init__ __init__(collector: IntentMetricsCollector) Initialize middleware.
wrap_intent_processing async wrap_intent_processing(intent_id: str, action: str, target: str, requester: str, process_func: Callable, **args, ****kwargs) -> Any Wrap intent processing with metrics collection.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/orchestrator/intent_metrics.py

# mypy: ignore-errors
"""Intent Metrics and Monitoring System.

This module provides comprehensive metrics collection, aggregation, and export
for the Intent Broker system with Prometheus and Grafana integration.

@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-001", "REQ-003-US-009-003", "REQ-003-US-009-004", "REQ-003-US-009-005", "REQ-003-US-009-006", "REQ-003-US-009-010", "REQ-003-US-009-013", "REQ-003-US-009-015", "REQ-003-US-009-017", "REQ-003-US-009-020", "REQ-003-US-009-022", "REQ-003-US-009-025", "REQ-003-US-009-026", "REQ-003-US-009-027", "REQ-003-US-009-028", "REQ-003-US-009-029", "REQ-003-US-009-030", "REQ-003-US-009-031", "REQ-003-US-009-032", "REQ-003-US-009-033", "REQ-003-US-009-035"])
"""

from __future__ import annotations

import asyncio
import inspect
import json
import logging
import time
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from typing import TYPE_CHECKING, Any

from orchestrata_ultimate.core.base.traces import traces

if TYPE_CHECKING:
    import redis.asyncio

_logger = logging.getLogger(__name__)


@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-014"])
class MetricType(Enum):
    """Type of metric."""

    COUNTER = "counter"
    GAUGE = "gauge"
    HISTOGRAM = "histogram"
    SUMMARY = "summary"


@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-014"])
@dataclass
class MetricValue:
    """A single metric value with timestamp."""

    value: float
    timestamp: datetime = field(default_factory=datetime.now)
    labels: dict[str, str] = field(default_factory=dict)


@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-014"])
@dataclass
class HistogramBucket:
    """Histogram bucket for latency distribution."""

    upper_bound: float
    count: int = 0


@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-014"])
@dataclass
class Metric:
    """Metric definition and values."""

    name: str
    type: MetricType
    description: str
    values: list[MetricValue] = field(default_factory=list)
    buckets: list[HistogramBucket] | None = None  # For histograms
    sum_value: float = 0.0  # For histograms/summaries
    count_value: int = 0  # For histograms/summaries


@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-012"])
@dataclass
class IntentMetricEvent:
    """Metric event for an intent processing."""

    intent_id: str
    action: str
    target: str
    requester: str
    status: str
    latency_ms: float
    parsing_ms: float = 0.0
    validation_ms: float = 0.0
    routing_ms: float = 0.0
    timestamp: datetime = field(default_factory=datetime.now)
    error_type: str | None = None


@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-011"])
class IntentMetricsCollector:
    """Collector for intent processing metrics.

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-011"])
    """

    # Latency buckets in milliseconds
    LATENCY_BUCKETS = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]

    def __init__(
        self,
        redis_client: redis.asyncio.Redis | None = None,
        retention_days: int = 30,
        key_prefix: str = "intent_metrics",
    ):
        """Initialize the metrics collector.

        Args:
            redis_client: Optional Redis client for persistence
            retention_days: Number of days to retain metrics
            key_prefix: Redis key prefix for isolation
        """
        self.redis = redis_client
        self.retention_days = retention_days
        self.key_prefix = key_prefix

        # Initialize metrics
        self._metrics: dict[str, Metric] = {}
        self._init_metrics()

        # In-memory event buffer (last 10000 events)
        self._events: list[IntentMetricEvent] = []
        self._max_events = 10000

        # Aggregation windows
        self._aggregation_windows: dict[str, list[tuple[datetime, dict]]] = defaultdict(
            list
        )

        # Alert thresholds
        self._alert_thresholds: dict[str, tuple[float, Callable]] = {}
        self._alerts: list[dict[str, Any]] = []

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-014"])
    def _init_metrics(self) -> None:
        """Initialize all metrics."""
        # Counter metrics
        self._metrics["intents_total"] = Metric(
            name="intents_total",
            type=MetricType.COUNTER,
            description="Total number of intents processed",
        )

        self._metrics["intents_failed_total"] = Metric(
            name="intents_failed_total",
            type=MetricType.COUNTER,
            description="Total number of failed intents",
        )

        # Gauge metrics
        self._metrics["intents_in_progress"] = Metric(
            name="intents_in_progress",
            type=MetricType.GAUGE,
            description="Number of intents currently being processed",
        )

        # Histogram metrics
        self._metrics["intent_latency_ms"] = Metric(
            name="intent_latency_ms",
            type=MetricType.HISTOGRAM,
            description="Intent processing latency in milliseconds",
            buckets=[HistogramBucket(b) for b in self.LATENCY_BUCKETS],
        )

        self._metrics["intent_parsing_ms"] = Metric(
            name="intent_parsing_ms",
            type=MetricType.HISTOGRAM,
            description="Intent parsing latency in milliseconds",
            buckets=[HistogramBucket(b) for b in self.LATENCY_BUCKETS],
        )

        self._metrics["intent_validation_ms"] = Metric(
            name="intent_validation_ms",
            type=MetricType.HISTOGRAM,
            description="Intent validation latency in milliseconds",
            buckets=[HistogramBucket(b) for b in self.LATENCY_BUCKETS],
        )

        self._metrics["intent_routing_ms"] = Metric(
            name="intent_routing_ms",
            type=MetricType.HISTOGRAM,
            description="Intent routing latency in milliseconds",
            buckets=[HistogramBucket(b) for b in self.LATENCY_BUCKETS],
        )

    # Cross-story: REQ-003-US-004-009 ("Routing metrics tracked (latency, success
    # rate)") is a US-004 (Intent Routing) requirement satisfied here in
    # US-009 (Intent Monitoring and Metrics) — the routing story REQUIRES
    # metrics, the monitoring module PROVIDES them; a requirement and its
    # implementation need not share a story number. routing_ms below is
    # routing latency, and intents_total/intents_failed_total (via
    # _increment_counter) make a success rate computable — see
    # specs/003-intent/spec.md:92 (US-004) and :178 (US-009), and
    # specs/003-intent/checklists/requirements.md:319 for the requirement
    # text. Do not re-litigate this without re-reading both story sections.
    @traces(
        spec="003",
        story="US-009",
        reqs=["REQ-003-US-004-009", "REQ-003-US-009-001", "REQ-003-US-009-002"],
    )
    def record_intent_processed(
        self,
        intent_id: str,
        action: str,
        target: str,
        requester: str,
        status: str,
        latency_ms: float,
        parsing_ms: float = 0.0,
        validation_ms: float = 0.0,
        routing_ms: float = 0.0,
        error_type: str | None = None,
    ) -> None:
        """Record metrics for a processed intent.

        @traces(spec="003", story="US-009", reqs=["REQ-003-US-004-009", "REQ-003-US-009-001", "REQ-003-US-009-002"])
        """
        labels = {
            "action": action,
            "target": target,
            "requester": requester,
            "status": status,
        }

        # Record counter
        self._increment_counter("intents_total", labels)

        if status == "failed":
            fail_labels = {**labels, "error_type": error_type or "unknown"}
            self._increment_counter("intents_failed_total", fail_labels)

        # Record histograms
        self._observe_histogram("intent_latency_ms", latency_ms, labels)
        self._observe_histogram("intent_parsing_ms", parsing_ms, labels)
        self._observe_histogram("intent_validation_ms", validation_ms, labels)
        self._observe_histogram("intent_routing_ms", routing_ms, labels)

        # Store event
        event = IntentMetricEvent(
            intent_id=intent_id,
            action=action,
            target=target,
            requester=requester,
            status=status,
            latency_ms=latency_ms,
            parsing_ms=parsing_ms,
            validation_ms=validation_ms,
            routing_ms=routing_ms,
            error_type=error_type,
        )
        self._events.append(event)

        # Trim events if needed
        if len(self._events) > self._max_events:
            self._events = self._events[-self._max_events :]

        # Persist to Redis if available
        if self.redis:
            coro = self._persist_event(event)
            try:
                asyncio.create_task(coro)
            except RuntimeError:
                # No event loop — close the unawaited coroutine, persist synchronously
                coro.close()
                self._persist_event_sync(event)

        # Check alerts
        self._check_alerts(latency_ms, status)

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-012"])
    def _increment_counter(self, metric_name: str, labels: dict[str, str]) -> None:
        """Increment a counter metric."""
        metric = self._metrics[metric_name]
        metric.values.append(MetricValue(value=1, labels=labels))
        metric.count_value += 1

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-012"])
    def _observe_histogram(
        self, metric_name: str, value: float, labels: dict[str, str]
    ) -> None:
        """Observe a value in a histogram."""
        metric = self._metrics[metric_name]
        metric.values.append(MetricValue(value=value, labels=labels))
        metric.sum_value += value
        metric.count_value += 1

        # Update buckets
        if metric.buckets:
            for bucket in metric.buckets:
                if value <= bucket.upper_bound:
                    bucket.count += 1

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-014"])
    def _build_event_data(self, event: IntentMetricEvent) -> dict:
        """Build serializable dict from event."""
        return {
            "intent_id": event.intent_id,
            "action": event.action,
            "target": event.target,
            "requester": event.requester,
            "status": event.status,
            "latency_ms": event.latency_ms,
            "parsing_ms": event.parsing_ms,
            "validation_ms": event.validation_ms,
            "routing_ms": event.routing_ms,
            "timestamp": event.timestamp.isoformat(),
            "error_type": event.error_type or "",
        }

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-014"])
    async def _persist_event(self, event: IntentMetricEvent) -> None:
        """Persist event to Redis asynchronously."""
        if not self.redis:
            return

        key = f"{self.key_prefix}:events:{event.timestamp.strftime('%Y-%m-%d')}"
        data = self._build_event_data(event)

        result = self.redis.lpush(key, json.dumps(data))
        if inspect.isawaitable(result):
            await result
        result = self.redis.expire(key, 86400 * self.retention_days)
        if inspect.isawaitable(result):
            await result

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-014"])
    def _persist_event_sync(self, event: IntentMetricEvent) -> None:
        """Persist event to Redis synchronously."""
        if not self.redis:
            return

        key = f"{self.key_prefix}:events:{event.timestamp.strftime('%Y-%m-%d')}"
        data = self._build_event_data(event)

        try:
            self.redis.lpush(key, json.dumps(data))
            self.redis.expire(key, 86400 * self.retention_days)
        except Exception as exc:
            _logger.warning("Redis metrics push failed: %s", exc)

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-009"])
    def get_metrics_summary(self) -> dict[str, Any]:
        """Get summary of all metrics.

        @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-009"])
        """
        return {
            "intents_total": self._metrics["intents_total"].count_value,
            "intents_failed_total": self._metrics["intents_failed_total"].count_value,
            "intents_in_progress": self._get_gauge_value("intents_in_progress"),
            "latency_p50": self._get_percentile("intent_latency_ms", 50),
            "latency_p95": self._get_percentile("intent_latency_ms", 95),
            "latency_p99": self._get_percentile("intent_latency_ms", 99),
            "parsing_p95": self._get_percentile("intent_parsing_ms", 95),
            "validation_p95": self._get_percentile("intent_validation_ms", 95),
            "routing_p95": self._get_percentile("intent_routing_ms", 95),
        }

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-002"])
    def get_metrics_by_dimension(
        self, dimension: str, time_window_minutes: int = 60
    ) -> dict[str, dict[str, Any]]:
        """Get metrics broken down by dimension.

        @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-002"])
        """
        cutoff = datetime.now() - timedelta(minutes=time_window_minutes)

        # Group events by dimension
        grouped = defaultdict(list)
        for event in self._events:
            if event.timestamp >= cutoff:
                key = getattr(event, dimension, "unknown")
                grouped[key].append(event)

        result = {}
        for key, events in grouped.items():
            latencies = [e.latency_ms for e in events]
            failures = [e for e in events if e.status == "failed"]

            result[key] = {
                "count": len(events),
                "failure_count": len(failures),
                "failure_rate": len(failures) / len(events) if events else 0,
                "latency_p50": self._calculate_percentile(latencies, 50),
                "latency_p95": self._calculate_percentile(latencies, 95),
                "latency_p99": self._calculate_percentile(latencies, 99),
            }

        return result

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-009"])
    def _get_gauge_value(self, metric_name: str) -> float:
        """Get current gauge value."""
        metric = self._metrics[metric_name]
        if metric.values:
            return metric.values[-1].value
        return 0.0

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-009"])
    def _get_percentile(self, metric_name: str, percentile: float) -> float:
        """Get percentile value for a histogram metric."""
        metric = self._metrics[metric_name]
        values = [v.value for v in metric.values]
        return self._calculate_percentile(values, percentile)

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-001"])
    def _calculate_percentile(self, values: list[float], percentile: float) -> float:
        """Calculate percentile from values using linear interpolation."""
        if not values:
            return 0.0

        sorted_values = sorted(values)
        n = len(sorted_values)
        if n == 1:
            return sorted_values[0]
        rank = (percentile / 100) * (n - 1)
        lower = int(rank)
        upper = min(lower + 1, n - 1)
        frac = rank - lower
        return sorted_values[lower] + frac * (
            sorted_values[upper] - sorted_values[lower]
        )

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-007"])
    def get_aggregated_metrics(self, window_minutes: int = 5) -> list[dict[str, Any]]:
        """Get time-windowed aggregated metrics.

        @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-007"])
        """
        if not self._events:
            return []

        # Group events into time windows
        windows = defaultdict(list)
        for event in self._events:
            window_key = event.timestamp.replace(
                minute=(event.timestamp.minute // window_minutes) * window_minutes,
                second=0,
                microsecond=0,
            )
            windows[window_key].append(event)

        result = []
        for window_time, events in sorted(windows.items()):
            latencies = [e.latency_ms for e in events]
            failures = [e for e in events if e.status == "failed"]

            result.append(
                {
                    "window_start": window_time.isoformat(),
                    "window_end": (
                        window_time + timedelta(minutes=window_minutes)
                    ).isoformat(),
                    "intent_count": len(events),
                    "failure_count": len(failures),
                    "failure_rate": len(failures) / len(events) if events else 0,
                    "latency_avg": sum(latencies) / len(latencies) if latencies else 0,
                    "latency_p50": self._calculate_percentile(latencies, 50),
                    "latency_p95": self._calculate_percentile(latencies, 95),
                }
            )

        return result

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-016"])
    def export_prometheus_format(self) -> str:
        """Export metrics in Prometheus exposition format.

        @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-016"])
        """
        lines = []

        for metric_name, metric in self._metrics.items():
            lines.append(f"# HELP {metric_name} {metric.description}")
            lines.append(f"# TYPE {metric_name} {metric.type.value}")

            if metric.type == MetricType.HISTOGRAM:
                # Export histogram buckets. `buckets` is a general Optional
                # field on Metric (only histograms populate it in
                # _init_metrics), so guard defensively rather than assume.
                cumulative_count = 0
                for bucket in metric.buckets or []:
                    cumulative_count += bucket.count
                    lines.append(
                        f'{metric_name}_bucket{{le="{bucket.upper_bound}"}} {cumulative_count}'
                    )
                lines.append(f'{metric_name}_bucket{{le="+Inf"}} {metric.count_value}')
                lines.append(f"{metric_name}_sum {metric.sum_value}")
                lines.append(f"{metric_name}_count {metric.count_value}")
            else:
                value = metric.count_value if metric.type == MetricType.COUNTER else 0
                lines.append(f"{metric_name} {value}")

            lines.append("")  # Empty line between metrics

        return "\n".join(lines)

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-023"])
    def export_json(self) -> dict[str, Any]:
        """Export metrics as JSON.

        @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-023"])
        """
        return {
            "timestamp": datetime.now().isoformat(),
            "summary": self.get_metrics_summary(),
            "by_action": self.get_metrics_by_dimension("action"),
            "by_target": self.get_metrics_by_dimension("target"),
            "by_requester": self.get_metrics_by_dimension("requester"),
            "time_series": self.get_aggregated_metrics(),
        }

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-018"])
    def set_alert_threshold(
        self, metric_name: str, threshold: float, callback: Callable[[str, float], None]
    ) -> None:
        """Set alert threshold for a metric.

        @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-018"])
        """
        self._alert_thresholds[metric_name] = (threshold, callback)

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-018"])
    def _check_alerts(self, latency_ms: float, status: str) -> None:
        """Check if any alert thresholds are breached."""
        # Check latency threshold
        if "intent_latency_ms" in self._alert_thresholds:
            threshold, callback = self._alert_thresholds["intent_latency_ms"]
            if latency_ms > threshold:
                alert = {
                    "timestamp": datetime.now().isoformat(),
                    "metric": "intent_latency_ms",
                    "value": latency_ms,
                    "threshold": threshold,
                    "message": f"Intent latency {latency_ms}ms exceeds threshold {threshold}ms",
                }
                self._alerts.append(alert)
                callback("intent_latency_ms", latency_ms)

        # Check failure rate
        if status == "failed" and "failure_rate" in self._alert_thresholds:
            recent_events = [
                e
                for e in self._events
                if e.timestamp > datetime.now() - timedelta(minutes=5)
            ]
            if recent_events:
                failure_rate = sum(
                    1 for e in recent_events if e.status == "failed"
                ) / len(recent_events)
                threshold, callback = self._alert_thresholds["failure_rate"]
                if failure_rate > threshold:
                    alert = {
                        "timestamp": datetime.now().isoformat(),
                        "metric": "failure_rate",
                        "value": failure_rate,
                        "threshold": threshold,
                        "message": f"Intent failure rate {failure_rate:.2%} exceeds threshold {threshold:.2%}",
                    }
                    self._alerts.append(alert)
                    callback("failure_rate", failure_rate)

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-018"])
    def get_active_alerts(self) -> list[dict[str, Any]]:
        """Get list of active alerts."""
        return self._alerts[-100:]  # Return last 100 alerts

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-018"])
    def clear_alerts(self) -> None:
        """Clear all alerts."""
        self._alerts.clear()

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-019"])
    async def cleanup_old_metrics(self) -> None:
        """Clean up metrics older than retention period.

        @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-019"])
        """
        cutoff = datetime.now() - timedelta(days=self.retention_days)

        # Clean in-memory events
        self._events = [e for e in self._events if e.timestamp >= cutoff]

        # Clean metric values
        for metric in self._metrics.values():
            metric.values = [v for v in metric.values if v.timestamp >= cutoff]

        # Clean Redis if available
        if self.redis:
            # This would iterate through old keys and delete them
            pass


@traces(spec="003", story="US-009", reqs=["REQ-003-US-009-012"])
class MetricsMiddleware:
    """Middleware for automatic metrics collection.

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-012"])
    """

    def __init__(self, collector: IntentMetricsCollector):
        """Initialize middleware.

        Args:
            collector: Metrics collector instance
        """
        self.collector = collector

    @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-012"])
    async def wrap_intent_processing(
        self,
        intent_id: str,
        action: str,
        target: str,
        requester: str,
        process_func: Callable,
        *args,
        **kwargs,
    ) -> Any:
        """Wrap intent processing with metrics collection.

        @traces(spec="003", story="US-009", reqs=["REQ-003-US-009-012"])
        """
        start_time = time.time()
        parsing_start = start_time

        try:
            # Record parsing time (assumed to be before this call)
            parsing_end = time.time()

            # Execute processing
            validation_start = time.time()
            result = await process_func(*args, **kwargs)
            validation_end = time.time()

            routing_start = validation_end
            routing_end = time.time()

            end_time = time.time()

            # Record metrics
            self.collector.record_intent_processed(
                intent_id=intent_id,
                action=action,
                target=target,
                requester=requester,
                status="success",
                latency_ms=(end_time - start_time) * 1000,
                parsing_ms=(parsing_end - parsing_start) * 1000,
                validation_ms=(validation_end - validation_start) * 1000,
                routing_ms=(routing_end - routing_start) * 1000,
            )

            return result

        except Exception as e:
            end_time = time.time()

            # Record failure
            self.collector.record_intent_processed(
                intent_id=intent_id,
                action=action,
                target=target,
                requester=requester,
                status="failed",
                latency_ms=(end_time - start_time) * 1000,
                error_type=type(e).__name__,
            )
            raise