Files
cleveragents-core/features/steps/structlog_config_steps.py
freemo a6242c1e38 feat(observability): implement Prometheus metrics export and event-to-audit bridge
Implements comprehensive observability infrastructure per issue #940:

- Add PrometheusRegistry with counters/histograms/gauges for all 14
  operational metric keys; supports /metrics HTTP endpoint (server mode)
  and file-based export (local mode)
- Add AuditBridge event subscriber that automatically writes
  security-relevant domain events to audit.jsonl with structured
  AuditRecord schema (timestamp, event_type, actor, action, resource,
  outcome, metadata, correlation_id, plan_id, session_id)
- Add configure_structlog() for JSON or text log output with
  inject_correlation_id processor for log correlation across plan
  executions
- Add observability config fields to Settings: metrics_prometheus_port,
  metrics_file_path, audit_jsonl_path, log_format
- Add prometheus-client>=0.20.0 to project dependencies
- Add 61 Behave BDD scenarios covering metrics registry, audit bridge
  routing, structured log output, and settings integration

ISSUES CLOSED: #940
2026-04-02 10:14:36 +00:00

171 lines
5.9 KiB
Python

"""Step definitions for JSON structlog configuration feature (Forgejo #940)."""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.config.settings import Settings
from cleveragents.infrastructure.observability.structlog_config import (
configure_structlog,
get_correlation_id,
inject_correlation_id,
set_correlation_id,
)
__all__: list[str] = []
# ---------------------------------------------------------------------------
# When — configure_structlog
# ---------------------------------------------------------------------------
@when('I call configure_structlog with log_format "{log_format}"')
def step_configure_structlog_format(context: Context, log_format: str) -> None:
context.configure_error = None
try:
configure_structlog(log_format=log_format)
except Exception as exc:
context.configure_error = exc
@when('I call configure_structlog with log_level "{log_level}"')
def step_configure_structlog_level(context: Context, log_level: str) -> None:
context.configure_error = None
try:
configure_structlog(log_level=log_level)
except Exception as exc:
context.configure_error = exc
@then("no exception should be raised from configure_structlog")
def step_no_exception_raised(context: Context) -> None:
error = getattr(context, "configure_error", None)
assert error is None, f"Unexpected exception: {error}"
# ---------------------------------------------------------------------------
# When / Then — correlation ID context variable
# ---------------------------------------------------------------------------
@when('I set the correlation ID to "{cid}"')
def step_set_correlation_id(context: Context, cid: str) -> None:
set_correlation_id(cid)
@when("I set the correlation ID to None")
def step_set_correlation_id_none(context: Context) -> None:
set_correlation_id(None)
@given("the correlation ID is cleared")
def step_clear_correlation_id(context: Context) -> None:
set_correlation_id(None)
@given('the correlation ID is set to "{cid}"')
def step_given_correlation_id_set(context: Context, cid: str) -> None:
set_correlation_id(cid)
@then('get_correlation_id should return "{expected}"')
def step_get_correlation_id_returns(context: Context, expected: str) -> None:
actual = get_correlation_id()
assert actual == expected, f"Expected '{expected}', got '{actual}'"
@then("get_correlation_id should return None")
def step_get_correlation_id_returns_none(context: Context) -> None:
assert get_correlation_id() is None, f"Expected None, got '{get_correlation_id()}'"
# ---------------------------------------------------------------------------
# When / Then — inject_correlation_id processor
# ---------------------------------------------------------------------------
@when("I run the inject_correlation_id processor on an empty event dict")
def step_run_processor_empty_dict(context: Context) -> None:
context.event_dict: dict[str, Any] = {}
context.event_dict = inject_correlation_id(None, "info", context.event_dict)
@when(
'I run the inject_correlation_id processor on an event dict with correlation_id "{existing}"'
)
def step_run_processor_existing_cid(context: Context, existing: str) -> None:
context.event_dict = {"correlation_id": existing}
context.event_dict = inject_correlation_id(None, "info", context.event_dict)
@then('the event dict should contain key "{key}" with value "{value}"')
def step_event_dict_has_key_value(context: Context, key: str, value: str) -> None:
assert key in context.event_dict, (
f"Key '{key}' not found in event dict: {context.event_dict}"
)
assert context.event_dict[key] == value, (
f"Expected event_dict['{key}'] == '{value}', got '{context.event_dict[key]}'"
)
@then('the event dict should not contain key "{key}"')
def step_event_dict_missing_key(context: Context, key: str) -> None:
assert key not in context.event_dict, (
f"Key '{key}' unexpectedly found in event dict: {context.event_dict}"
)
# ---------------------------------------------------------------------------
# Settings integration
# ---------------------------------------------------------------------------
@when("I create default settings for structlog")
def step_create_default_settings(context: Context) -> None:
Settings._instance = None
context.settings = Settings(database_url="sqlite:///:memory:")
@when('I create settings with log_format "{log_format}"')
def step_create_settings_with_log_format(context: Context, log_format: str) -> None:
import os
Settings._instance = None
os.environ["CLEVERAGENTS_LOG_FORMAT"] = log_format
try:
context.settings = Settings(database_url="sqlite:///:memory:")
finally:
del os.environ["CLEVERAGENTS_LOG_FORMAT"]
@then('settings.log_format should equal "{expected}"')
def step_settings_log_format_equals(context: Context, expected: str) -> None:
assert context.settings.log_format == expected, (
f"Expected log_format '{expected}', got '{context.settings.log_format}'"
)
@then("settings.metrics_prometheus_port should be an integer")
def step_settings_prometheus_port_is_int(context: Context) -> None:
assert isinstance(context.settings.metrics_prometheus_port, int), (
f"Expected int, got {type(context.settings.metrics_prometheus_port).__name__}"
)
@then("settings.audit_jsonl_path should be a string")
def step_settings_audit_jsonl_path_is_str(context: Context) -> None:
assert isinstance(context.settings.audit_jsonl_path, str), (
f"Expected str, got {type(context.settings.audit_jsonl_path).__name__}"
)
@then("settings.metrics_file_path should be a string")
def step_settings_metrics_file_path_is_str(context: Context) -> None:
assert isinstance(context.settings.metrics_file_path, str), (
f"Expected str, got {type(context.settings.metrics_file_path).__name__}"
)