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
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
209 lines
7.2 KiB
Python
209 lines
7.2 KiB
Python
"""Helper script for metrics collection Robot Framework tests.
|
|
|
|
Usage:
|
|
python helper_metrics_collection.py <command>
|
|
|
|
Commands:
|
|
metric-type-enum Verify MetricType enum
|
|
metric-definitions Verify METRIC_DEFINITIONS registry
|
|
typed-factories Test histogram/counter/gauge factory methods
|
|
convenience-methods Test all 14 convenience methods
|
|
emitter-local Test MetricsEmitter local mode
|
|
emitter-disabled Test MetricsEmitter disabled mode
|
|
settings-config Verify settings fields
|
|
log-processor Verify structlog processor
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
|
sys.path.insert(0, _SRC)
|
|
|
|
from cleveragents.config.metrics_processor import ( # noqa: E402
|
|
metrics_log_processor,
|
|
)
|
|
from cleveragents.config.settings import Settings # noqa: E402
|
|
from cleveragents.domain.models.observability.metrics import ( # noqa: E402
|
|
METRIC_DEFINITIONS,
|
|
MetricCollector,
|
|
MetricType,
|
|
OperationalMetricKey,
|
|
)
|
|
from cleveragents.infrastructure.observability.metrics_emitter import ( # noqa: E402
|
|
MetricsEmitter,
|
|
)
|
|
|
|
VALID_PLAN_ID = "01HX0000000000MMMMMMMMMMMM"
|
|
|
|
|
|
def cmd_metric_type_enum() -> None:
|
|
"""Verify MetricType enum."""
|
|
if len(MetricType) != 3:
|
|
print(f"FAIL: expected 3 MetricType, got {len(MetricType)}")
|
|
sys.exit(1)
|
|
if MetricType.HISTOGRAM.value != "histogram":
|
|
print("FAIL: HISTOGRAM value mismatch")
|
|
sys.exit(1)
|
|
if MetricType.COUNTER.value != "counter":
|
|
print("FAIL: COUNTER value mismatch")
|
|
sys.exit(1)
|
|
if MetricType.GAUGE.value != "gauge":
|
|
print("FAIL: GAUGE value mismatch")
|
|
sys.exit(1)
|
|
print("metrics-type-enum-ok")
|
|
|
|
|
|
def cmd_metric_definitions() -> None:
|
|
"""Verify METRIC_DEFINITIONS registry."""
|
|
if len(METRIC_DEFINITIONS) != 14:
|
|
print(f"FAIL: expected 14 definitions, got {len(METRIC_DEFINITIONS)}")
|
|
sys.exit(1)
|
|
# Verify every OperationalMetricKey is present
|
|
for key in OperationalMetricKey:
|
|
if key not in METRIC_DEFINITIONS:
|
|
print(f"FAIL: missing definition for {key}")
|
|
sys.exit(1)
|
|
print("metrics-definitions-ok")
|
|
|
|
|
|
def cmd_typed_factories() -> None:
|
|
"""Test histogram/counter/gauge factory methods."""
|
|
h = MetricCollector.histogram(
|
|
OperationalMetricKey.PLAN_DURATION_MS, 1500.0, VALID_PLAN_ID
|
|
)
|
|
if h.metric_type != MetricType.HISTOGRAM:
|
|
print(f"FAIL: histogram type {h.metric_type}")
|
|
sys.exit(1)
|
|
c = MetricCollector.counter(OperationalMetricKey.LLM_CALL_COUNT, 5.0, VALID_PLAN_ID)
|
|
if c.metric_type != MetricType.COUNTER:
|
|
print(f"FAIL: counter type {c.metric_type}")
|
|
sys.exit(1)
|
|
g = MetricCollector.gauge(
|
|
OperationalMetricKey.PLAN_DECISION_COUNT, 3.0, VALID_PLAN_ID
|
|
)
|
|
if g.metric_type != MetricType.GAUGE:
|
|
print(f"FAIL: gauge type {g.metric_type}")
|
|
sys.exit(1)
|
|
print("metrics-typed-factories-ok")
|
|
|
|
|
|
def cmd_convenience_methods() -> None:
|
|
"""Test all 14 convenience methods."""
|
|
methods_and_keys: list[tuple[str, OperationalMetricKey]] = [
|
|
("plan_duration", OperationalMetricKey.PLAN_DURATION_MS),
|
|
("plan_cost", OperationalMetricKey.PLAN_TOTAL_COST_USD),
|
|
("plan_decision_count", OperationalMetricKey.PLAN_DECISION_COUNT),
|
|
("subplan_count", OperationalMetricKey.SUBPLAN_COUNT),
|
|
("actor_invocation_count", OperationalMetricKey.ACTOR_INVOCATION_COUNT),
|
|
("actor_latency", OperationalMetricKey.ACTOR_LATENCY_MS),
|
|
("tool_invocation_count", OperationalMetricKey.TOOL_INVOCATION_COUNT),
|
|
("tool_error_rate", OperationalMetricKey.TOOL_ERROR_RATE),
|
|
("context_build_time", OperationalMetricKey.CONTEXT_BUILD_TIME_MS),
|
|
("context_token_count", OperationalMetricKey.CONTEXT_TOKEN_COUNT),
|
|
("llm_call_count", OperationalMetricKey.LLM_CALL_COUNT),
|
|
("llm_total_tokens", OperationalMetricKey.LLM_TOTAL_TOKENS),
|
|
("llm_total_cost", OperationalMetricKey.LLM_TOTAL_COST_USD),
|
|
("llm_avg_latency", OperationalMetricKey.LLM_AVG_LATENCY_MS),
|
|
]
|
|
for method_name, expected_key in methods_and_keys:
|
|
fn = getattr(MetricCollector, method_name)
|
|
entry = fn(VALID_PLAN_ID, 42.0)
|
|
if entry.key != expected_key:
|
|
print(f"FAIL: {method_name} key {entry.key} != {expected_key}")
|
|
sys.exit(1)
|
|
if entry.value != 42.0:
|
|
print(f"FAIL: {method_name} value {entry.value} != 42.0")
|
|
sys.exit(1)
|
|
print("metrics-convenience-ok")
|
|
|
|
|
|
def cmd_emitter_local() -> None:
|
|
"""Test MetricsEmitter local mode."""
|
|
emitter = MetricsEmitter(enabled=True)
|
|
entry = MetricCollector.plan_duration(VALID_PLAN_ID, 1500.0)
|
|
emitter.emit(entry)
|
|
count = emitter.emit_batch([entry, entry])
|
|
if count != 2:
|
|
print(f"FAIL: batch count {count} != 2")
|
|
sys.exit(1)
|
|
print("metrics-emitter-local-ok")
|
|
|
|
|
|
def cmd_emitter_disabled() -> None:
|
|
"""Test MetricsEmitter disabled mode."""
|
|
emitter = MetricsEmitter(enabled=False)
|
|
entry = MetricCollector.plan_duration(VALID_PLAN_ID, 1500.0)
|
|
emitter.emit(entry) # should be no-op
|
|
count = emitter.emit_batch([entry, entry])
|
|
if count != 0:
|
|
print(f"FAIL: disabled batch count {count} != 0")
|
|
sys.exit(1)
|
|
print("metrics-emitter-disabled-ok")
|
|
|
|
|
|
def cmd_settings_config() -> None:
|
|
"""Verify settings fields."""
|
|
settings = Settings()
|
|
if settings.metrics_enabled is not True:
|
|
print("FAIL: metrics_enabled not True")
|
|
sys.exit(1)
|
|
if settings.metrics_export_prometheus is not False:
|
|
print("FAIL: metrics_export_prometheus not False")
|
|
sys.exit(1)
|
|
print("metrics-settings-ok")
|
|
|
|
|
|
def cmd_log_processor() -> None:
|
|
"""Verify structlog processor."""
|
|
metric_event: dict[str, Any] = {
|
|
"event": "metric.recorded",
|
|
"metric_key": "plan_duration_ms",
|
|
"metric_value": 1500.0,
|
|
"metric_type": "histogram",
|
|
}
|
|
result = metrics_log_processor(None, "info", metric_event)
|
|
if result.get("event_category") != "metric":
|
|
print("FAIL: event_category not set")
|
|
sys.exit(1)
|
|
if "metric_summary" not in result:
|
|
print("FAIL: metric_summary not set")
|
|
sys.exit(1)
|
|
|
|
non_metric: dict[str, Any] = {"event": "plan.created"}
|
|
result2 = metrics_log_processor(None, "info", non_metric)
|
|
if "event_category" in result2:
|
|
print("FAIL: event_category set on non-metric")
|
|
sys.exit(1)
|
|
print("metrics-processor-ok")
|
|
|
|
|
|
def main() -> None:
|
|
"""Dispatch subcommand."""
|
|
if len(sys.argv) < 2:
|
|
print("Usage: helper_metrics_collection.py <command>")
|
|
sys.exit(1)
|
|
|
|
commands = {
|
|
"metric-type-enum": cmd_metric_type_enum,
|
|
"metric-definitions": cmd_metric_definitions,
|
|
"typed-factories": cmd_typed_factories,
|
|
"convenience-methods": cmd_convenience_methods,
|
|
"emitter-local": cmd_emitter_local,
|
|
"emitter-disabled": cmd_emitter_disabled,
|
|
"settings-config": cmd_settings_config,
|
|
"log-processor": cmd_log_processor,
|
|
}
|
|
fn = commands.get(sys.argv[1])
|
|
if fn is None:
|
|
print(f"Unknown command: {sys.argv[1]}")
|
|
sys.exit(1)
|
|
fn()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|