Operational Observability — Implementation Plan¶
Design Document: docs/designs/v3.1/06-operational-observability.md
Priority: P0 — Critical Path
Estimated Duration: 11 weeks across 3 phases
Summary¶
This plan implements the Operational Observability layer for MAID, providing production-grade monitoring, metrics, structured logging, health checks, tracing, and alerting. The implementation adds:
- ObservabilityRegistry — protocol-based registry owned by
GameEngine(not a singleton) withDefaultObservabilityRegistryandNullObservabilityRegistryimplementations - MaidContext — frozen dataclass propagated via
ContextVarcarrying correlation ID, player ID, session ID, command, and tick number - structlog integration — JSON/console structured logging with stdlib bridge, context processors, and privacy-aware field redaction
- Prometheus metrics —
PrometheusMeterwith caching,CardinalityGuard, histogram profiles,SystemTickAggregator, andScrapeCache - Health checks —
HealthCheckerserving/healthz,/readyz,/livezon a dedicated internal port (default 9090) - safe_observe() — context manager wrapping all instrumentation calls with rate-limited error logging and a meta-counter
- Privacy — HMAC-based
PlayerIDAnonymizerand whitelist-based command argument redaction - Log sampling —
AdaptiveSamplerwith error/slow-path bypass and audit/operational channel split - AI cost tracking —
CompletionChunkstreaming fix,LLMProviderinstrumentation wrapper, event-drivenAICostTrackerwith configurable pricing - OpenTelemetry tracing — optional tracing with no-op fallback and configurable
tracing_mode(minimal/default/verbose) - Grafana dashboards — Server Health and AI Cost Overview JSON dashboard definitions
- Content pack metric API —
register_pack_counter()/register_pack_histogram()with prefix validation and label allowlists - SLO/SLI definitions — multi-window burn-rate alerting with Jinja2 alert rule templates
- Runbooks — operational runbooks for
MaidTickLoopStalled,MaidAIBudgetExhausted,MaidTargetDown
Existing infrastructure leveraged:
- GameEngine at packages/maid-engine/src/maid_engine/core/engine.py with __init__(), start(), stop(), _tick_loop()
- EventBus at packages/maid-engine/src/maid_engine/core/events.py with subscribe(), emit(), emit_sync()
- World at packages/maid-engine/src/maid_engine/core/world.py with create_entity(), destroy_entity(), tick(delta)
- Settings at packages/maid-engine/src/maid_engine/config/settings.py with get_settings(), clear_settings_cache()
- LLMProvider at packages/maid-engine/src/maid_engine/ai/providers/base.py with complete(), complete_streaming()
- RateLimiter at packages/maid-engine/src/maid_engine/ai/rate_limiter.py with check_and_reserve()
- CircuitBreaker at packages/maid-engine/src/maid_engine/ai/circuit_breaker.py with state property, CircuitState enum
- LayeredCommandRegistry at packages/maid-engine/src/maid_engine/commands/registry.py with execute()
- ProfileManager at packages/maid-engine/src/maid_engine/profiling/manager.py with start_session(), stop_session()
- MetricsCollector at packages/maid-engine/src/maid_engine/api/admin/dashboard.py with collect_server_metrics(), collect_player_metrics(), collect_world_metrics()
- AuditLogger at packages/maid-engine/src/maid_engine/logging/audit.py
- WebServer at packages/maid-engine/src/maid_engine/net/web/server.py
- DocumentStore at packages/maid-engine/src/maid_engine/storage/document_store.py
Phase 1: Foundation (Weeks 1–4) — P0¶
1.1 Package Structure and Dependencies¶
Package:
maid-engine| Priority: P0 | Dependencies: none
- [ ] Create
packages/maid-engine/src/maid_engine/observability/__init__.py - [ ] Module docstring explaining the observability framework
- [ ] Re-export key public types:
ObservabilityRegistry,MaidContext,safe_observe,HealthChecker - [ ] Create empty module files for the observability package:
- [ ]
packages/maid-engine/src/maid_engine/observability/registry.py - [ ]
packages/maid-engine/src/maid_engine/observability/logging.py - [ ]
packages/maid-engine/src/maid_engine/observability/metrics.py - [ ]
packages/maid-engine/src/maid_engine/observability/tracing.py - [ ]
packages/maid-engine/src/maid_engine/observability/context.py - [ ]
packages/maid-engine/src/maid_engine/observability/health.py - [ ]
packages/maid-engine/src/maid_engine/observability/internal_server.py - [ ]
packages/maid-engine/src/maid_engine/observability/middleware.py - [ ]
packages/maid-engine/src/maid_engine/observability/ai_metrics.py - [ ]
packages/maid-engine/src/maid_engine/observability/hooks.py - [ ]
packages/maid-engine/src/maid_engine/observability/safe_observe.py - [ ]
packages/maid-engine/src/maid_engine/observability/privacy.py - [ ]
packages/maid-engine/src/maid_engine/observability/sentry.py - [ ]
packages/maid-engine/src/maid_engine/observability/log_sampling.py - [ ]
packages/maid-engine/src/maid_engine/observability/bridges/__init__.py - [ ] Add required dependencies to
packages/maid-engine/pyproject.toml: - [ ]
structlog>=24.1.0(required) - [ ]
prometheus-client>=0.21.0(required) - [ ] Add optional dependency extras to
packages/maid-engine/pyproject.toml: - [ ]
tracing = ["opentelemetry-api>=1.25.0", "opentelemetry-sdk>=1.25.0"] - [ ]
otlp = ["opentelemetry-exporter-otlp-proto-grpc>=1.25.0"] - [ ]
sentry = ["sentry-sdk[asyncio]>=2.0.0"] - [ ]
monitoring = ["psutil>=5.9.0"] - [ ] Run
uv syncto verify dependency resolution - [ ] Create test directory
packages/maid-engine/tests/observability/with__init__.py
1.2 ObservabilitySettings Configuration¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.1
- [ ] Add
ObservabilitySettings(BaseSettings)Pydantic model topackages/maid-engine/src/maid_engine/config/settings.py: - [ ]
model_config = SettingsConfigDict(env_prefix="MAID_OBSERVABILITY__", env_nested_delimiter="__") - [ ] General:
- [ ]
enabled: bool = Field(default=True)— master switch for all observability
- [ ]
- [ ] Logging fields:
- [ ]
json_logs: bool = Field(default=True)— JSON (prod) or console (dev) output - [ ]
log_level: str = Field(default="INFO") - [ ]
log_sampling_enabled: bool = Field(default=True) - [ ]
operational_log_sample_rate: int = Field(default=10)— 1-in-N for operational INFO - [ ]
audit_retention_days: int = Field(default=90)— TTL for DocumentStore audit records - [ ]
log_queue_size: int = Field(default=10_000)— bounded async log queue
- [ ]
- [ ] Metrics fields:
- [ ]
metrics_enabled: bool = Field(default=True) - [ ]
internal_host: str = Field(default="127.0.0.1")— bind address for internal port - [ ]
internal_port: int = Field(default=9090)— dedicated port for /metrics + health - [ ]
metrics_token: str = Field(default="")— Bearer token for /metrics (required when host != 127.0.0.1) - [ ]
gauge_export_interval: float = Field(default=15.0) - [ ]
histogram_profile: str = Field(default="compact")— compact (6 buckets) or detailed (10-12 buckets) - [ ]
scrape_cache_ttl: float = Field(default=1.0)— seconds between background /metrics renders - [ ]
system_tick_detail: str = Field(default="pack_only")— pack_only or all - [ ]
max_pack_metrics: int = Field(default=20)— maximum metrics per content pack
- [ ]
- [ ] Cardinality guardrails:
- [ ]
cardinality_caps: dict[str, int] = Field(default={"command": 500, "event_domain": 20, "system_name": 100, "template": 20})
- [ ]
- [ ] Tracing fields (requires optional opentelemetry-sdk):
- [ ]
tracing_enabled: bool = Field(default=False) - [ ]
tracing_mode: str = Field(default="minimal")— minimal, default, verbose - [ ]
tracing_sample_rate: float = Field(default=0.05) - [ ]
tracing_exporter: str = Field(default="none")— none, otlp, jaeger, console
- [ ]
- [ ] Sentry (requires optional sentry-sdk):
- [ ]
sentry_dsn: str = Field(default="")
- [ ]
- [ ] Privacy fields:
- [ ]
anonymize_player_ids: bool = Field(default=True)— HMAC player IDs in logs - [ ]
redact_command_args: bool = Field(default=True)— redact ALL command args by default - [ ]
command_args_allowlist: set[str] = Field(default={"look", "move", "go", "north", "south", "east", "west", "up", "down", "inventory", "score", "who", "help", "quit", "exits"})— commands whose args are safe to log
- [ ]
- [ ] Profile selection:
- [ ]
profile: str = Field(default="production")— development, staging, production
- [ ]
- [ ] Define
PROFILESdict with preset overrides for development, staging, production (§11.1) - [ ] Environment variable prefix:
MAID_OBSERVABILITY__ - [ ] Add
observability: ObservabilitySettings = ObservabilitySettings()field toSettingsclass - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_settings.py: - [ ] Test default settings
- [ ] Test profile overrides (dev/staging/prod)
- [ ] Test environment variable binding with
MAID_OBSERVABILITY__prefix
1.3 MaidContext and ContextVar Propagation¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.1
- [ ] Create
MaidContextfrozen dataclass inpackages/maid-engine/src/maid_engine/observability/context.py: - [ ]
@dataclass(frozen=True)— frozen to prevent partial mutation; all fields set atomically - [ ]
correlation_id: str = ""— hex UUID4 prefix (16 chars), generated per request/command - [ ]
player_id: str = ""— raw player UUID (anonymized only in log output, not in ContextVar) - [ ]
session_id: str = "" - [ ]
command: str = "" - [ ]
tick_number: int = -1 - [ ] Define module-level
_maid_context_var: ContextVar[MaidContext]with defaultMaidContext()(never None) - [ ] Implement
get_context() -> MaidContext— returns current context (never None, returns default empty MaidContext) - [ ] Implement
bind_context(**overrides: object) -> Token[MaidContext]— copies current context with overrides, returns Token for restoring previous - [ ] Implement
new_correlation_id() -> str— generatesuuid4().hex[:16], binds it into current context - [ ] Implement
bind_player_context(player_id: str, session_id: str) -> None— convenience for session setup - [ ] Implement
clear_context() -> None— resets to emptyMaidContext() - [ ] Implement
_add_maid_context(logger, method_name, event_dict) -> dictstructlog processor: - [ ] Single
_maid_context_var.get()call - [ ] Unpacks all non-empty fields into
event_dict - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_context.py: - [ ] Test
MaidContextis frozen (immutable) - [ ] Test
bind_context()sets and resetsContextVar - [ ] Test
get_context()returns emptyMaidContextwhen unset (never None) - [ ] Test
structlog_context_processorinjects fields - [ ] Test nested
bind_context()correctly restores outer context - [ ] Test
correlation_idauto-generation
1.4 ObservabilityRegistry Protocol and Implementations¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.2, 1.3
- [ ] Define
ObservabilityRegistryprotocol inpackages/maid-engine/src/maid_engine/observability/registry.py: - [ ]
def get_meter(self) -> PrometheusMeter— returns the meter for metric creation - [ ]
def get_logger(self, name: str) -> Any - [ ]
def get_tracer(self, name: str) -> Any - [ ]
def health_checker(self) -> HealthChecker - [ ]
@property def settings(self) -> ObservabilitySettings - [ ] Implement
DefaultObservabilityRegistryclass: - [ ] Constructor accepts
ObservabilitySettings - [ ]
_meter: PrometheusMeter— singlePrometheusMeterinstance with caching andCardinalityGuard - [ ]
get_meter()returnsself._meter - [ ]
get_logger()returnsstructlog.get_logger(name) - [ ]
get_tracer()returns OpenTelemetry tracer or no-op - [ ]
health_checker()returns sharedHealthCheckerinstance - [ ] Implement
NullObservabilityRegistryclass: - [ ] All methods return no-op stubs (no-op meter, no-op logger, etc.)
- [ ] Used when
settings.observability.enabledisFalse - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_registry.py: - [ ] Test
DefaultObservabilityRegistry.get_meter()returnsPrometheusMeter - [ ] Test
PrometheusMetercaches metric objects (same name returns same instance) - [ ] Test
NullObservabilityRegistryreturns no-op objects - [ ] Test
CardinalityGuardenforcement (see task 1.8)
1.5 safe_observe() Context Manager¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.4
- [ ] Implement
safe_observe()context manager inpackages/maid-engine/src/maid_engine/observability/safe_observe.py: - [ ] Signature:
@contextmanager safe_observe(operation: str, registry: ObservabilityRegistry | None = None) -> Iterator[None] - [ ] Wraps body in try/except catching all
Exception - [ ] On exception: increment
maid_observability_errors_totalcounter with labeloperation - [ ] Rate-limit error logging: at most 1 log per 5 minutes (300s) per unique operation name
- [ ] Use
_error_timestamps: dict[str, float]module-level dict for rate limiting - [ ] Never re-raise — observability failures must not crash the game
- [ ] Implement
_observability_error_countermeta-counter (created lazily on first use) - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_safe_observe.py: - [ ] Test body executes normally when no exception
- [ ] Test exception is swallowed (not re-raised)
- [ ] Test error counter is incremented on exception
- [ ] Test rate-limited logging (second error within 5 minutes is not logged)
- [ ] Test different operations have independent rate limits
1.6 structlog Configuration and stdlib Bridge¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.3
- [ ] Implement
setup_logging(settings: ObservabilitySettings) -> Noneinpackages/maid-engine/src/maid_engine/observability/logging.py(§6.2 of design doc): - [ ] Configure
structlogwith shared processor chain:structlog.contextvars.merge_contextvarsstructlog.stdlib.add_logger_namestructlog.stdlib.add_log_levelstructlog.processors.TimeStamper(fmt="iso", utc=True)_add_maid_context(fromcontext.py— injects MaidContext fields via single ContextVar.get())_add_log_channel— classifies events as"audit"or"operational"(§6.7)structlog.processors.StackInfoRenderer()structlog.processors.format_exc_infostructlog.processors.UnicodeDecoder()- If
log_sampling_enabled: insert_sampling_processorat position 0 - If
anonymize_player_ids: append_anonymize_player_idprocessor - If
redact_command_args: append_redact_commandprocessor
- [ ] Configure stdlib logging bridge via
structlog.stdlib.ProcessorFormatter:- [ ]
foreign_pre_chain=shared_processorsto capture existinglogging.getLogger()calls - [ ] Renderer:
structlog.processors.JSONRenderer()whenjson_logs=True, elsestructlog.dev.ConsoleRenderer()
- [ ]
- [ ] Set root logger level from
settings.log_level - [ ] Suppress noisy loggers:
asyncio,websockets,uvicorn.access→ WARNING level - [ ] Configure
logging.captureWarnings(True)to route Python warnings through structlog - [ ] Implement
_add_log_channel(logger, method_name, event_dict) -> dict(§6.7): - [ ]
_AUDIT_EVENTS: frozenset[str]— events classified as audit:"command_executed","player_connected","player_disconnected","admin_action","auth_login","auth_failure","ai_completion","budget_warning","entity_created","entity_destroyed" - [ ] Sets
event_dict["channel"]to"audit"if event name in_AUDIT_EVENTS, else"operational" - [ ] Implement
_anonymize_player_id(logger, method_name, event_dict) -> dict: - [ ] If
player_idkey present and non-empty, replace withPlayerIDAnonymizer.anonymize()value - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_logging.py: - [ ] Test JSON output format includes expected fields
- [ ] Test console output format is human-readable
- [ ] Test stdlib log records are captured by structlog
- [ ] Test
_anonymize_processorreplaces player IDs - [ ] Test
_redact_processorredacts sensitive command arguments
1.7 Privacy: PlayerIDAnonymizer and Command Redaction¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.2
- [ ] Implement
PlayerIDAnonymizerclass inpackages/maid-engine/src/maid_engine/observability/privacy.py: - [ ] HMAC-based anonymization with rotating salt (§14.1 of design doc)
- [ ] Constructor accepts
salt_rotation_hours: int = 24 - [ ]
_salt: bytes—os.urandom(32)generated at init - [ ]
_salt_created: float—time.monotonic()timestamp of salt creation - [ ]
anonymize(player_id: str) -> str— returnsf"p_{hmac.new(self._salt, player_id.encode(), hashlib.sha256).hexdigest()[:12]}" - [ ]
_maybe_rotate_salt() -> None— rotates salt if elapsed time exceedssalt_rotation_hours * 3600 - [ ] Same player maps to same pseudonym within rotation window (enables log correlation)
- [ ] Different pseudonyms across rotation windows (GDPR-compatible)
- [ ] Implement
_redact_commandstructlog processor inprivacy.py(§14.2 of design doc): - [ ]
_SAFE_COMMANDS_ALLOWLIST: frozenset[str]— commands whose arguments are safe to log (loaded fromObservabilitySettings.command_args_allowlist) - [ ] Default allowlist:
look,move,go,north,south,east,west,up,down,inventory,score,who,help,quit,exits,commands,areas,map,time,weather - [ ]
_redact_command(logger, method_name, event_dict) -> dict— extracts verb fromcommandfield, strips args if verb not in allowlist - [ ] Whitelist approach: redact ALL arguments by default, only safe commands retain args
- [ ] Content packs can register additional safe commands via
register_safe_commands() - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_privacy.py: - [ ] Test
PlayerIDAnonymizerproduces deterministic output - [ ] Test
PlayerIDAnonymizerproduces different output for different inputs - [ ] Test
PlayerIDAnonymizercache does not exceed max size - [ ] Test
CommandRedactorpasses whitelisted command arguments through - [ ] Test
CommandRedactorredacts non-whitelisted command arguments - [ ] Test
CommandRedactorwith custom whitelist
1.8 Prometheus Metrics: PrometheusMeter, CardinalityGuard, Histogram Profiles¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.4
- [ ] Implement
PrometheusMeterclass inpackages/maid-engine/src/maid_engine/observability/metrics.py: - [ ] Thin wrapper over
prometheus_clientproviding prefixed metric creation - [ ]
create_counter(name: str, description: str, labels: tuple[str, ...] = ()) -> Counter - [ ]
create_histogram(name: str, description: str, labels: tuple[str, ...] = (), buckets: tuple[float, ...] | None = None) -> Histogram - [ ]
create_gauge(name: str, description: str, labels: tuple[str, ...] = ()) -> Gauge - [ ] Internal
_registry: dict[str, Counter | Histogram | Gauge]for deduplication - [ ] Auto-prefix all metric names with
"maid_"(e.g.,maid_tick_duration_seconds) - [ ] Implement
CardinalityGuardclass inmetrics.py(§12.2 of design doc): - [ ] Constructor accepts
caps: dict[str, int] | None = None(fromObservabilitySettings.cardinality_caps),default_cap: int = 500 - [ ]
_seen: dict[str, set[str]]— tracks seen label values per label name - [ ]
_warned: set[str]— labels for which cap-hit warning has been emitted - [ ]
check(label_name: str, value: str, allowlist: set[str] | None = None) -> str:- [ ] If allowlist provided and value not in it: return
"_other" - [ ] If value already seen: return value
- [ ] If
len(seen) >= cap: emit warning (once per label_name), return"_other" - [ ] Otherwise: add to seen, return value
- [ ] If allowlist provided and value not in it: return
- [ ] Operator-tunable per-label caps via
ObservabilitySettings.cardinality_caps - [ ] Pre-defined allowlists:
- [ ]
_PROVIDER_ALLOWLIST = {"anthropic", "openai", "ollama"} - [ ]
_STATUS_ALLOWLIST = {"success", "error", "rate_limited", "timeout", "not_found", "denied"} - [ ]
_AI_INTENT_ALLOWLIST = {"dialogue", "quest_gen", "narrative_desc", "combat_narrate", "npc_autonomy", "content_gen", "moderation"}
- [ ]
- [ ] Define histogram bucket profiles per metric type in
metrics.py(§4.6 of design doc): - [ ]
HistogramProfileenum:COMPACT,DETAILED - [ ]
TICK_BUCKETS: dict[HistogramProfile, tuple[float, ...]]— COMPACT: 6 buckets, DETAILED: 10-12 - [ ]
COMMAND_BUCKETS: dict[HistogramProfile, tuple[float, ...]]— command execution duration buckets - [ ]
AI_LATENCY_BUCKETS: dict[HistogramProfile, tuple[float, ...]]— AI request latency buckets (0.5s–60s range) - [ ]
DB_QUERY_BUCKETS: dict[HistogramProfile, tuple[float, ...]]— database query duration buckets - [ ]
API_REQUEST_BUCKETS: dict[HistogramProfile, tuple[float, ...]]— HTTP API request duration buckets - [ ] Default profile:
COMPACTin production,DETAILEDin development (viaObservabilitySettings.histogram_profile) - [ ] Implement
SystemTickAggregatorclass inmetrics.py: - [ ] Two modes:
pack_only(default) andall(opt-in viaObservabilitySettings.system_tick_detail) - [ ]
pack_only: aggregates per-system tick durations into a single histogram observation per content pack - [ ]
all: records per-system histogram observations (use only for debugging) - [ ]
record_system(system_name: str, pack_name: str, duration: float) -> None - [ ]
flush(total_tick_duration: float) -> None— observes total tick duration into histogram, resets accumulators - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_metrics.py: - [ ] Test
PrometheusMetercreates counters with correct prefix - [ ] Test
PrometheusMeterdeduplicates metrics (same name returns same object) - [ ] Test
CardinalityGuardallows labels within limit - [ ] Test
CardinalityGuardrejects labels exceeding limit - [ ] Test
CardinalityGuardsubstitutes"_other"on breach - [ ] Test
SystemTickAggregatoraggregates and flushes correctly - [ ] Test histogram profile bucket definitions
1.9 Log Sampling: AdaptiveSampler¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.6
- [ ] Implement
AdaptiveSamplerclass inpackages/maid-engine/src/maid_engine/observability/log_sampling.py: - [ ] Constructor accepts
base_rate: float(fromObservabilitySettings.operational_log_sample_rate) - [ ]
should_sample(level: str, event: str, context: MaidContext | None = None) -> bool - [ ] Always sample:
ERROR,CRITICALlevels (bypass sampling) - [ ] Always sample: events tagged as
audit=Truein event_dict - [ ] Always sample: slow-path events (tick duration > 2x target)
- [ ] Apply
base_ratesampling toDEBUGandINFOlevels viarandom.random() < base_rate - [ ]
_operational_counter: int— count of sampled-out operational logs for periodic summary - [ ] Implement
sampling_processor(logger, method_name, event_dict) -> dictstructlog processor: - [ ] Calls
AdaptiveSampler.should_sample() - [ ] Raises
structlog.DropEventif not sampled - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_log_sampling.py: - [ ] Test ERROR level always sampled
- [ ] Test CRITICAL level always sampled
- [ ] Test audit events always sampled
- [ ] Test INFO level sampled at configured rate
- [ ] Test DEBUG level sampled at configured rate
- [ ] Test
sampling_processordrops events correctly
1.10 ScrapeCache with Background Rendering¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.8
- [ ] Implement
ScrapeCacheclass inpackages/maid-engine/src/maid_engine/observability/metrics.py: - [ ]
_cached_output: bytes— pre-rendered Prometheus exposition format - [ ]
_last_render: float— monotonic timestamp of last render - [ ]
_ttl: float— cache TTL fromObservabilitySettings.scrape_cache_ttl(default 1.0s) - [ ]
_lock: asyncio.Lock— prevents concurrent rendering - [ ]
async def get() -> bytes— returns cached output if within TTL, else renders fresh - [ ]
async def _render() -> bytes— callsprometheus_client.generate_latest()in thread executor to avoid blocking event loop - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_scrape_cache.py: - [ ] Test cached output returned within TTL
- [ ] Test fresh render after TTL expiry
- [ ] Test concurrent calls do not trigger multiple renders
1.11 Health Checks: HealthChecker¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.4
- [ ] Implement
HealthCheckerclass inpackages/maid-engine/src/maid_engine/observability/health.py: - [ ]
HealthStatusenum:HEALTHY,DEGRADED,UNHEALTHY - [ ]
HealthCheckResultdataclass:status: HealthStatus,message: str,duration_ms: float,details: dict[str, Any] - [ ]
register_check(name: str, check_fn: Callable[[], Awaitable[HealthCheckResult]]) -> None - [ ]
async def check_health() -> dict[str, HealthCheckResult]— runs all registered checks - [ ]
async def check_liveness() -> HealthCheckResult— lightweight check (is the process alive and event loop responsive) - [ ]
async def check_readiness() -> HealthCheckResult— aggregates all registered checks - [ ] Built-in checks:
- [ ]
_check_event_loop()— verifies event loop is not blocked (schedules a callback, measures delay) - [ ]
_check_tick_loop(engine: GameEngine)— verifies last tick was within 5x tick interval
- [ ]
- [ ]
_db_health_cache: HealthCheckResult | None— cached DB health, updated by background loop - [ ]
async def _db_health_loop(interval: float = 30.0) -> None— background task that periodically checks DB connectivity - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_health.py: - [ ] Test
check_liveness()returns HEALTHY when event loop responsive - [ ] Test
check_readiness()aggregates multiple checks - [ ] Test DEGRADED status when one check fails but others pass
- [ ] Test UNHEALTHY status when critical check fails
- [ ] Test DB health cache is used (not re-queried on every request)
- [ ] Test custom check registration
1.12 Internal Port Server¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.10, 1.11
- [ ] Implement
InternalServerclass inpackages/maid-engine/src/maid_engine/observability/internal_server.py: - [ ] Lightweight ASGI application (no framework dependency — raw ASGI)
- [ ] Binds to
ObservabilitySettings.internal_port(default 9090) - [ ] Routes:
- [ ]
GET /metrics— serves Prometheus metrics fromScrapeCache - [ ]
GET /healthz— serves overall health status (HTTP 200/503) - [ ]
GET /readyz— serves readiness status (HTTP 200/503) - [ ]
GET /livez— serves liveness status (HTTP 200/503)
- [ ]
- [ ] Response format: JSON for health endpoints, Prometheus text exposition for
/metrics - [ ]
async def start() -> None— startsuvicornorhypercornon the internal port - [ ]
async def stop() -> None— graceful shutdown - [ ] No authentication required when bound to loopback (127.0.0.1)
- [ ] When
internal_hostis not loopback: require Bearer token fromObservabilitySettings.metrics_token; warn at startup if token is empty - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_internal_server.py: - [ ] Test
/metricsreturns valid Prometheus text format - [ ] Test
/healthzreturns 200 with JSON body - [ ] Test
/readyzreturns 503 when not ready - [ ] Test
/livezreturns 200 when alive - [ ] Test unknown routes return 404
1.13 Tick Loop Instrumentation¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.4, 1.5
- [ ] Modify
GameEngine._tick_loop()inpackages/maid-engine/src/maid_engine/core/engine.py: - [ ] Wrap tick body in
with safe_observe("tick_loop"): - [ ] Record tick duration:
self._obs_registry.histogram("tick_duration_seconds", "Duration of a single game tick").observe(duration) - [ ] Increment tick counter:
self._obs_registry.counter("ticks_total", "Total number of ticks processed").inc() - [ ] Record per-system durations via
SystemTickAggregatoriftracing_mode >= "default" - [ ] Set
maid_tick_loop_healthygauge:1.0iflast_tick_duration < tick_budget,0.0otherwise (referenced by SLO burn-rate alerts) - [ ] Bind
MaidContext(tick_number=self.tick_count)for the tick scope - [ ] Add
last_tick_monotonic: floatproperty toGameEngine: - [ ] Set
self._last_tick_monotonic = time.monotonic()at start of each tick - [ ] Used by
HealthChecker._check_tick_loop()to detect stalled tick loops - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_tick_instrumentation.py: - [ ] Test tick duration histogram is observed
- [ ] Test tick counter is incremented
- [ ] Test
last_tick_monotonicis updated each tick - [ ] Test safe_observe swallows observability errors without affecting tick
1.14 Command Instrumentation¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.3, 1.4, 1.5
- [ ] Modify
LayeredCommandRegistry.execute()inpackages/maid-engine/src/maid_engine/commands/registry.py: - [ ] Bind
MaidContext(command=command_name, player_id=context.player_id, correlation_id=uuid4())at command entry - [ ] Wrap execution in
with safe_observe("command_execute"): - [ ] Record command duration:
registry.histogram("command_duration_seconds", "Command execution duration", labels=("command",)).labels(command=command_name).observe(duration) - [ ] Increment command counter:
registry.counter("commands_total", "Total commands processed", labels=("command", "status")).labels(command=command_name, status="ok"|"error").inc() - [ ] The
ObservabilityRegistryreference comes fromCommandContext— addobs_registry: ObservabilityRegistry | None = Nonefield toCommandContext - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_command_instrumentation.py: - [ ] Test command duration histogram is observed with correct label
- [ ] Test command counter incremented on success
- [ ] Test command counter incremented with
status="error"on failure - [ ] Test
MaidContextis set with correct command name and player_id - [ ] Test instrumentation is no-op when
obs_registryisNone
1.15 EventBus Instrumentation¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.4, 1.5
- [ ] Modify
EventBus.emit()inpackages/maid-engine/src/maid_engine/core/events.py: - [ ] Wrap in
with safe_observe("event_emit"): - [ ] Increment event counter:
registry.counter("events_total", "Total events emitted", labels=("event_domain",)).labels(event_domain=get_event_domain(event)).inc() - [ ] For
emit_sync()deferred events: capture currentMaidContextat queue time and restore it when the handler runs, to preserve correlation IDs across async boundaries - [ ]
get_event_domain(event_type_name: str) -> str— maps event class names to ~15 bounded domain labels via prefix matching (§12.1 of design doc):- [ ]
_EVENT_DOMAIN_MAPdict mapping prefixes to domains:Tick/Startup/Shutdown→"engine",Connect/Disconnect→"network",Room/Move→"movement",Combat/Damage/Death→"combat",Item/Pickup/Drop→"inventory",Spell/Cast→"magic",Quest→"quest",Guild/Faction→"social",Auction/Trade→"economy",Craft→"crafting",NPC/Dialogue→"npc",PvP→"pvp",Weather/Time→"world" - [ ] Unknown event types →
"other"fallback
- [ ]
- [ ] Add
obs_registry: ObservabilityRegistry | Noneattribute toEventBus.__init__(): - [ ] Default
None— no instrumentation unless explicitly set - [ ] Set by
GameEngineduring initialization - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_event_instrumentation.py: - [ ] Test event counter incremented on emit
- [ ] Test domain bucketing maps event types correctly
- [ ] Test unknown event types map to
"other"bucket - [ ] Test instrumentation is no-op when
obs_registryisNone
1.16 O(1) Entity Counting via EntityManager¶
Package:
maid-engine| Priority: P0 | Dependencies: none
- [ ] Add
count_with_tag(tag: str) -> intmethod toEntityManagerinpackages/maid-engine/src/maid_engine/core/ecs/entity.py: - [ ] Leverages existing
_by_tagindex for O(1) lookup vialen(self._by_tag.get(tag, set())) - [ ] Expose total entity count via existing
_entitiesdict:@property entity_count(self) -> int - [ ] Add convenience methods to
Worldinpackages/maid-engine/src/maid_engine/core/world.py: - [ ]
@property entity_count(self) -> int— delegates toself._entity_manager.entity_count - [ ]
def entity_type_count(self, entity_type: str) -> int— delegates toself._entity_manager.count_with_tag(entity_type) - [ ] These counters feed Prometheus gauges without requiring O(N) iteration
- [ ] Write unit tests in
packages/maid-engine/tests/observability/test_entity_counting.py: - [ ] Test count increments on create
- [ ] Test count decrements on destroy
- [ ] Test type-specific counts
- [ ] Test count is 0 after creating and destroying same entity
1.17 GameEngine Integration¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.4, 1.11, 1.12, 1.13
- [ ] Modify
GameEngine.__init__()inpackages/maid-engine/src/maid_engine/core/engine.py: - [ ] Create
ObservabilityRegistrybased onsettings.observability.enabled:- If enabled:
self._obs_registry = DefaultObservabilityRegistry(settings.observability) - If disabled:
self._obs_registry = NullObservabilityRegistry()
- If enabled:
- [ ] Store as
self._obs_registry: ObservabilityRegistry - [ ] Expose as
@property obs_registry(self) -> ObservabilityRegistry - [ ] Pass
obs_registrytoEventBusviaself.world.events.obs_registry = self._obs_registry - [ ] Modify
GameEngine.start(): - [ ] Call
setup_logging(settings.observability)before other initialization - [ ] Register built-in health checks on
self._obs_registry.health_checker() - [ ] Start
InternalServeronsettings.observability.internal_port - [ ] Store
self._internal_server: InternalServer - [ ] Modify
GameEngine.stop(): - [ ] Stop
InternalServergracefully - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_engine_integration.py: - [ ] Test
GameEnginecreatesDefaultObservabilityRegistrywhen enabled - [ ] Test
GameEnginecreatesNullObservabilityRegistrywhen disabled - [ ] Test
obs_registryproperty returns the registry - [ ] Test health checks are registered on start
1.18 ASGI Middleware¶
Package:
maid-engine| Priority: P1 | Dependencies: 1.3, 1.4
- [ ] Implement
ObservabilityMiddlewareASGI middleware inpackages/maid-engine/src/maid_engine/observability/middleware.py: - [ ] Wraps the existing ASGI application (
WebServer.app) - [ ] On each request:
- [ ] Extract
path_templatefrom request path (collapse path params to{id}to limit cardinality) - [ ] Bind
MaidContext(correlation_id=uuid4())for the request scope - [ ] Record request duration:
histogram("http_request_duration_seconds", labels=("method", "path_template", "status_code")).observe(duration) - [ ] Increment request counter:
counter("http_requests_total", labels=("method", "path_template", "status_code")).inc()
- [ ] Extract
- [ ]
_extract_path_template(path: str) -> str— e.g.,/admin/entities/abc-123→/admin/entities/{id} - [ ] Register middleware on
WebServerinpackages/maid-engine/src/maid_engine/net/web/server.py: - [ ] Wrap
self._appwithObservabilityMiddlewareif observability is enabled - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_middleware.py: - [ ] Test path template extraction collapses UUIDs
- [ ] Test request duration histogram is observed
- [ ] Test request counter is incremented with correct labels
- [ ] Test
MaidContextis bound for request scope
1.19 Benchmark Validation Gate¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.13, 1.14, 1.15, 1.16
This is a validation gate — Phase 2 must not begin until these benchmarks pass.
- [ ] Create benchmark script at
packages/maid-engine/tests/observability/bench_overhead.py: - [ ] Measure tick loop overhead with observability enabled vs disabled
- [ ] Target: < 2% overhead on tick loop at 4 ticks/second
- [ ] Measure command execution overhead with instrumentation
- [ ] Target: < 1ms additional latency per command
- [ ] Measure EventBus emit overhead with instrumentation
- [ ] Target: < 0.1ms additional latency per event
- [ ] Measure memory overhead of ObservabilityRegistry
- [ ] Target: < 50MB additional memory at 10,000 entities
- [ ] Document benchmark results in
docs/impl/v3.1/06-benchmarks.md
Phase 2: AI and Game Metrics (Weeks 5–8) — P0/P1¶
2.1 CompletionChunk and TokenUsage Dataclasses¶
Package:
maid-engine| Priority: P0 | Dependencies: none
- [ ] Add
CompletionChunkfrozen dataclass topackages/maid-engine/src/maid_engine/ai/providers/base.py: - [ ]
content: str— text chunk from streaming response - [ ]
finish_reason: str | None = None— set on final chunk - [ ]
model: str | None = None— set on final chunk - [ ]
usage: TokenUsage | None = None— set on final chunk (if provider supports it) - [ ] Add
TokenUsagefrozen dataclass tobase.py: - [ ]
prompt_tokens: int - [ ]
completion_tokens: int - [ ]
total_tokens: int - [ ] Write unit tests in
packages/maid-engine/tests/ai/test_completion_chunk.py: - [ ] Test
CompletionChunkis frozen - [ ] Test
TokenUsagetotal equals prompt + completion
2.2 LLMProvider Base Class Refactor with Instrumentation¶
Package:
maid-engine| Priority: P0 | Dependencies: 1.4, 2.1
- [ ] Refactor
LLMProviderinpackages/maid-engine/src/maid_engine/ai/providers/base.py: - [ ] Rename existing abstract
complete()to_do_complete(self, messages: list[Message], options: CompletionOptions) -> CompletionResult(abstract) - [ ] Add new concrete
complete()wrapper method:- [ ] Calls
_do_complete()internally - [ ] Wraps in
with safe_observe("llm_complete"): - [ ] Records
maid_ai_request_duration_secondshistogram with labels(provider, model) - [ ] Records
maid_ai_tokens_totalcounter with labels(provider, model, token_type)wheretoken_typeis"prompt"or"completion" - [ ] Records
maid_ai_requests_totalcounter with labels(provider, model, status)wherestatusis"ok"or"error" - [ ] Emits
AICompletionEventvia EventBus (if available)
- [ ] Calls
- [ ] Add
obs_registry: ObservabilityRegistry | None = Noneattribute toLLMProvider.__init__() - [ ] Add
event_bus: EventBus | None = Noneattribute toLLMProvider.__init__() - [ ] Update all three providers — each currently has a concrete
complete()override: - [ ]
AnthropicProvider: renamecomplete()→_do_complete(), ensureTokenUsagepopulated from API response - [ ]
OpenAIProvider: renamecomplete()→_do_complete(), ensureTokenUsagepopulated from API response - [ ]
OllamaProvider: renamecomplete()→_do_complete(), ensureTokenUsagepopulated from API response - [ ] Update provider construction in
ai/registry.py(create_registry_from_settings): - [ ] Pass
obs_registryandevent_busto provider constructors - [ ] Update all
complete_streaming()consumers across packages (breaking change): - [ ] Search
complete_streaminginmaid-engine,maid-classic-rpg, andmaid-stdlib - [ ] Update
maid_classic_rpg/systems/npc/dialogue.pyand test doubles intests/test_npc_dialogue_system.py - [ ] Update consumers to access
chunk.contentfor text instead of barestr - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_llm_instrumentation.py: - [ ] Test
complete()wrapper recordsmaid_ai_request_duration_secondshistogram - [ ] Test
complete()wrapper recordsmaid_ai_tokens_totalcounter - [ ] Test
complete()wrapper recordsmaid_ai_requests_totalcounter with status - [ ] Test
complete()wrapper emitsAICompletionEvent - [ ] Test instrumentation is no-op when
obs_registryisNone - [ ] Test cross-package:
maid-classic-rpgNPC dialogue system works with refactored providers
2.3 Streaming Response Fix with CompletionChunk¶
Package:
maid-engine| Priority: P0 | Dependencies: 2.1
- [ ] Refactor
LLMProvider.complete_streaming()inpackages/maid-engine/src/maid_engine/ai/providers/base.py: - [ ] Change return type from
AsyncIterator[str]toAsyncIterator[CompletionChunk] - [ ] Rename existing
complete_streaming()to_do_complete_streaming()(abstract) - [ ] Add wrapper
complete_streaming()that:- [ ] Wraps iteration in
try/finallyfor cancellation safety - [ ] Records total streaming duration on completion
- [ ] Records token usage from final chunk (if available)
- [ ] Increments
llm_streaming_requests_totalcounter
- [ ] Wraps iteration in
- [ ] Update
AnthropicProvider.complete_streaming()→_do_complete_streaming(): - [ ] Yield
CompletionChunkobjects with content and final-chunk metadata - [ ] Update
OpenAIProvider.complete_streaming()→_do_complete_streaming(): - [ ] Yield
CompletionChunkobjects with content and final-chunk metadata - [ ] Update
OllamaProvider.complete_streaming()→_do_complete_streaming(): - [ ] Yield
CompletionChunkobjects with content and final-chunk metadata - [ ] Update all call sites that consume
complete_streaming()to handleCompletionChunkinstead ofstr: - [ ] Search for
complete_streamingusage across all packages - [ ] Update to access
chunk.contentfor text - [ ] Write unit tests in
packages/maid-engine/tests/ai/test_streaming_fix.py: - [ ] Test streaming returns
CompletionChunkobjects - [ ] Test final chunk includes
finish_reasonandusage - [ ] Test cancellation safety (async generator cleanup)
- [ ] Test duration is recorded on stream completion
2.4 AI Cost Calculation and Pricing¶
Package:
maid-engine| Priority: P0 | Dependencies: 2.1
- [ ] Implement AI cost calculation in
packages/maid-engine/src/maid_engine/observability/ai_metrics.py: - [ ]
PricingEntrydataclass:model: str,prompt_cost_per_1k: float,completion_cost_per_1k: float,effective_date: str - [ ]
PricingConfigdataclass:entries: dict[str, PricingEntry],default_prompt_cost: float = 0.0,default_completion_cost: float = 0.0 - [ ]
load_pricing(config_path: Path | None = None, env_override: str | None = None) -> PricingConfig:- [ ] Priority: env var → config file → hardcoded defaults
- [ ] Config file path:
data/ai_pricing.yml(YAML format) - [ ] Env var:
MAID_AI__PRICING_JSON(JSON string) - [ ] DocumentStore persistence and runtime admin API deferred to Phase 3
- [ ]
calculate_cost(usage: TokenUsage, model: str, pricing: PricingConfig) -> float:- [ ] Returns cost in USD
- [ ] Uses model-specific pricing if available, else default
- [ ] Implement
AICompletionEventevent class inai_metrics.py: - [ ] Extends
Eventbase class - [ ] Fields:
provider: str,model: str,usage: TokenUsage,duration: float,cost: float,player_id: str | None - [ ] Implement
AICostTrackerclass inai_metrics.py: - [ ] Event-driven: subscribes to
AICompletionEventviaEventBus - [ ] Maintains running totals:
_total_cost: float,_total_tokens: int,_costs_by_model: dict[str, float],_costs_by_player: dict[str, float] - [ ]
get_total_cost() -> float - [ ]
get_cost_by_model() -> dict[str, float] - [ ]
get_cost_by_player() -> dict[str, float] - [ ]
reset() -> None— resets all counters (called by gauge export loop) - [ ] Exposes Prometheus gauges:
maid_ai_cost_total_usd,maid_ai_cost_by_model_usd,maid_ai_tokens_total - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_ai_metrics.py: - [ ] Test
load_pricing()from file - [ ] Test
load_pricing()env var overrides file - [ ] Test
calculate_cost()with known model pricing - [ ] Test
calculate_cost()with default pricing - [ ] Test
AICostTrackeraccumulates costs from events - [ ] Test
AICostTrackertracks per-model and per-player costs - [ ] Test
AICostTracker.reset()clears accumulators
2.5 Runtime Pricing Admin API¶
Package:
maid-engine| Priority: P2 | Dependencies: 2.4 Note: Deferred to Phase 3. Phase 2load_pricing()uses env var → config file → defaults only. DocumentStore persistence added here.
- [ ] Add
PUT /admin/ai/pricingendpoint inpackages/maid-engine/src/maid_engine/api/admin/: - [ ] Accepts JSON body with pricing entries
- [ ] Validates entries via
PricingConfigPydantic model - [ ] Persists to
DocumentStoreunder collection"ai_config" - [ ] Reloads
AICostTrackerpricing on update - [ ] Requires
AccessLevel.ADMIN - [ ] Add
GET /admin/ai/pricingendpoint: - [ ] Returns current pricing configuration
- [ ] Write unit tests in
packages/maid-engine/tests/api/test_ai_pricing_api.py: - [ ] Test PUT updates pricing
- [ ] Test GET returns current pricing
- [ ] Test validation rejects invalid pricing entries
- [ ] Test admin access level required
2.6 Rate Limiter Instrumentation¶
Package:
maid-engine| Priority: P1 | Dependencies: 1.4
- [ ] Modify
RateLimiter.check_and_reserve()inpackages/maid-engine/src/maid_engine/ai/rate_limiter.py: - [ ] Add
obs_registry: ObservabilityRegistry | None = NonetoRateLimiter.__init__() - [ ] On denial: increment
maid_ai_rate_limit_denials_totalcounter with labels(reason,)where reason is"global_rpm","player_rpm","daily_budget","player_daily_budget" - [ ] On allow: increment
maid_ai_rate_limit_allowed_totalcounter - [ ] Wrap in
with safe_observe("rate_limit_check"): - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_rate_limiter_metrics.py: - [ ] Test denial counter incremented with correct reason label
- [ ] Test allowed counter incremented on successful reservation
- [ ] Test no-op when
obs_registryisNone
2.7 Circuit Breaker Metrics¶
Package:
maid-engine| Priority: P1 | Dependencies: 1.4
- [ ] Modify
CircuitBreakerinpackages/maid-engine/src/maid_engine/ai/circuit_breaker.py: - [ ] Add
obs_registry: ObservabilityRegistry | None = NonetoCircuitBreaker.__init__() - [ ] On state transition: set
maid_ai_circuit_breaker_stategauge with labels(provider,)to0(CLOSED),1(OPEN),2(HALF_OPEN) - [ ] On trip: increment
maid_ai_circuit_breaker_trips_totalcounter with labels(provider,) - [ ] Wrap state transitions in
with safe_observe("circuit_breaker"): - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_circuit_breaker_metrics.py: - [ ] Test state gauge updated on transition
- [ ] Test trips counter incremented on trip
- [ ] Test no-op when
obs_registryisNone
2.8 MetricsCollector Unification¶
Package:
maid-engine| Priority: P1 | Dependencies: 1.8, 2.4
- [ ] Refactor
MetricsCollectorinpackages/maid-engine/src/maid_engine/api/admin/dashboard.py: - [ ] Where possible, read from existing Prometheus
Gauge/Counterobjects rather than re-computing - [ ]
collect_server_metrics()readsmaid_tick_duration_secondshistogram for tick stats - [ ]
collect_player_metrics()reads entity count gauges for player counts - [ ]
collect_world_metrics()reads entity type count gauges - [ ] Retain computed metrics for data not exposed via Prometheus (e.g., detailed per-room counts)
- [ ] Add
collect_ai_metrics() -> AIMetricsthat reads fromAICostTrackergauges - [ ] Add
AIMetricsdataclass:total_cost: float,total_tokens: int,costs_by_model: dict[str, float],active_providers: list[str] - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_metrics_collector_unified.py: - [ ] Test
collect_server_metrics()reads from Prometheus - [ ] Test
collect_ai_metrics()returns cost data - [ ] Test unified collector returns consistent data with Prometheus exposition
2.9 Database Metrics¶
Package:
maid-engine| Priority: P1 | Dependencies: 1.4
- [ ] Add database instrumentation hooks to
packages/maid-engine/src/maid_engine/storage/document_store.py: - [ ]
maid_db_query_duration_secondshistogram with labels(operation, collection)where operation is"get","get_many","create","update","delete","query","count" - [ ]
maid_db_operations_totalcounter with labels(operation, collection, status) - [ ]
maid_db_connections_activegauge for active connection count (PostgreSQL only) - [ ] Instrument
PostgresDocumentCollectionmethods:get(),get_many(),create(),update(),delete(),query(),count() - [ ] Wrap instrumentation in
with safe_observe("db_operation"): - [ ] Add
obs_registry: ObservabilityRegistry | None = NonetoDocumentStore.__init__()and propagate to collections - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_db_metrics.py: - [ ] Test query duration histogram observed on get
- [ ] Test operations counter incremented with correct labels
- [ ] Test no-op when
obs_registryisNone
2.10 Network Metrics¶
Package:
maid-engine| Priority: P1 | Dependencies: 1.4
- [ ] Add network instrumentation to
packages/maid-engine/src/maid_engine/net/web/server.py: - [ ]
maid_net_connections_activegauge with labels(protocol,)where protocol is"telnet","websocket" - [ ]
maid_net_bytes_sent_totalcounter with labels(protocol,) - [ ]
maid_net_bytes_received_totalcounter with labels(protocol,) - [ ]
maid_net_messages_sent_totalcounter with labels(protocol,) - [ ]
maid_net_messages_received_totalcounter with labels(protocol,) - [ ] Increment on send/receive in WebSocket handler
- [ ] Add network instrumentation to
packages/maid-engine/src/maid_engine/net/telnet/: - [ ] Same counter/gauge pattern for telnet connections
- [ ] Write unit tests in
packages/maid-engine/tests/observability/test_network_metrics.py: - [ ] Test connection gauge incremented on connect, decremented on disconnect
- [ ] Test bytes counters incremented on send/receive
- [ ] Test message counters incremented correctly
2.11 Sentry Integration (Optional)¶
Package:
maid-engine| Priority: P2 | Dependencies: 1.4
- [ ] Implement optional Sentry integration in
packages/maid-engine/src/maid_engine/observability/sentry.py: - [ ]
configure_sentry(settings: ObservabilitySettings) -> None:- [ ] Guard: return immediately if
sentry-sdkis not installed orsentry_dsnis empty - [ ] Call
sentry_sdk.init(dsn=settings.sentry_dsn, traces_sample_rate=settings.sentry_traces_sample_rate) - [ ] Configure
before_sendhook to add game context (tick number, player count, active commands) - [ ] Configure
before_send_transactionhook for trace sampling
- [ ] Guard: return immediately if
- [ ]
capture_game_exception(exc: Exception, context: MaidContext | None = None) -> None:- [ ] Adds
MaidContextfields as Sentry tags - [ ] Adds game state snapshot as Sentry extra data
- [ ] Adds
- [ ] Write unit tests in
packages/maid-engine/tests/observability/test_sentry.py: - [ ] Test
configure_sentry()is no-op whensentry-sdknot installed - [ ] Test
configure_sentry()is no-op whensentry_dsnis empty - [ ] Test
capture_game_exception()includes MaidContext tags (mock sentry_sdk)
Phase 3: Tracing, Dashboards, and Polish (Weeks 9–11) — P1/P2¶
3.1 OpenTelemetry Tracing Setup¶
Package:
maid-engine| Priority: P1 | Dependencies: 1.4
- [ ] Implement tracing setup in
packages/maid-engine/src/maid_engine/observability/tracing.py: - [ ]
configure_tracing(settings: ObservabilitySettings) -> Tracer:- [ ] If
opentelemetrynot installed: returnNoOpTracer()(fromopentelemetry.traceor custom stub) - [ ] If installed but
tracing_enabledisFalse: returnNoOpTracer() - [ ] Configure
TracerProviderwithBatchSpanProcessor - [ ] If
otlpextra installed: configureOTLPSpanExporterwith endpoint from env varOTEL_EXPORTER_OTLP_ENDPOINT - [ ] Set service name:
maid-engine - [ ] Set resource attributes:
service.version,deployment.environment
- [ ] If
- [ ]
NoOpTracerclass:- [ ]
start_span(name, **kwargs) -> NoOpSpanreturns context manager that does nothing - [ ] Used when OpenTelemetry is not available
- [ ]
- [ ]
TracingModeenum:MINIMAL,DEFAULT,VERBOSE- [ ]
MINIMAL: only command root spans - [ ]
DEFAULT: commands + AI calls + storage operations - [ ]
VERBOSE: all of above + event handlers + individual system ticks
- [ ]
- [ ] Write unit tests in
packages/maid-engine/tests/observability/test_tracing.py: - [ ] Test
NoOpTracerreturns no-op spans - [ ] Test
configure_tracing()returnsNoOpTracerwhen OTel not installed - [ ] Test
configure_tracing()returnsNoOpTracerwhen tracing disabled - [ ] Test
TracingModeenum values
3.2 Command and AI Tracing¶
Package:
maid-engine| Priority: P1 | Dependencies: 3.1, 1.14, 2.2
- [ ] Add tracing to
LayeredCommandRegistry.execute()inpackages/maid-engine/src/maid_engine/commands/registry.py: - [ ] Create root span
command:{command_name}with attributescommand,player_id,correlation_id - [ ] Only when
tracing_mode >= TracingMode.MINIMAL - [ ] Add tracing to
LLMProvider.complete()wrapper inpackages/maid-engine/src/maid_engine/ai/providers/base.py: - [ ] Create child span
llm.completewith attributesprovider,model,prompt_tokens,completion_tokens - [ ] Only when
tracing_mode >= TracingMode.DEFAULT - [ ] Add tracing to
EventBus.emit()inpackages/maid-engine/src/maid_engine/core/events.py: - [ ] Create child span
event:{event_type}with attributesevent_type,handler_count - [ ] Only when
tracing_mode >= TracingMode.VERBOSE - [ ] Add tracing to
DocumentStoreoperations: - [ ] Create child span
db:{operation}with attributescollection,operation - [ ] Only when
tracing_mode >= TracingMode.DEFAULT - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_command_tracing.py: - [ ] Test root span created for command execution
- [ ] Test child span created for AI completion
- [ ] Test no spans when
tracing_modeis too low - [ ] Test span attributes are correctly set
3.3 Grafana Dashboard Definitions¶
Package:
maid-engine| Priority: P1 | Dependencies: 1.8, 2.4
- [ ] Create
docs/dashboards/maid-server-health.json: - [ ] Grafana dashboard JSON definition for Server Health
- [ ] Panels:
- [ ] Tick loop duration histogram (p50, p95, p99)
- [ ] Tick rate gauge (ticks/second)
- [ ] Commands per second rate
- [ ] Command latency by command type
- [ ] Active connections by protocol (telnet/websocket)
- [ ] Entity counts by type
- [ ] Event emission rate by domain bucket
- [ ] Memory usage gauge
- [ ] Database query latency (p50, p95, p99)
- [ ] Health check status
- [ ] Variables:
, - [ ] Refresh interval: 10s
- [ ] Create
docs/dashboards/maid-ai-cost-overview.json: - [ ] Grafana dashboard JSON definition for AI Cost Overview
- [ ] Panels:
- [ ] Total AI cost (USD) over time
- [ ] Cost by model (stacked area)
- [ ] Token usage by model (stacked area)
- [ ] Request rate by provider
- [ ] Request latency by provider (p50, p95, p99)
- [ ] Rate limiter denial rate
- [ ] Circuit breaker state timeline
- [ ] Cost per player (top 10)
- [ ] Daily token budget remaining
- [ ] Variables:
,, `` - [ ] Refresh interval: 30s
3.4 Content Pack Metric API¶
Package:
maid-engine| Priority: P1 | Dependencies: 1.4, 1.8
- [ ] Add content pack metric registration to
ObservabilityRegistryinpackages/maid-engine/src/maid_engine/observability/registry.py: - [ ]
register_pack_counter(pack_name: str, name: str, description: str, labels: tuple[str, ...] = ()) -> Counter:- [ ] Validates prefix: metric name must start with
pack_name + "_"or bepack_nameitself - [ ] Validates labels against denylist (no
player_id,session_id,ip_address) - [ ] Full metric name:
maid_pack_{pack_name}_{name} - [ ] Subject to
CardinalityGuard - [ ] Handle name collisions: if metric already registered (e.g., two packs with same prefix), return existing metric and log a warning
- [ ] Validates prefix: metric name must start with
- [ ]
register_pack_histogram(pack_name: str, name: str, description: str, labels: tuple[str, ...] = (), buckets: tuple[float, ...] | None = None) -> Histogram:- [ ] Same validation as
register_pack_counter()
- [ ] Same validation as
- [ ]
register_pack_gauge(pack_name: str, name: str, description: str, labels: tuple[str, ...] = ()) -> Gauge:- [ ] Same validation as
register_pack_counter()
- [ ] Same validation as
- [ ]
LABEL_DENYLIST: frozenset[str] = frozenset({"player_id", "session_id", "ip_address", "email", "password"})for labels content packs may not use - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_pack_metrics.py: - [ ] Test
register_pack_counter()creates metric with correct prefix - [ ] Test prefix validation rejects metrics not starting with pack name
- [ ] Test label denylist rejects forbidden labels
- [ ] Test
CardinalityGuardis enforced for pack metrics
3.5 structlog Migration (Tier 1-2 Core Modules)¶
Package:
maid-engine| Priority: P1 | Dependencies: 1.6
- [ ] Migrate the following modules from
logging.getLogger()tostructlog.get_logger(): - [ ]
packages/maid-engine/src/maid_engine/core/engine.py - [ ]
packages/maid-engine/src/maid_engine/core/world.py - [ ]
packages/maid-engine/src/maid_engine/core/events.py - [ ]
packages/maid-engine/src/maid_engine/commands/registry.py - [ ]
packages/maid-engine/src/maid_engine/ai/providers/base.py - [ ]
packages/maid-engine/src/maid_engine/ai/rate_limiter.py - [ ]
packages/maid-engine/src/maid_engine/ai/circuit_breaker.py - [ ]
packages/maid-engine/src/maid_engine/net/web/server.py - [ ]
packages/maid-engine/src/maid_engine/storage/document_store.py - [ ]
packages/maid-engine/src/maid_engine/plugins/loader.py - [ ] Migration pattern for each module:
- [ ] Replace
import logging/logger = logging.getLogger(__name__)withimport structlog/logger = structlog.get_logger(__name__) - [ ] Replace
logger.info("message %s", arg)withlogger.info("message", arg=arg) - [ ] Add structured context where beneficial (e.g.,
logger.info("entity_created", entity_id=str(entity.id))) - [ ] Verify all existing tests pass after migration
3.6 Profiling Bridge — DROPPED¶
Dropped: The always-on Prometheus instrumentation (tick histograms, command counters, etc.) already covers the same data at higher quality than the session-based profiling bridge. The
ProfilingBridgewould only produce data when an admin runs@profile start, which is rare. Operators can correlate profiling sessions with Prometheus data via timestamps if needed.
3.7 Gauge Export Loop¶
Package:
maid-engine| Priority: P1 | Dependencies: 1.16, 2.4
- [ ] Implement gauge export background task in
packages/maid-engine/src/maid_engine/observability/hooks.py: - [ ]
async def gauge_export_loop(engine: GameEngine, interval: float = 15.0) -> None:- [ ] Periodically updates Prometheus gauges:
- [ ]
maid_entities_totalgauge with labels(type,)fromWorld.entity_type_count() - [ ]
maid_players_onlinegauge from active session count - [ ]
maid_rooms_totalgauge fromWorld.entity_type_count("room") - [ ]
maid_uptime_secondsgauge fromGameEngine.uptime - [ ] Calls
AICostTrackercleanup for stale per-player entries
- [ ] Started by
GameEngine.start()as anasyncio.Task - [ ] Cancelled by
GameEngine.stop() - [ ] Write unit tests in
packages/maid-engine/tests/observability/test_gauge_export.py: - [ ] Test entity gauges updated from World counters
- [ ] Test player online gauge reflects active sessions
- [ ] Test loop runs at configured interval
- [ ] Test loop handles exceptions gracefully (continues running)
3.8 Client-Side Error Ingestion — DROPPED¶
Dropped: No production web client exists to consume this endpoint. Adding a public-facing endpoint with PII sanitization and rate limiting for a theoretical future consumer is premature. Reintroduce when a web client ships.
3.9 SLO/SLI Definitions and Alert Rules¶
Package:
maid-engine| Priority: P1 | Dependencies: 1.8
- [ ] Create
deploy/prometheus/maid_alert_rules.yml.j2(Jinja2 template): - [ ] SLO: Tick loop latency p99 < 250ms
- [ ]
MaidTickLoopSlowwarning when p99 > 200ms for 5 minutes - [ ]
MaidTickLoopStalledcritical when no tick for > 10 seconds
- [ ]
- [ ] SLO: Command latency p99 < 500ms
- [ ]
MaidCommandSlowwarning when p99 > 400ms for 5 minutes
- [ ]
- [ ] SLO: AI request error rate < 5%
- [ ]
MaidAIErrorRateHighwarning when error rate > 3% for 10 minutes - [ ]
MaidAIErrorRateCriticalcritical when error rate > 10% for 5 minutes
- [ ]
- [ ] SLO: AI daily budget
- [ ]
MaidAIBudgetWarningwarning when daily spend > 80% of budget - [ ]
MaidAIBudgetExhaustedcritical when daily spend > 95% of budget
- [ ]
- [ ] Infrastructure:
- [ ]
MaidTargetDowncritical when target is down for > 2 minutes - [ ]
MaidHighMemorywarning when RSS > 80% of limit
- [ ]
- [ ] Multi-window burn-rate alerting for SLOs:
- [ ] 1h window: fast burn (14.4x error budget)
- [ ] 6h window: slow burn (6x error budget)
- [ ] Create
deploy/prometheus/maid_alert_config.yml: - [ ] Template variables for customization:
tick_rate,command_latency_target,ai_budget_daily_usd - [ ] Create
deploy/prometheus/alertmanager_routing.yml: - [ ] Route
criticalalerts to PagerDuty/Slack - [ ] Route
warningalerts to Slack only - [ ] Group by
alertnamewith 5-minute group wait
3.10 Runbooks¶
Package: documentation | Priority: P1 | Dependencies: 3.9
- [ ] Create
docs/runbooks/MaidTickLoopStalled.md: - [ ] Symptom description
- [ ] Diagnostic steps (check tick duration histogram, check system timing, check event loop blocking)
- [ ] Common causes (blocking I/O in system, large entity count, database timeout)
- [ ] Remediation steps
- [ ] Create
docs/runbooks/MaidAIBudgetExhausted.md: - [ ] Symptom description
- [ ] Diagnostic steps (check AI cost gauges, check per-model costs, check per-player costs)
- [ ] Common causes (runaway NPC dialogue, misconfigured rate limits, pricing change)
- [ ] Remediation steps (adjust rate limits, disable specific providers, increase budget)
- [ ] Create
docs/runbooks/MaidTargetDown.md: - [ ] Symptom description
- [ ] Diagnostic steps (check health endpoints, check process status, check logs)
- [ ] Common causes (OOM crash, unhandled exception, port conflict)
- [ ] Remediation steps
3.11 Operator Guide Documentation¶
Package: documentation | Priority: P2 | Dependencies: 3.3, 3.9, 3.10
- [ ] Create
docs/guides/operator-observability.md: - [ ] Overview of observability architecture
- [ ] Configuration reference for all
MAID_OBSERVABILITY__*environment variables - [ ] Profile descriptions (dev/staging/prod)
- [ ] Prometheus scrape configuration example
- [ ] Grafana dashboard import instructions
- [ ] Alert rule deployment instructions
- [ ] structlog configuration and log format reference
- [ ] Health endpoint documentation
- [ ] Tracing setup with OpenTelemetry Collector
- [ ] Sentry integration guide
- [ ] Content pack metric API usage for pack authors
- [ ] Log rotation configuration:
logrotateexamples for audit logs (90-day retention) and operational logs (14-day retention)
3.12 Alertmanager Routing Template¶
Package:
maid-engine| Priority: P2 | Dependencies: 3.9
- [ ] Create
deploy/alertmanager/maid_routes.yml.j2: - [ ] Jinja2 template for Alertmanager routing configuration
- [ ] Variables:
slack_webhook_url,pagerduty_service_key,email_to - [ ] Routes:
- [ ]
severity: criticalto PagerDuty + Slack - [ ]
severity: warningto Slack only - [ ]
alertname: MaidAIBudgetWarningto dedicated AI cost channel
- [ ]
- [ ] Inhibition rules: suppress warning when critical is firing for same alertname
Files Created (Summary)¶
packages/maid-engine/src/maid_engine/observability/
__init__.py
registry.py # ObservabilityRegistry protocol + Default + Null
logging.py # structlog config, processors, stdlib bridge
metrics.py # PrometheusMeter, ScrapeCache, CardinalityGuard, SystemTickAggregator
tracing.py # OTel tracer setup (no-op if not installed)
context.py # MaidContext frozen dataclass, ContextVar
health.py # HealthChecker, HealthStatus, HealthCheckResult
internal_server.py # Dedicated internal port ASGI server
middleware.py # ASGI ObservabilityMiddleware
ai_metrics.py # PricingConfig, AICostTracker, AICompletionEvent, calculate_cost()
hooks.py # gauge_export_loop() background task
safe_observe.py # safe_observe() context manager
privacy.py # PlayerIDAnonymizer, CommandRedactor
sentry.py # Optional Sentry integration
log_sampling.py # AdaptiveSampler, sampling_processor
bridges/
__init__.py
packages/maid-engine/tests/observability/
__init__.py
test_settings.py
test_context.py
test_registry.py
test_safe_observe.py
test_logging.py
test_privacy.py
test_metrics.py
test_log_sampling.py
test_scrape_cache.py
test_health.py
test_internal_server.py
test_tick_instrumentation.py
test_command_instrumentation.py
test_event_instrumentation.py
test_entity_counting.py
test_engine_integration.py
test_middleware.py
test_llm_instrumentation.py
test_ai_metrics.py
test_rate_limiter_metrics.py
test_circuit_breaker_metrics.py
test_metrics_collector_unified.py
test_db_metrics.py
test_network_metrics.py
test_sentry.py
test_tracing.py
test_command_tracing.py
test_pack_metrics.py
test_profiling_bridge.py # DROPPED — profiling bridge removed
test_gauge_export.py
bench_overhead.py
packages/maid-engine/tests/ai/
test_completion_chunk.py
test_streaming_fix.py
packages/maid-engine/tests/api/
test_ai_pricing_api.py
deploy/prometheus/
maid_alert_rules.yml.j2
maid_alert_config.yml
alertmanager_routing.yml
deploy/alertmanager/
maid_routes.yml.j2
docs/dashboards/
maid-server-health.json
maid-ai-cost-overview.json
docs/runbooks/
MaidTickLoopStalled.md
MaidAIBudgetExhausted.md
MaidTargetDown.md
docs/guides/
operator-observability.md
Files Modified (Summary)¶
packages/maid-engine/pyproject.toml # Add structlog, prometheus-client deps + optional extras
packages/maid-engine/src/maid_engine/config/settings.py # Add ObservabilitySettings model
packages/maid-engine/src/maid_engine/core/engine.py # Create ObservabilityRegistry, start InternalServer, last_tick_monotonic
packages/maid-engine/src/maid_engine/core/events.py # Add obs_registry to EventBus, event counter
packages/maid-engine/src/maid_engine/core/world.py # Add O(1) entity counters
packages/maid-engine/src/maid_engine/commands/registry.py # Add command instrumentation and tracing
packages/maid-engine/src/maid_engine/ai/providers/base.py # LLMProvider refactor, CompletionChunk, TokenUsage
packages/maid-engine/src/maid_engine/ai/providers/anthropic.py # Rename complete() to _do_complete()
packages/maid-engine/src/maid_engine/ai/providers/openai.py # Rename complete() to _do_complete()
packages/maid-engine/src/maid_engine/ai/providers/ollama.py # Rename complete() to _do_complete()
packages/maid-engine/src/maid_engine/ai/rate_limiter.py # Add denial/allowed counters
packages/maid-engine/src/maid_engine/ai/circuit_breaker.py # Add state gauge and trips counter
packages/maid-engine/src/maid_engine/api/admin/dashboard.py # Unify MetricsCollector with Prometheus, add AIMetrics
packages/maid-engine/src/maid_engine/storage/document_store.py # Add database operation metrics
packages/maid-engine/src/maid_engine/net/web/server.py # Add network metrics, ASGI middleware
Dependency Graph¶
Phase 1 (Foundation):
1.1 Package Structure ----+
1.2 Settings ------------ | -+
1.3 MaidContext --------- | -+-+
1.4 Registry ----------- (1.2, 1.3) -+
1.5 safe_observe ------- (1.4) -+
1.6 structlog config --- (1.3) -+
1.7 Privacy ------------ (1.2) -+
1.8 Prometheus Metrics - (1.4) -+
1.9 Log Sampling ------- (1.6) -+
1.10 ScrapeCache ------- (1.8) -+
1.11 HealthChecker ----- (1.4) -+
1.12 Internal Server --- (1.10, 1.11) -+
1.13 Tick Instrumentation (1.4, 1.5) -+
1.14 Command Instrumentation (1.3, 1.4, 1.5)
1.15 EventBus Instrumentation (1.4, 1.5)
1.16 Entity Counting --- (none, parallel)
1.17 GameEngine Integration (1.4, 1.11, 1.12, 1.13)
1.18 ASGI Middleware --- (1.3, 1.4)
1.19 Benchmark Gate ---- (1.13, 1.14, 1.15, 1.16) -- BLOCKER for Phase 2
Phase 2 (AI and Game Metrics):
2.1 CompletionChunk ---- (none)
2.2 LLMProvider Refactor (1.4, 2.1)
2.3 Streaming Fix ------ (2.1)
2.4 AI Cost Calculation - (2.1)
2.5 Pricing Admin API -- (2.4)
2.6 Rate Limiter Metrics (1.4)
2.7 Circuit Breaker Metrics (1.4)
2.8 MetricsCollector --- (1.8, 2.4)
2.9 Database Metrics --- (1.4)
2.10 Network Metrics --- (1.4)
2.11 Sentry ------------ (1.4)
Phase 3 (Tracing, Dashboards, Polish):
3.1 OTel Tracing ------- (1.4)
3.2 Command/AI Tracing - (3.1, 1.14, 2.2)
3.3 Grafana Dashboards - (1.8, 2.4)
3.4 Pack Metric API ---- (1.4, 1.8)
3.5 structlog Migration - (1.6)
3.6 Profiling Bridge --- DROPPED
3.7 Gauge Export Loop -- (1.16, 2.4)
3.8 Client Error Ingestion DROPPED
3.9 SLO/Alert Rules ---- (1.8)
3.10 Runbooks ---------- (3.9)
3.11 Operator Guide ---- (3.3, 3.9, 3.10)
3.12 Alertmanager Template (3.9)
Success Criteria¶
- [ ] All observability instrumentation is wrapped in
safe_observe()and never crashes the game - [ ] Tick loop overhead with observability enabled is < 2% at 4 ticks/second (benchmark validated)
- [ ] Command execution overhead is < 1ms additional latency per command
- [ ]
/metricsendpoint returns valid Prometheus text exposition format - [ ]
/healthz,/readyz,/livezendpoints respond correctly on internal port 9090 - [ ] structlog produces JSON output in production and console output in development
- [ ] Player IDs are HMAC-anonymized in all log output when
anonymize_player_idsis enabled - [ ] Non-whitelisted command arguments are redacted in log output
- [ ]
AdaptiveSampleralways passes ERROR/CRITICAL/audit events through - [ ]
CardinalityGuardprevents label explosion beyond configured limit - [ ] AI cost tracking accurately calculates USD cost from token usage
- [ ]
AICostTrackeraccumulates costs correctly across multiple providers and models - [ ]
LLMProvider.complete()wrapper records duration, tokens, and request count - [ ]
complete_streaming()returnsCompletionChunkobjects with cancellation safety - [ ] Circuit breaker state gauge reflects current state (CLOSED/OPEN/HALF_OPEN)
- [ ] Rate limiter denial counter includes reason label
- [ ] Database and network metrics are collected with correct labels
- [ ] OpenTelemetry tracing is no-op when
opentelemetrypackage is not installed - [ ] Sentry integration is no-op when
sentry-sdkis not installed or DSN is empty - [ ] Content pack metrics are prefixed and label-validated
- [ ] Grafana dashboard JSON files are importable
- [ ] Alert rules cover tick loop, command latency, AI error rate, and AI budget SLOs
- [ ] Runbooks exist for all critical alerts
- [ ]
NullObservabilityRegistryallows the engine to run with zero observability overhead - [ ] All new code has >80% test coverage
- [ ] All public APIs have Google-style docstrings
- [ ] MyPy strict mode passes on all new modules
Prerequisites / Blockers from Other Design Docs¶
- No hard external blockers. The observability system is self-contained within
maid-engineinfrastructure. - Soft dependency on Doc 01 (Durable Persistence):
AICostTrackercan persist pricing configuration viaDocumentStore. If Doc 01 has not landed, pricing falls back to file-based or environment variable configuration. TheDocumentStoreintegration inload_pricing()is deferred to Phase 3 (task 2.5). - Soft dependency on Doc 02 (Database Migrations): Database metrics instrumentation hooks into
PostgresDocumentStore. If the migration system changesDocumentStoreinternals, the instrumentation hooks in task 2.9 may need adjustment. - Integration with existing
ProfileManager: The profiling bridge has been dropped from this plan. The existingProfileManageratpackages/maid-engine/src/maid_engine/profiling/continues to operate independently; operators can correlate profiling sessions with Prometheus data via timestamps. - Integration with existing
MetricsCollector: Task 2.8 refactors the admin dashboardMetricsCollectorto read from Prometheus counters/gauges instead of re-computing. This is a non-breaking change; the admin API contract remains identical. - The existing
AuditLoggeratpackages/maid-engine/src/maid_engine/logging/audit.pyis not replaced. Audit logging and operational observability are complementary systems. TheAdaptiveSampler(task 1.9) ensures audit-tagged events always pass through sampling.