crprotocol 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crp/__init__.py +126 -0
- crp/__main__.py +8 -0
- crp/_typing.py +27 -0
- crp/_version.py +5 -0
- crp/adapters.py +31 -0
- crp/advanced/__init__.py +40 -0
- crp/advanced/auto_ingest.py +400 -0
- crp/advanced/cqs.py +235 -0
- crp/advanced/cross_window.py +477 -0
- crp/advanced/curator.py +265 -0
- crp/advanced/feedback.py +146 -0
- crp/advanced/hierarchical.py +211 -0
- crp/advanced/meta_learning.py +401 -0
- crp/advanced/parallel.py +98 -0
- crp/advanced/review_cycle.py +329 -0
- crp/advanced/scale_mode.py +129 -0
- crp/advanced/source_grounding.py +207 -0
- crp/ckf/__init__.py +35 -0
- crp/ckf/community.py +377 -0
- crp/ckf/fabric.py +445 -0
- crp/ckf/gc.py +175 -0
- crp/ckf/graph_walk.py +87 -0
- crp/ckf/merge.py +133 -0
- crp/ckf/pattern_query.py +122 -0
- crp/ckf/pubsub.py +128 -0
- crp/ckf/semantic.py +207 -0
- crp/cli/__init__.py +7 -0
- crp/cli/main.py +329 -0
- crp/cli/sidecar.py +929 -0
- crp/cli/startup.py +272 -0
- crp/continuation/__init__.py +103 -0
- crp/continuation/completion.py +348 -0
- crp/continuation/degradation.py +157 -0
- crp/continuation/document_map.py +160 -0
- crp/continuation/flow.py +109 -0
- crp/continuation/gap.py +419 -0
- crp/continuation/manager.py +484 -0
- crp/continuation/quality_monitor.py +179 -0
- crp/continuation/stitch.py +419 -0
- crp/continuation/trigger.py +142 -0
- crp/continuation/voice.py +157 -0
- crp/core/__init__.py +69 -0
- crp/core/batch.py +77 -0
- crp/core/circuit_breaker.py +116 -0
- crp/core/config.py +377 -0
- crp/core/context_tools.py +540 -0
- crp/core/dispatch_router.py +3977 -0
- crp/core/errors.py +128 -0
- crp/core/extraction_facade.py +384 -0
- crp/core/facilitator.py +713 -0
- crp/core/idempotency.py +215 -0
- crp/core/orchestrator.py +1435 -0
- crp/core/relay_strategies.py +613 -0
- crp/core/security_manager.py +140 -0
- crp/core/session.py +134 -0
- crp/core/task_intent.py +36 -0
- crp/core/window.py +363 -0
- crp/envelope/__init__.py +30 -0
- crp/envelope/builder.py +288 -0
- crp/envelope/decomposer.py +236 -0
- crp/envelope/formatter.py +168 -0
- crp/envelope/packer.py +211 -0
- crp/envelope/reranker.py +209 -0
- crp/envelope/scoring.py +310 -0
- crp/extraction/__init__.py +45 -0
- crp/extraction/complexity.py +96 -0
- crp/extraction/contradiction.py +132 -0
- crp/extraction/pipeline.py +360 -0
- crp/extraction/quality_gate.py +237 -0
- crp/extraction/stage1_regex.py +173 -0
- crp/extraction/stage2_statistical.py +244 -0
- crp/extraction/stage3_gliner.py +210 -0
- crp/extraction/stage4_uie.py +183 -0
- crp/extraction/stage5_discourse.py +175 -0
- crp/extraction/stage6_llm.py +178 -0
- crp/extraction/structured_output.py +219 -0
- crp/extraction/types.py +299 -0
- crp/license_guard.py +722 -0
- crp/observability/__init__.py +30 -0
- crp/observability/audit.py +118 -0
- crp/observability/events.py +233 -0
- crp/observability/metrics.py +264 -0
- crp/observability/quality.py +135 -0
- crp/observability/structured_logging.py +81 -0
- crp/observability/telemetry.py +117 -0
- crp/provenance/__init__.py +314 -0
- crp/provenance/_embeddings.py +97 -0
- crp/provenance/_types.py +378 -0
- crp/provenance/attribution_scorer.py +252 -0
- crp/provenance/claim_detector.py +229 -0
- crp/provenance/contradiction_detector.py +243 -0
- crp/provenance/distortion_detector.py +397 -0
- crp/provenance/entailment_verifier.py +358 -0
- crp/provenance/fabrication_detector.py +203 -0
- crp/provenance/hallucination_scorer.py +320 -0
- crp/provenance/omission_analyzer.py +106 -0
- crp/provenance/provenance_chain.py +205 -0
- crp/provenance/report_generator.py +440 -0
- crp/providers/__init__.py +43 -0
- crp/providers/anthropic.py +270 -0
- crp/providers/base.py +135 -0
- crp/providers/custom.py +63 -0
- crp/providers/diagnostic.py +251 -0
- crp/providers/llamacpp.py +224 -0
- crp/providers/manager.py +139 -0
- crp/providers/ollama.py +243 -0
- crp/providers/openai.py +628 -0
- crp/providers/tokenizers.py +48 -0
- crp/py.typed +0 -0
- crp/resources/__init__.py +53 -0
- crp/resources/adaptive_allocator.py +525 -0
- crp/resources/cost_model.py +388 -0
- crp/resources/overhead_manager.py +217 -0
- crp/resources/resource_manager.py +262 -0
- crp/schemas/__init__.py +20 -0
- crp/schemas/cost-estimate.json +33 -0
- crp/schemas/crp-error.json +43 -0
- crp/schemas/envelope-preview.json +40 -0
- crp/schemas/persisted-state-header.json +27 -0
- crp/schemas/quality-report.json +94 -0
- crp/schemas/session-handle.json +33 -0
- crp/schemas/session-status.json +57 -0
- crp/schemas/stream-event.json +18 -0
- crp/schemas/task-intent.json +42 -0
- crp/security/__init__.py +93 -0
- crp/security/audit_trail.py +392 -0
- crp/security/binding.py +192 -0
- crp/security/compliance.py +813 -0
- crp/security/consent.py +593 -0
- crp/security/embedding_defense.py +161 -0
- crp/security/encryption.py +202 -0
- crp/security/injection.py +335 -0
- crp/security/integrity.py +267 -0
- crp/security/privacy.py +662 -0
- crp/security/quarantine.py +249 -0
- crp/security/rbac.py +221 -0
- crp/security/validation.py +164 -0
- crp/state/__init__.py +31 -0
- crp/state/cold_storage.py +258 -0
- crp/state/compaction.py +263 -0
- crp/state/critical_state.py +104 -0
- crp/state/event_log.py +313 -0
- crp/state/fact.py +189 -0
- crp/state/serialization.py +189 -0
- crp/state/session_cleanup.py +77 -0
- crp/state/snapshot.py +290 -0
- crp/state/warm_store.py +346 -0
- crprotocol-2.0.0.dist-info/METADATA +1295 -0
- crprotocol-2.0.0.dist-info/RECORD +153 -0
- crprotocol-2.0.0.dist-info/WHEEL +4 -0
- crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
- crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
- crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Observability — events, metrics, audit log, quality reporting, telemetry."""
|
|
4
|
+
|
|
5
|
+
from crp.observability.audit import AuditLog
|
|
6
|
+
from crp.observability.events import ALL_EVENT_TYPES, CRPEvent, EventEmitter
|
|
7
|
+
from crp.observability.metrics import (
|
|
8
|
+
ExportFormat,
|
|
9
|
+
HealthMonitor,
|
|
10
|
+
HealthStatus,
|
|
11
|
+
MetricsExporter,
|
|
12
|
+
)
|
|
13
|
+
from crp.observability.quality import QualityReport, QualityReporter, QualityTier
|
|
14
|
+
from crp.observability.telemetry import TelemetryWriter, WindowTelemetry
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ALL_EVENT_TYPES",
|
|
18
|
+
"AuditLog",
|
|
19
|
+
"CRPEvent",
|
|
20
|
+
"EventEmitter",
|
|
21
|
+
"ExportFormat",
|
|
22
|
+
"HealthMonitor",
|
|
23
|
+
"HealthStatus",
|
|
24
|
+
"MetricsExporter",
|
|
25
|
+
"QualityReport",
|
|
26
|
+
"QualityReporter",
|
|
27
|
+
"QualityTier",
|
|
28
|
+
"TelemetryWriter",
|
|
29
|
+
"WindowTelemetry",
|
|
30
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Audit log — reconstruct sessions from recorded events (§8.9).
|
|
4
|
+
|
|
5
|
+
AuditLog stores every emitted CRPEvent and provides query helpers to:
|
|
6
|
+
* Replay the full timeline for a session or time range.
|
|
7
|
+
* Filter by event type.
|
|
8
|
+
* Reconstruct how many facts were created / superseded / archived.
|
|
9
|
+
|
|
10
|
+
Design goals:
|
|
11
|
+
* In-memory by default (no external DB).
|
|
12
|
+
* Thread-safe append + query.
|
|
13
|
+
* Easy to hook into EventEmitter via ``emitter.on_all(audit.record)``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import threading
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from crp.observability.events import CRPEvent
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuditLog:
|
|
25
|
+
"""Immutable append-only event log with query support.
|
|
26
|
+
|
|
27
|
+
Usage::
|
|
28
|
+
|
|
29
|
+
audit = AuditLog()
|
|
30
|
+
emitter.on_all(audit.record) # auto-capture every event
|
|
31
|
+
...
|
|
32
|
+
timeline = audit.query(session_id="abc")
|
|
33
|
+
fact_events = audit.query(event_type="fact.created")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
self._events: list[CRPEvent] = []
|
|
39
|
+
|
|
40
|
+
# -- recording --------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def record(self, event: CRPEvent) -> None:
|
|
43
|
+
"""Append an event (thread-safe, never raises)."""
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._events.append(event)
|
|
46
|
+
|
|
47
|
+
# -- querying ---------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def query(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
event_type: str | None = None,
|
|
53
|
+
session_id: str | None = None,
|
|
54
|
+
since: float | None = None,
|
|
55
|
+
until: float | None = None,
|
|
56
|
+
) -> list[CRPEvent]:
|
|
57
|
+
"""Return events matching all supplied filters.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
event_type: Exact event type string (e.g. "dispatch.completed").
|
|
61
|
+
session_id: Match events whose ``data["session_id"]`` equals this.
|
|
62
|
+
since: Only events with ``timestamp >= since``.
|
|
63
|
+
until: Only events with ``timestamp <= until``.
|
|
64
|
+
"""
|
|
65
|
+
with self._lock:
|
|
66
|
+
results: list[CRPEvent] = []
|
|
67
|
+
for ev in self._events:
|
|
68
|
+
if event_type is not None and ev.event_type != event_type:
|
|
69
|
+
continue
|
|
70
|
+
if session_id is not None and ev.data.get("session_id") != session_id:
|
|
71
|
+
continue
|
|
72
|
+
if since is not None and ev.timestamp < since:
|
|
73
|
+
continue
|
|
74
|
+
if until is not None and ev.timestamp > until:
|
|
75
|
+
continue
|
|
76
|
+
results.append(ev)
|
|
77
|
+
return results
|
|
78
|
+
|
|
79
|
+
def reconstruct_session(self, session_id: str) -> dict[str, Any]:
|
|
80
|
+
"""Build a summary dict for *session_id* from the event stream.
|
|
81
|
+
|
|
82
|
+
Returns a dict with keys:
|
|
83
|
+
* ``session_id``
|
|
84
|
+
* ``events`` — full ordered list of events (as dicts).
|
|
85
|
+
* ``dispatch_count`` — number of dispatch.completed events.
|
|
86
|
+
* ``facts_created`` — count of fact.created events.
|
|
87
|
+
* ``facts_superseded`` — count of fact.superseded events.
|
|
88
|
+
* ``duration_s`` — seconds between first and last event.
|
|
89
|
+
"""
|
|
90
|
+
events = self.query(session_id=session_id)
|
|
91
|
+
dispatch_count = sum(1 for e in events if e.event_type == "dispatch.completed")
|
|
92
|
+
facts_created = sum(1 for e in events if e.event_type == "fact.created")
|
|
93
|
+
facts_superseded = sum(1 for e in events if e.event_type == "fact.superseded")
|
|
94
|
+
|
|
95
|
+
if events:
|
|
96
|
+
duration = events[-1].timestamp - events[0].timestamp
|
|
97
|
+
else:
|
|
98
|
+
duration = 0.0
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"session_id": session_id,
|
|
102
|
+
"events": [e.to_dict() for e in events],
|
|
103
|
+
"dispatch_count": dispatch_count,
|
|
104
|
+
"facts_created": facts_created,
|
|
105
|
+
"facts_superseded": facts_superseded,
|
|
106
|
+
"duration_s": round(duration, 3),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def count(self) -> int:
|
|
111
|
+
"""Total number of recorded events."""
|
|
112
|
+
with self._lock:
|
|
113
|
+
return len(self._events)
|
|
114
|
+
|
|
115
|
+
def clear(self) -> None:
|
|
116
|
+
"""Remove all stored events (useful in testing)."""
|
|
117
|
+
with self._lock:
|
|
118
|
+
self._events.clear()
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Event emitter for CRP observability — structured event bus (§09 §9.5).
|
|
4
|
+
|
|
5
|
+
EventEmitter is a lightweight publish/subscribe bus that lets CLI,
|
|
6
|
+
diagnostics, and future metrics sinks observe protocol activity
|
|
7
|
+
without coupling to internals.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("crp.events")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Canonical event type constants (§8.9) — 30+ named event types.
|
|
23
|
+
#
|
|
24
|
+
# Naming convention: ``<domain>.<action>`` (e.g. "session.created").
|
|
25
|
+
# All lowercase, dots as separators, past-tense verbs.
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
# Session lifecycle
|
|
29
|
+
SESSION_CREATED = "session.created"
|
|
30
|
+
SESSION_CLOSED = "session.closed"
|
|
31
|
+
SESSION_EXPIRED = "session.expired"
|
|
32
|
+
SESSION_RESTORED = "session.restored"
|
|
33
|
+
|
|
34
|
+
# Dispatch / window lifecycle
|
|
35
|
+
DISPATCH_STARTED = "dispatch.started"
|
|
36
|
+
DISPATCH_COMPLETED = "dispatch.completed"
|
|
37
|
+
DISPATCH_FAILED = "dispatch.failed"
|
|
38
|
+
WINDOW_OPENED = "window.opened"
|
|
39
|
+
WINDOW_COMPLETED = "window.completed"
|
|
40
|
+
WINDOW_CONTINUED = "window.continued"
|
|
41
|
+
WINDOW_CANCELLED = "window.cancelled"
|
|
42
|
+
|
|
43
|
+
# Extraction
|
|
44
|
+
FACT_CREATED = "fact.created"
|
|
45
|
+
FACT_SUPERSEDED = "fact.superseded"
|
|
46
|
+
FACT_COMPACTED = "fact.compacted"
|
|
47
|
+
FACT_ARCHIVED = "fact.archived"
|
|
48
|
+
FACT_RESTORED = "fact.restored"
|
|
49
|
+
EXTRACTION_COMPLETED = "extraction.completed"
|
|
50
|
+
|
|
51
|
+
# Ingestion
|
|
52
|
+
INGEST_STARTED = "ingest.started"
|
|
53
|
+
INGEST_COMPLETED = "ingest.completed"
|
|
54
|
+
|
|
55
|
+
# Envelope / context
|
|
56
|
+
ENVELOPE_BUILT = "envelope.built"
|
|
57
|
+
ENVELOPE_SATURATED = "envelope.saturated" # saturation > 0.95
|
|
58
|
+
CONTEXT_OVERFLOW = "context.overflow"
|
|
59
|
+
CONTEXT_TRUNCATED = "context.truncated"
|
|
60
|
+
|
|
61
|
+
# Budget / cost
|
|
62
|
+
BUDGET_WARNING = "budget.warning"
|
|
63
|
+
BUDGET_EXHAUSTED = "budget.exhausted"
|
|
64
|
+
OVERHEAD_SHED = "overhead.shed"
|
|
65
|
+
COST_RECORDED = "cost.recorded"
|
|
66
|
+
|
|
67
|
+
# Provider / LLM
|
|
68
|
+
PROVIDER_CONNECTED = "provider.connected"
|
|
69
|
+
PROVIDER_DISCONNECTED = "provider.disconnected"
|
|
70
|
+
PROVIDER_ERROR = "provider.error"
|
|
71
|
+
PROVIDER_RETRY = "provider.retry"
|
|
72
|
+
|
|
73
|
+
# Quality
|
|
74
|
+
QUALITY_ASSESSED = "quality.assessed"
|
|
75
|
+
QUALITY_DEGRADED = "quality.degraded"
|
|
76
|
+
|
|
77
|
+
# Security
|
|
78
|
+
AUTH_SUCCESS = "auth.success"
|
|
79
|
+
AUTH_FAILURE = "auth.failure"
|
|
80
|
+
|
|
81
|
+
# Health
|
|
82
|
+
HEALTH_CHECK_PASSED = "health.check.passed"
|
|
83
|
+
HEALTH_CHECK_FAILED = "health.check.failed"
|
|
84
|
+
|
|
85
|
+
# Startup
|
|
86
|
+
STARTUP_COMPLETED = "startup.completed"
|
|
87
|
+
STARTUP_FAILED = "startup.failed"
|
|
88
|
+
|
|
89
|
+
# Collect all event types for programmatic access (useful for audit/metrics).
|
|
90
|
+
ALL_EVENT_TYPES: frozenset[str] = frozenset([
|
|
91
|
+
SESSION_CREATED, SESSION_CLOSED, SESSION_EXPIRED, SESSION_RESTORED,
|
|
92
|
+
DISPATCH_STARTED, DISPATCH_COMPLETED, DISPATCH_FAILED,
|
|
93
|
+
WINDOW_OPENED, WINDOW_COMPLETED, WINDOW_CONTINUED, WINDOW_CANCELLED,
|
|
94
|
+
FACT_CREATED, FACT_SUPERSEDED, FACT_COMPACTED, FACT_ARCHIVED,
|
|
95
|
+
FACT_RESTORED, EXTRACTION_COMPLETED,
|
|
96
|
+
INGEST_STARTED, INGEST_COMPLETED,
|
|
97
|
+
ENVELOPE_BUILT, ENVELOPE_SATURATED, CONTEXT_OVERFLOW, CONTEXT_TRUNCATED,
|
|
98
|
+
BUDGET_WARNING, BUDGET_EXHAUSTED, OVERHEAD_SHED, COST_RECORDED,
|
|
99
|
+
PROVIDER_CONNECTED, PROVIDER_DISCONNECTED, PROVIDER_ERROR, PROVIDER_RETRY,
|
|
100
|
+
QUALITY_ASSESSED, QUALITY_DEGRADED,
|
|
101
|
+
AUTH_SUCCESS, AUTH_FAILURE,
|
|
102
|
+
HEALTH_CHECK_PASSED, HEALTH_CHECK_FAILED,
|
|
103
|
+
STARTUP_COMPLETED, STARTUP_FAILED,
|
|
104
|
+
])
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Event dataclass
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class CRPEvent:
|
|
113
|
+
"""Single protocol event."""
|
|
114
|
+
|
|
115
|
+
event_type: str # e.g. "session.created", "dispatch.started", "window.completed"
|
|
116
|
+
timestamp: float = field(default_factory=time.time)
|
|
117
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict[str, Any]:
|
|
120
|
+
return {
|
|
121
|
+
"event_type": self.event_type,
|
|
122
|
+
"timestamp": self.timestamp,
|
|
123
|
+
"data": self.data,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# Listener type: callable that receives one CRPEvent.
|
|
128
|
+
Listener = Callable[[CRPEvent], None]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# EventEmitter
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class EventEmitter:
|
|
137
|
+
"""Simple synchronous event bus.
|
|
138
|
+
|
|
139
|
+
Usage::
|
|
140
|
+
|
|
141
|
+
emitter = EventEmitter()
|
|
142
|
+
emitter.on("dispatch.started", my_callback)
|
|
143
|
+
emitter.emit("dispatch.started", {"task": "..."})
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, *, max_listeners_per_event: int = 100) -> None:
|
|
147
|
+
self._listeners: dict[str, list[Listener]] = {}
|
|
148
|
+
self._global_listeners: list[Listener] = []
|
|
149
|
+
self._started = False
|
|
150
|
+
self._max_listeners = max_listeners_per_event
|
|
151
|
+
|
|
152
|
+
# -- lifecycle --------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def start(self) -> None:
|
|
155
|
+
"""Mark the emitter as active (startup step 6)."""
|
|
156
|
+
self._started = True
|
|
157
|
+
logger.debug("EventEmitter started")
|
|
158
|
+
|
|
159
|
+
def stop(self) -> None:
|
|
160
|
+
"""Stop accepting and delivering events."""
|
|
161
|
+
self._started = False
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def is_running(self) -> bool:
|
|
165
|
+
return self._started
|
|
166
|
+
|
|
167
|
+
# -- subscription -----------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def on(self, event_type: str, listener: Listener) -> None:
|
|
170
|
+
"""Subscribe *listener* to *event_type*.
|
|
171
|
+
|
|
172
|
+
Deduplicates listeners and enforces a per-event cap (§audit4 REL-M2).
|
|
173
|
+
"""
|
|
174
|
+
listeners = self._listeners.setdefault(event_type, [])
|
|
175
|
+
if listener in listeners:
|
|
176
|
+
return
|
|
177
|
+
if len(listeners) >= self._max_listeners:
|
|
178
|
+
logger.warning(
|
|
179
|
+
"Max listeners (%d) reached for event '%s' — ignoring",
|
|
180
|
+
self._max_listeners, event_type,
|
|
181
|
+
)
|
|
182
|
+
return
|
|
183
|
+
listeners.append(listener)
|
|
184
|
+
|
|
185
|
+
def on_all(self, listener: Listener) -> None:
|
|
186
|
+
"""Subscribe *listener* to every event type."""
|
|
187
|
+
if listener in self._global_listeners:
|
|
188
|
+
return
|
|
189
|
+
if len(self._global_listeners) >= self._max_listeners:
|
|
190
|
+
logger.warning(
|
|
191
|
+
"Max global listeners (%d) reached — ignoring",
|
|
192
|
+
self._max_listeners,
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
self._global_listeners.append(listener)
|
|
196
|
+
|
|
197
|
+
def off(self, event_type: str, listener: Listener) -> bool:
|
|
198
|
+
"""Remove a specific listener. Returns True if removed."""
|
|
199
|
+
listeners = self._listeners.get(event_type, [])
|
|
200
|
+
try:
|
|
201
|
+
listeners.remove(listener)
|
|
202
|
+
return True
|
|
203
|
+
except ValueError:
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
# -- emission ---------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def emit(self, event_type: str, data: dict[str, Any] | None = None) -> None:
|
|
209
|
+
"""Emit an event to all matching listeners.
|
|
210
|
+
|
|
211
|
+
If the emitter has not been started, events are silently dropped.
|
|
212
|
+
Listener exceptions are logged but never propagate.
|
|
213
|
+
"""
|
|
214
|
+
if not self._started:
|
|
215
|
+
return
|
|
216
|
+
event = CRPEvent(event_type=event_type, data=data or {})
|
|
217
|
+
|
|
218
|
+
for listener in self._listeners.get(event_type, []):
|
|
219
|
+
try:
|
|
220
|
+
listener(event)
|
|
221
|
+
except Exception:
|
|
222
|
+
logger.exception("Listener error for %s", event_type)
|
|
223
|
+
|
|
224
|
+
for listener in self._global_listeners:
|
|
225
|
+
try:
|
|
226
|
+
listener(event)
|
|
227
|
+
except Exception:
|
|
228
|
+
logger.exception("Global listener error for %s", event_type)
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def listener_count(self) -> int:
|
|
232
|
+
total = sum(len(v) for v in self._listeners.values())
|
|
233
|
+
return total + len(self._global_listeners)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Metrics export and health monitoring (§8.9, §05).
|
|
4
|
+
|
|
5
|
+
MetricsExporter — collects counters/gauges and exports as Prometheus,
|
|
6
|
+
OTLP-compatible JSON, or plain JSON.
|
|
7
|
+
HealthMonitor — liveness + readiness probes for the CRP runtime.
|
|
8
|
+
|
|
9
|
+
Design goals:
|
|
10
|
+
* Zero external dependencies (no prometheus_client, no opentelemetry).
|
|
11
|
+
* Thread-safe counters via stdlib threading.Lock.
|
|
12
|
+
* Easy to read: one class per concern, plain dicts for state.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
25
|
+
# MetricsExporter (§9.2)
|
|
26
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExportFormat(Enum):
|
|
30
|
+
"""Supported output formats."""
|
|
31
|
+
|
|
32
|
+
JSON = "json"
|
|
33
|
+
PROMETHEUS = "prometheus"
|
|
34
|
+
OTLP_JSON = "otlp_json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MetricsExporter:
|
|
38
|
+
"""Collects CRP metrics (counters, gauges, histograms) and exports them.
|
|
39
|
+
|
|
40
|
+
Usage::
|
|
41
|
+
|
|
42
|
+
mx = MetricsExporter()
|
|
43
|
+
mx.incr("dispatch.count")
|
|
44
|
+
mx.gauge("overhead.ratio", 0.12)
|
|
45
|
+
mx.observe("dispatch.latency_ms", 42.3)
|
|
46
|
+
print(mx.export(ExportFormat.JSON))
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
_MAX_HISTOGRAM_SIZE = 10_000 # Cap per-metric observations to prevent unbounded growth
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
# Counters: monotonically increasing integers.
|
|
54
|
+
self._counters: dict[str, int] = {}
|
|
55
|
+
# Gauges: point-in-time values that can go up or down.
|
|
56
|
+
self._gauges: dict[str, float] = {}
|
|
57
|
+
# Histograms: list of observed values (for percentiles).
|
|
58
|
+
self._histograms: dict[str, list[float]] = {}
|
|
59
|
+
|
|
60
|
+
# -- recording --------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def incr(self, name: str, delta: int = 1) -> None:
|
|
63
|
+
"""Increment a counter by *delta* (default 1)."""
|
|
64
|
+
with self._lock:
|
|
65
|
+
self._counters[name] = self._counters.get(name, 0) + delta
|
|
66
|
+
|
|
67
|
+
def gauge(self, name: str, value: float) -> None:
|
|
68
|
+
"""Set a gauge to the current *value*."""
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._gauges[name] = value
|
|
71
|
+
|
|
72
|
+
def observe(self, name: str, value: float) -> None:
|
|
73
|
+
"""Record an observation in a histogram bucket."""
|
|
74
|
+
with self._lock:
|
|
75
|
+
bucket = self._histograms.setdefault(name, [])
|
|
76
|
+
bucket.append(value)
|
|
77
|
+
if len(bucket) > self._MAX_HISTOGRAM_SIZE:
|
|
78
|
+
# Keep the most recent half to avoid repeated trimming
|
|
79
|
+
del bucket[: len(bucket) // 2]
|
|
80
|
+
|
|
81
|
+
# -- querying ---------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def get_counter(self, name: str) -> int:
|
|
84
|
+
with self._lock:
|
|
85
|
+
return self._counters.get(name, 0)
|
|
86
|
+
|
|
87
|
+
def get_gauge(self, name: str) -> float | None:
|
|
88
|
+
with self._lock:
|
|
89
|
+
return self._gauges.get(name)
|
|
90
|
+
|
|
91
|
+
def get_histogram(self, name: str) -> list[float]:
|
|
92
|
+
with self._lock:
|
|
93
|
+
return list(self._histograms.get(name, []))
|
|
94
|
+
|
|
95
|
+
# -- export -----------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def export(self, fmt: ExportFormat = ExportFormat.JSON) -> str:
|
|
98
|
+
"""Export all metrics in the given format.
|
|
99
|
+
|
|
100
|
+
Supported:
|
|
101
|
+
* ``JSON`` — human-friendly nested dict.
|
|
102
|
+
* ``PROMETHEUS`` — text/plain Prometheus exposition format.
|
|
103
|
+
* ``OTLP_JSON`` — OpenTelemetry-compatible JSON envelope.
|
|
104
|
+
"""
|
|
105
|
+
with self._lock:
|
|
106
|
+
counters = dict(self._counters)
|
|
107
|
+
gauges = dict(self._gauges)
|
|
108
|
+
histograms = {k: list(v) for k, v in self._histograms.items()}
|
|
109
|
+
|
|
110
|
+
if fmt == ExportFormat.JSON:
|
|
111
|
+
return self._export_json(counters, gauges, histograms)
|
|
112
|
+
if fmt == ExportFormat.PROMETHEUS:
|
|
113
|
+
return self._export_prometheus(counters, gauges, histograms)
|
|
114
|
+
if fmt == ExportFormat.OTLP_JSON:
|
|
115
|
+
return self._export_otlp(counters, gauges, histograms)
|
|
116
|
+
raise ValueError(f"Unknown export format: {fmt}")
|
|
117
|
+
|
|
118
|
+
def reset(self) -> None:
|
|
119
|
+
"""Clear all collected metrics."""
|
|
120
|
+
with self._lock:
|
|
121
|
+
self._counters.clear()
|
|
122
|
+
self._gauges.clear()
|
|
123
|
+
self._histograms.clear()
|
|
124
|
+
|
|
125
|
+
# -- private helpers --------------------------------------------------
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _export_json(
|
|
129
|
+
counters: dict[str, int],
|
|
130
|
+
gauges: dict[str, float],
|
|
131
|
+
histograms: dict[str, list[float]],
|
|
132
|
+
) -> str:
|
|
133
|
+
return json.dumps(
|
|
134
|
+
{"counters": counters, "gauges": gauges, "histograms": histograms},
|
|
135
|
+
indent=2,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _export_prometheus(
|
|
140
|
+
counters: dict[str, int],
|
|
141
|
+
gauges: dict[str, float],
|
|
142
|
+
histograms: dict[str, list[float]],
|
|
143
|
+
) -> str:
|
|
144
|
+
"""Prometheus text exposition: each metric on its own line."""
|
|
145
|
+
lines: list[str] = []
|
|
146
|
+
for name, value in sorted(counters.items()):
|
|
147
|
+
safe = name.replace(".", "_")
|
|
148
|
+
lines.append(f"# TYPE crp_{safe} counter")
|
|
149
|
+
lines.append(f"crp_{safe} {value}")
|
|
150
|
+
for name, value in sorted(gauges.items()): # type: ignore[assignment]
|
|
151
|
+
safe = name.replace(".", "_")
|
|
152
|
+
lines.append(f"# TYPE crp_{safe} gauge")
|
|
153
|
+
lines.append(f"crp_{safe} {value}")
|
|
154
|
+
for name, values in sorted(histograms.items()):
|
|
155
|
+
safe = name.replace(".", "_")
|
|
156
|
+
if values:
|
|
157
|
+
lines.append(f"# TYPE crp_{safe} summary")
|
|
158
|
+
lines.append(f"crp_{safe}_count {len(values)}")
|
|
159
|
+
lines.append(f"crp_{safe}_sum {sum(values)}")
|
|
160
|
+
return "\n".join(lines) + "\n" if lines else ""
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _export_otlp(
|
|
164
|
+
counters: dict[str, int],
|
|
165
|
+
gauges: dict[str, float],
|
|
166
|
+
histograms: dict[str, list[float]],
|
|
167
|
+
) -> str:
|
|
168
|
+
"""OTLP-compatible JSON envelope (simplified)."""
|
|
169
|
+
metrics: list[dict[str, Any]] = []
|
|
170
|
+
for name, value in counters.items():
|
|
171
|
+
metrics.append({
|
|
172
|
+
"name": f"crp.{name}",
|
|
173
|
+
"type": "sum",
|
|
174
|
+
"data_points": [{"value": value, "time_unix_nano": int(time.time() * 1e9)}],
|
|
175
|
+
})
|
|
176
|
+
for name, value in gauges.items(): # type: ignore[assignment]
|
|
177
|
+
metrics.append({
|
|
178
|
+
"name": f"crp.{name}",
|
|
179
|
+
"type": "gauge",
|
|
180
|
+
"data_points": [{"value": value, "time_unix_nano": int(time.time() * 1e9)}],
|
|
181
|
+
})
|
|
182
|
+
for name, values in histograms.items():
|
|
183
|
+
metrics.append({
|
|
184
|
+
"name": f"crp.{name}",
|
|
185
|
+
"type": "histogram",
|
|
186
|
+
"data_points": [{
|
|
187
|
+
"count": len(values),
|
|
188
|
+
"sum": sum(values),
|
|
189
|
+
"time_unix_nano": int(time.time() * 1e9),
|
|
190
|
+
}],
|
|
191
|
+
})
|
|
192
|
+
envelope = {
|
|
193
|
+
"resource_metrics": [{
|
|
194
|
+
"scope_metrics": [{"metrics": metrics}],
|
|
195
|
+
}],
|
|
196
|
+
}
|
|
197
|
+
return json.dumps(envelope, indent=2)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
201
|
+
# HealthMonitor (§9.7)
|
|
202
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class HealthStatus:
|
|
207
|
+
"""Result of a health probe."""
|
|
208
|
+
|
|
209
|
+
alive: bool = True
|
|
210
|
+
ready: bool = True
|
|
211
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
212
|
+
|
|
213
|
+
def to_dict(self) -> dict[str, Any]:
|
|
214
|
+
return {"alive": self.alive, "ready": self.ready, "details": self.details}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class HealthMonitor:
|
|
218
|
+
"""Liveness and readiness probes for the CRP runtime.
|
|
219
|
+
|
|
220
|
+
Register named checks with ``add_check(name, callable)``. Each check
|
|
221
|
+
returns ``True`` (healthy) or ``False`` (unhealthy).
|
|
222
|
+
|
|
223
|
+
Usage::
|
|
224
|
+
|
|
225
|
+
hm = HealthMonitor()
|
|
226
|
+
hm.add_check("emitter", lambda: emitter.is_running)
|
|
227
|
+
hm.add_check("warm_store", lambda: warm_store is not None)
|
|
228
|
+
status = hm.probe()
|
|
229
|
+
assert status.alive
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
def __init__(self) -> None:
|
|
233
|
+
self._checks: dict[str, Any] = {} # name → callable() → bool
|
|
234
|
+
self._alive = True
|
|
235
|
+
|
|
236
|
+
def add_check(self, name: str, check_fn: Any) -> None:
|
|
237
|
+
"""Register a readiness check."""
|
|
238
|
+
self._checks[name] = check_fn
|
|
239
|
+
|
|
240
|
+
def remove_check(self, name: str) -> bool:
|
|
241
|
+
"""Remove a check. Returns True if it existed."""
|
|
242
|
+
return self._checks.pop(name, None) is not None
|
|
243
|
+
|
|
244
|
+
def set_alive(self, alive: bool) -> None:
|
|
245
|
+
"""Manually mark the runtime as alive or dead (e.g. on shutdown)."""
|
|
246
|
+
self._alive = alive
|
|
247
|
+
|
|
248
|
+
def probe(self) -> HealthStatus:
|
|
249
|
+
"""Run all registered checks and return combined status.
|
|
250
|
+
|
|
251
|
+
Liveness: ``True`` unless ``set_alive(False)`` was called.
|
|
252
|
+
Readiness: ``True`` only if *all* registered checks pass.
|
|
253
|
+
"""
|
|
254
|
+
details: dict[str, Any] = {}
|
|
255
|
+
all_ready = True
|
|
256
|
+
for name, fn in self._checks.items():
|
|
257
|
+
try:
|
|
258
|
+
ok = bool(fn())
|
|
259
|
+
except Exception:
|
|
260
|
+
ok = False
|
|
261
|
+
details[name] = ok
|
|
262
|
+
if not ok:
|
|
263
|
+
all_ready = False
|
|
264
|
+
return HealthStatus(alive=self._alive, ready=all_ready, details=details)
|