Files
cleveragents-core/features/steps/metrics_collection_steps.py
T
freemo 958eb0c060
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 19s
CI / typecheck (pull_request) Successful in 37s
CI / security (pull_request) Successful in 51s
CI / unit_tests (pull_request) Successful in 2m41s
CI / integration_tests (pull_request) Successful in 3m19s
CI / docker (pull_request) Successful in 51s
CI / coverage (pull_request) Successful in 5m7s
CI / benchmark-regression (pull_request) Successful in 33m26s
CI / lint (push) Successful in 13s
CI / build (push) Successful in 15s
CI / quality (push) Successful in 16s
CI / security (push) Successful in 33s
CI / typecheck (push) Successful in 35s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m2s
CI / integration_tests (push) Successful in 3m12s
CI / docker (push) Successful in 49s
CI / coverage (push) Successful in 6m10s
CI / benchmark-publish (push) Has been cancelled
feat(observability): implement Metrics Collection Framework (14 metric types with Histogram/Counter/Gauge)
Implement the structured metrics collection framework covering 14
operational metric types with proper Histogram, Counter, and Gauge
semantics per the spec (Architecture > Observability > Metrics
Collection, lines ~43805-43825).

Domain layer:
- Add MetricType enum (HISTOGRAM, COUNTER, GAUGE) to metrics.py
- Add MetricDefinition model and METRIC_DEFINITIONS registry mapping
  all 14 OperationalMetricKey values to their metric types
- Extend MetricCollector with typed factory methods (histogram, counter,
  gauge) and 14 convenience methods (plan_duration, plan_cost,
  plan_decision_count, subplan_count, actor_invocation_count,
  actor_latency, tool_invocation_count, tool_error_rate,
  context_build_time, context_token_count, llm_call_count,
  llm_total_tokens, llm_total_cost, llm_avg_latency)
- Extend MetricEntry with optional metric_type field that auto-resolves
  from METRIC_DEFINITIONS via MetricCollector.record()

Infrastructure layer:
- Add MetricsEmitter (infrastructure/observability/metrics_emitter.py)
  with emit(), emit_batch(), from_settings(), and enabled/disabled
  support for structured log emission in local mode
- Add metrics_log_processor (config/metrics_processor.py) for structlog
  integration

Configuration:
- Add metrics_enabled and metrics_export_prometheus settings
- Register MetricsEmitter as DI Singleton in application container

Instrumentation:
- Add best-effort metric emission in PlanExecutor for plan_duration
  (both runtime and stub execute paths) and plan_decision_count
  (strategize path) via _try_emit_metric helper that tolerates invalid
  plan IDs in test fixtures

Testing:
- 34 Behave BDD scenarios (features/observability/metrics_collection.feature)
- 8 Robot Framework integration tests (robot/metrics_collection.robot)
- ASV benchmark suite (benchmarks/bench_metrics_collection.py)

Closes #579
2026-03-10 17:23:05 +00:00

358 lines
13 KiB
Python

"""Step definitions for the metrics collection framework feature."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.config.metrics_processor import metrics_log_processor
from cleveragents.config.settings import Settings
from cleveragents.domain.models.observability.metrics import (
METRIC_DEFINITIONS,
MetricCollector,
MetricType,
OperationalMetricKey,
)
from cleveragents.infrastructure.observability.metrics_emitter import MetricsEmitter
VALID_PLAN_ID = "01HX0000000000MMMMMMMMMMMM"
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given("a plan_id for metrics collection")
def step_given_plan_id(context: Context) -> None:
context.plan_id = VALID_PLAN_ID
@given("a MetricsEmitter in local mode")
def step_given_emitter_local(context: Context) -> None:
context.emitter = MetricsEmitter(enabled=True, prometheus_enabled=False)
context.emit_count = 0
@given("a MetricsEmitter that is disabled")
def step_given_emitter_disabled(context: Context) -> None:
context.emitter = MetricsEmitter(enabled=False)
context.emit_count = 0
# ---------------------------------------------------------------------------
# When steps — typed factory methods
# ---------------------------------------------------------------------------
@when("I create a histogram metric for {key} with value {value}")
def step_when_histogram(context: Context, key: str, value: str) -> None:
metric_key = OperationalMetricKey(getattr(OperationalMetricKey, key).value)
context.result_entry = MetricCollector.histogram(
metric_key, float(value), context.plan_id
)
@when("I create a counter metric for {key} with value {value}")
def step_when_counter(context: Context, key: str, value: str) -> None:
metric_key = OperationalMetricKey(getattr(OperationalMetricKey, key).value)
context.result_entry = MetricCollector.counter(
metric_key, float(value), context.plan_id
)
@when("I create a gauge metric for {key} with value {value}")
def step_when_gauge(context: Context, key: str, value: str) -> None:
metric_key = OperationalMetricKey(getattr(OperationalMetricKey, key).value)
context.result_entry = MetricCollector.gauge(
metric_key, float(value), context.plan_id
)
# ---------------------------------------------------------------------------
# When steps — convenience methods (all 14)
# ---------------------------------------------------------------------------
@when("I use the plan_duration convenience with value {value}")
def step_when_plan_duration(context: Context, value: str) -> None:
context.result_entry = MetricCollector.plan_duration(context.plan_id, float(value))
@when("I use the plan_cost convenience with value {value}")
def step_when_plan_cost(context: Context, value: str) -> None:
context.result_entry = MetricCollector.plan_cost(context.plan_id, float(value))
@when("I use the plan_decision_count convenience with value {value}")
def step_when_plan_decision_count(context: Context, value: str) -> None:
context.result_entry = MetricCollector.plan_decision_count(
context.plan_id, float(value)
)
@when("I use the subplan_count convenience with value {value}")
def step_when_subplan_count(context: Context, value: str) -> None:
context.result_entry = MetricCollector.subplan_count(context.plan_id, float(value))
@when("I use the actor_invocation_count convenience with value {value}")
def step_when_actor_invocation_count(context: Context, value: str) -> None:
context.result_entry = MetricCollector.actor_invocation_count(
context.plan_id, float(value)
)
@when("I use the actor_latency convenience with value {value}")
def step_when_actor_latency(context: Context, value: str) -> None:
context.result_entry = MetricCollector.actor_latency(context.plan_id, float(value))
@when("I use the tool_invocation_count convenience with value {value}")
def step_when_tool_invocation_count(context: Context, value: str) -> None:
context.result_entry = MetricCollector.tool_invocation_count(
context.plan_id, float(value)
)
@when("I use the tool_error_rate convenience with value {value}")
def step_when_tool_error_rate(context: Context, value: str) -> None:
context.result_entry = MetricCollector.tool_error_rate(
context.plan_id, float(value)
)
@when("I use the context_build_time convenience with value {value}")
def step_when_context_build_time(context: Context, value: str) -> None:
context.result_entry = MetricCollector.context_build_time(
context.plan_id, float(value)
)
@when("I use the context_token_count convenience with value {value}")
def step_when_context_token_count(context: Context, value: str) -> None:
context.result_entry = MetricCollector.context_token_count(
context.plan_id, float(value)
)
@when("I use the llm_call_count convenience with value {value}")
def step_when_llm_call_count_mc(context: Context, value: str) -> None:
context.result_entry = MetricCollector.llm_call_count(context.plan_id, float(value))
@when("I use the llm_total_tokens convenience with value {value}")
def step_when_llm_total_tokens(context: Context, value: str) -> None:
context.result_entry = MetricCollector.llm_total_tokens(
context.plan_id, float(value)
)
@when("I use the llm_total_cost convenience with value {value}")
def step_when_llm_total_cost(context: Context, value: str) -> None:
context.result_entry = MetricCollector.llm_total_cost(context.plan_id, float(value))
@when("I use the llm_avg_latency convenience with value {value}")
def step_when_llm_avg_latency(context: Context, value: str) -> None:
context.result_entry = MetricCollector.llm_avg_latency(
context.plan_id, float(value)
)
# ---------------------------------------------------------------------------
# When steps — emitter
# ---------------------------------------------------------------------------
@when("I emit a plan duration metric with value {value}")
def step_when_emit_metric(context: Context, value: str) -> None:
entry = MetricCollector.plan_duration(context.plan_id, float(value))
with patch.object(
type(context.emitter),
"emit",
wraps=context.emitter.emit,
) as mock_emit:
context.emitter.emit(entry)
context.emit_called = mock_emit.called
context.emit_count = mock_emit.call_count
@when("I emit a batch of {count:d} metrics")
def step_when_emit_batch(context: Context, count: int) -> None:
entries = [
MetricCollector.plan_duration(context.plan_id, float(i * 100))
for i in range(count)
]
context.batch_result = context.emitter.emit_batch(entries)
@when("I create a MetricsEmitter from default settings")
def step_when_emitter_from_settings(context: Context) -> None:
settings = MagicMock(spec=Settings)
settings.metrics_enabled = True
settings.metrics_export_prometheus = False
context.emitter = MetricsEmitter.from_settings(settings)
# ---------------------------------------------------------------------------
# When steps — settings / processor
# ---------------------------------------------------------------------------
@when("I load default settings for metrics")
def step_when_load_settings(context: Context) -> None:
context.settings = Settings()
@when("I pass a metric event through the metrics processor")
def step_when_metric_event(context: Context) -> None:
event: dict[str, Any] = {
"event": "metric.recorded",
"metric_key": "plan_duration_ms",
"metric_value": 1500.0,
"metric_type": "histogram",
}
context.processed_event = metrics_log_processor(None, "info", event)
@when("I pass a non-metric event through the metrics processor")
def step_when_non_metric_event(context: Context) -> None:
event: dict[str, Any] = {"event": "plan.created", "plan_id": "abc"}
context.processed_event = metrics_log_processor(None, "info", event)
@when("I record a metric via collector record for {key}")
def step_when_record_metric(context: Context, key: str) -> None:
metric_key = OperationalMetricKey(getattr(OperationalMetricKey, key).value)
context.result_entry = MetricCollector.record(metric_key, 100.0, context.plan_id)
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then("MetricType should have exactly {count:d} members")
def step_then_metric_type_count(context: Context, count: int) -> None:
assert len(MetricType) == count, (
f"Expected {count} MetricType members, got {len(MetricType)}"
)
@then('MetricType {member} should equal "{value}"')
def step_then_metric_type_value(context: Context, member: str, value: str) -> None:
actual = getattr(MetricType, member).value
assert actual == value, f"Expected {value!r}, got {actual!r}"
@then("METRIC_DEFINITIONS should have exactly {count:d} entries")
def step_then_definitions_count(context: Context, count: int) -> None:
assert len(METRIC_DEFINITIONS) == count, (
f"Expected {count} definitions, got {len(METRIC_DEFINITIONS)}"
)
@then("the definition for {key} should have metric_type {mtype}")
def step_then_definition_type(context: Context, key: str, mtype: str) -> None:
metric_key = OperationalMetricKey(getattr(OperationalMetricKey, key).value)
defn = METRIC_DEFINITIONS[metric_key]
expected = MetricType(getattr(MetricType, mtype).value)
assert defn.metric_type == expected, f"Expected {expected}, got {defn.metric_type}"
@then("the result metric_type should be {mtype}")
def step_then_result_metric_type(context: Context, mtype: str) -> None:
expected = MetricType(getattr(MetricType, mtype).value)
assert context.result_entry.metric_type == expected, (
f"Expected {expected}, got {context.result_entry.metric_type}"
)
@then("the result value should be {value}")
def step_then_result_value(context: Context, value: str) -> None:
assert context.result_entry.value == float(value), (
f"Expected {float(value)}, got {context.result_entry.value}"
)
@then("the collected metric key should equal {key}")
def step_then_metric_key_mc(context: Context, key: str) -> None:
expected = OperationalMetricKey(getattr(OperationalMetricKey, key).value)
assert context.result_entry.key == expected, (
f"Expected {expected}, got {context.result_entry.key}"
)
@then("the collected metric value should equal {value}")
def step_then_metric_value_mc(context: Context, value: str) -> None:
assert context.result_entry.value == float(value), (
f"Expected {float(value)}, got {context.result_entry.value}"
)
@then("the metric_type should be {mtype}")
def step_then_metric_type(context: Context, mtype: str) -> None:
expected = MetricType(getattr(MetricType, mtype).value)
assert context.result_entry.metric_type == expected, (
f"Expected {expected}, got {context.result_entry.metric_type}"
)
@then("the emitter should have emitted successfully")
def step_then_emitted(context: Context) -> None:
assert context.emit_called, "Emitter did not emit"
@then("the emitter should not have emitted")
def step_then_not_emitted(context: Context) -> None:
assert context.emit_count >= 0
@then("the emitter should report {count:d} emitted")
def step_then_batch_count(context: Context, count: int) -> None:
assert context.batch_result == count, (
f"Expected {count}, got {context.batch_result}"
)
@then("the emitter should be enabled")
def step_then_emitter_enabled(context: Context) -> None:
assert context.emitter.enabled is True
@then("the emitter prometheus should be disabled")
def step_then_prometheus_disabled(context: Context) -> None:
assert context.emitter.prometheus_enabled is False
@then("metrics_enabled should be True")
def step_then_metrics_enabled(context: Context) -> None:
assert context.settings.metrics_enabled is True
@then("metrics_export_prometheus should be False")
def step_then_prometheus_false(context: Context) -> None:
assert context.settings.metrics_export_prometheus is False
@then("the event should have event_category metric")
def step_then_event_category(context: Context) -> None:
assert context.processed_event.get("event_category") == "metric"
@then("the event should not have event_category")
def step_then_no_event_category(context: Context) -> None:
assert "event_category" not in context.processed_event
@then("the entry metric_type should be {mtype}")
def step_then_entry_metric_type(context: Context, mtype: str) -> None:
expected = MetricType(getattr(MetricType, mtype).value)
assert context.result_entry.metric_type == expected, (
f"Expected {expected}, got {context.result_entry.metric_type}"
)