codevigil 0.1.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.
- codevigil/__init__.py +19 -0
- codevigil/__main__.py +10 -0
- codevigil/aggregator.py +506 -0
- codevigil/bootstrap.py +284 -0
- codevigil/cli.py +732 -0
- codevigil/collectors/__init__.py +24 -0
- codevigil/collectors/_text_match.py +271 -0
- codevigil/collectors/parse_health.py +94 -0
- codevigil/collectors/read_edit_ratio.py +258 -0
- codevigil/collectors/reasoning_loop.py +167 -0
- codevigil/collectors/stop_phrase.py +266 -0
- codevigil/config.py +776 -0
- codevigil/errors.py +211 -0
- codevigil/parser.py +673 -0
- codevigil/privacy.py +191 -0
- codevigil/projects.py +132 -0
- codevigil/registry.py +121 -0
- codevigil/renderers/__init__.py +20 -0
- codevigil/renderers/json_file.py +105 -0
- codevigil/renderers/terminal.py +236 -0
- codevigil/types.py +189 -0
- codevigil/watcher.py +456 -0
- codevigil-0.1.0.dist-info/METADATA +351 -0
- codevigil-0.1.0.dist-info/RECORD +27 -0
- codevigil-0.1.0.dist-info/WHEEL +4 -0
- codevigil-0.1.0.dist-info/entry_points.txt +2 -0
- codevigil-0.1.0.dist-info/licenses/LICENSE +201 -0
codevigil/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""codevigil — local, privacy-preserving observability for Claude Code sessions.
|
|
2
|
+
|
|
3
|
+
Importing the top-level package installs the privacy import hook so that any
|
|
4
|
+
subsequent ``import socket`` / ``import subprocess`` / etc. from inside a
|
|
5
|
+
codevigil module raises ``PrivacyViolationError`` immediately. This must
|
|
6
|
+
happen before any other codevigil submodule loads, so the hook install is
|
|
7
|
+
the first statement after ``__future__`` in this file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from codevigil.privacy import PrivacyViolationError
|
|
13
|
+
from codevigil.privacy import install as _install_privacy_hook
|
|
14
|
+
|
|
15
|
+
_install_privacy_hook()
|
|
16
|
+
|
|
17
|
+
__version__: str = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = ["PrivacyViolationError", "__version__"]
|
codevigil/__main__.py
ADDED
codevigil/aggregator.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Session orchestration: source → parser → collectors → snapshots.
|
|
2
|
+
|
|
3
|
+
The :class:`SessionAggregator` is the only subsystem that touches every
|
|
4
|
+
other subsystem. It owns one :class:`SessionParser` per session, instantiates
|
|
5
|
+
the active collectors from the registry per session, drives lifecycle
|
|
6
|
+
transitions (ACTIVE → STALE → EVICTED) on a monotonic clock, and is the
|
|
7
|
+
single owner of the error channel: every :class:`CodevigilError` raised by a
|
|
8
|
+
collector is caught here, recorded with ``source=COLLECTOR`` plus the
|
|
9
|
+
offending collector name, and the loop moves on. Peer collectors and other
|
|
10
|
+
sessions are unaffected — see ``docs/design.md`` §Error Non-Swallowing Rule.
|
|
11
|
+
|
|
12
|
+
Wiring the parse_health collector
|
|
13
|
+
---------------------------------
|
|
14
|
+
|
|
15
|
+
The :class:`~codevigil.types.Collector` protocol is frozen and exposes no
|
|
16
|
+
"give me the parser stats" hook. The aggregator works around this with a
|
|
17
|
+
duck-typed bind: at session creation time it constructs the parser, then for
|
|
18
|
+
each collector instance whose class declares a ``bind_stats`` method it calls
|
|
19
|
+
``collector.bind_stats(parser.stats)``. Today only
|
|
20
|
+
:class:`~codevigil.collectors.parse_health.ParseHealthCollector` declares
|
|
21
|
+
that method; future drift-aware collectors that need the same handle just
|
|
22
|
+
need to grow the same one-method protocol. This keeps the registry
|
|
23
|
+
collector-shape clean while still letting the always-on integrity collector
|
|
24
|
+
read live parser counters without going through the error channel.
|
|
25
|
+
|
|
26
|
+
The "always on, never disableable" rule for ``parse_health`` is enforced
|
|
27
|
+
here in addition to ``codevigil.config``: even if a future code path managed
|
|
28
|
+
to drop ``parse_health`` from ``collectors.enabled``, the aggregator still
|
|
29
|
+
instantiates it for every session.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import time
|
|
35
|
+
from collections.abc import Callable, Iterator
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from datetime import UTC, datetime
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from codevigil.bootstrap import BootstrapManager
|
|
42
|
+
from codevigil.collectors import COLLECTORS
|
|
43
|
+
from codevigil.errors import CodevigilError, ErrorLevel, ErrorSource, record
|
|
44
|
+
from codevigil.parser import SessionParser
|
|
45
|
+
from codevigil.projects import ProjectRegistry
|
|
46
|
+
from codevigil.types import (
|
|
47
|
+
Collector,
|
|
48
|
+
Event,
|
|
49
|
+
EventKind,
|
|
50
|
+
MetricSnapshot,
|
|
51
|
+
SessionMeta,
|
|
52
|
+
SessionState,
|
|
53
|
+
Severity,
|
|
54
|
+
)
|
|
55
|
+
from codevigil.watcher import Source, SourceEvent, SourceEventKind
|
|
56
|
+
|
|
57
|
+
_PARSE_HEALTH_NAME: str = "parse_health"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class _SessionContext:
|
|
62
|
+
"""Per-session bookkeeping owned by :class:`SessionAggregator`.
|
|
63
|
+
|
|
64
|
+
``last_monotonic`` is updated from the aggregator's clock callable on
|
|
65
|
+
every observed APPEND so lifecycle transitions are deterministic in
|
|
66
|
+
tests. ``last_event_time`` is the wall-clock timestamp from the last
|
|
67
|
+
emitted :class:`Event` and is what the renderer surfaces in
|
|
68
|
+
:class:`SessionMeta`.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
session_id: str
|
|
72
|
+
file_path: Path
|
|
73
|
+
project_hash: str
|
|
74
|
+
parser: SessionParser
|
|
75
|
+
collectors: dict[str, Collector]
|
|
76
|
+
first_event_time: datetime
|
|
77
|
+
last_event_time: datetime
|
|
78
|
+
last_monotonic: float
|
|
79
|
+
event_count: int = 0
|
|
80
|
+
state: SessionState = SessionState.ACTIVE
|
|
81
|
+
last_snapshots: dict[str, MetricSnapshot] = field(default_factory=dict)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_ClockFn = Callable[[], float]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SessionAggregator:
|
|
88
|
+
"""Drive a :class:`Source` through parser, collectors, and lifecycle.
|
|
89
|
+
|
|
90
|
+
The aggregator does not block, does not schedule its own ticks, and does
|
|
91
|
+
not own any threads. The CLI watch loop calls :meth:`tick` on whatever
|
|
92
|
+
cadence ``watch.tick_interval`` dictates and consumes the yielded
|
|
93
|
+
``(meta, snapshots)`` pairs. Tests drive ``tick()`` directly with a
|
|
94
|
+
scripted fake source and a controllable clock — see
|
|
95
|
+
``tests/_aggregator_helpers.py``.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
source: Source,
|
|
101
|
+
*,
|
|
102
|
+
config: dict[str, Any],
|
|
103
|
+
project_registry: ProjectRegistry | None = None,
|
|
104
|
+
clock: _ClockFn = time.monotonic,
|
|
105
|
+
registry: dict[str, type[Collector]] | None = None,
|
|
106
|
+
bootstrap: BootstrapManager | None = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
self._source: Source = source
|
|
109
|
+
self._config: dict[str, Any] = config
|
|
110
|
+
self._registry: dict[str, type[Collector]] = (
|
|
111
|
+
registry if registry is not None else COLLECTORS
|
|
112
|
+
)
|
|
113
|
+
self._project_registry: ProjectRegistry = (
|
|
114
|
+
project_registry if project_registry is not None else ProjectRegistry()
|
|
115
|
+
)
|
|
116
|
+
self._clock: _ClockFn = clock
|
|
117
|
+
self._sessions: dict[str, _SessionContext] = {}
|
|
118
|
+
self._bootstrap: BootstrapManager | None = bootstrap
|
|
119
|
+
|
|
120
|
+
watch_cfg = config.get("watch", {})
|
|
121
|
+
self._stale_after: float = float(watch_cfg.get("stale_after_seconds", 300))
|
|
122
|
+
self._evict_after: float = float(watch_cfg.get("evict_after_seconds", 2100))
|
|
123
|
+
collectors_cfg = config.get("collectors", {})
|
|
124
|
+
enabled = collectors_cfg.get("enabled", [])
|
|
125
|
+
self._enabled_collectors: tuple[str, ...] = tuple(enabled)
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------- properties
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def sessions(self) -> dict[str, _SessionContext]:
|
|
131
|
+
"""Read-only-ish accessor used by tests; do not mutate externally."""
|
|
132
|
+
|
|
133
|
+
return self._sessions
|
|
134
|
+
|
|
135
|
+
# ----------------------------------------------------------------- tick API
|
|
136
|
+
|
|
137
|
+
def tick(self) -> Iterator[tuple[SessionMeta, list[MetricSnapshot]]]:
|
|
138
|
+
"""Consume one batch of source events and yield current snapshots.
|
|
139
|
+
|
|
140
|
+
Order of operations: drain ``source.poll()`` first, then run the
|
|
141
|
+
lifecycle pass over every known session. Doing the source pass first
|
|
142
|
+
means an APPEND received in the same tick that would have crossed
|
|
143
|
+
the STALE threshold counts as activity and keeps the session ACTIVE.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
for source_event in self._source.poll():
|
|
147
|
+
self._dispatch_source_event(source_event)
|
|
148
|
+
self._run_lifecycle_pass()
|
|
149
|
+
|
|
150
|
+
results: list[tuple[SessionMeta, list[MetricSnapshot]]] = []
|
|
151
|
+
for ctx in self._sessions.values():
|
|
152
|
+
if ctx.state is SessionState.EVICTED:
|
|
153
|
+
continue
|
|
154
|
+
snapshots = self._snapshot_session(ctx)
|
|
155
|
+
results.append((self._build_meta(ctx), snapshots))
|
|
156
|
+
return iter(results)
|
|
157
|
+
|
|
158
|
+
def close(self) -> None:
|
|
159
|
+
"""Tear down the source and every live collector."""
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
self._source.close()
|
|
163
|
+
except CodevigilError as err:
|
|
164
|
+
self._record_collector_error(err, collector_name="source", session_id="*")
|
|
165
|
+
for ctx in list(self._sessions.values()):
|
|
166
|
+
# Take a final snapshot pass so sessions that never reached
|
|
167
|
+
# EVICTED still contribute to the bootstrap distribution.
|
|
168
|
+
if self._bootstrap is not None and self._bootstrap.is_active():
|
|
169
|
+
self._snapshot_session(ctx)
|
|
170
|
+
self._observe_for_bootstrap(ctx)
|
|
171
|
+
self._reset_collectors(ctx)
|
|
172
|
+
self._sessions.clear()
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------- source dispatch
|
|
175
|
+
|
|
176
|
+
def _dispatch_source_event(self, source_event: SourceEvent) -> None:
|
|
177
|
+
kind = source_event.kind
|
|
178
|
+
if kind is SourceEventKind.NEW_SESSION:
|
|
179
|
+
self._ensure_session(source_event)
|
|
180
|
+
return
|
|
181
|
+
if kind is SourceEventKind.APPEND:
|
|
182
|
+
ctx = self._ensure_session(source_event)
|
|
183
|
+
if source_event.line is None:
|
|
184
|
+
return
|
|
185
|
+
self._ingest_line(ctx, source_event.line)
|
|
186
|
+
return
|
|
187
|
+
if kind is SourceEventKind.ROTATE or kind is SourceEventKind.TRUNCATE:
|
|
188
|
+
# The watcher resets its file cursor and re-reads from byte 0,
|
|
189
|
+
# so a fresh stream of APPEND events will follow. The *session*
|
|
190
|
+
# is the same logical session, so we deliberately preserve the
|
|
191
|
+
# collector state and the parser instance — clearing them would
|
|
192
|
+
# erase the very degradation history the metrics exist to
|
|
193
|
+
# surface. The watcher is the source of truth for line replay;
|
|
194
|
+
# the aggregator just keeps consuming.
|
|
195
|
+
return
|
|
196
|
+
if kind is SourceEventKind.DELETE:
|
|
197
|
+
self._evict_session(source_event.session_id)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
def _ensure_session(self, source_event: SourceEvent) -> _SessionContext:
|
|
201
|
+
sid = source_event.session_id
|
|
202
|
+
existing = self._sessions.get(sid)
|
|
203
|
+
if existing is not None:
|
|
204
|
+
return existing
|
|
205
|
+
parser = SessionParser(session_id=sid)
|
|
206
|
+
collectors = self._instantiate_collectors(parser)
|
|
207
|
+
now_clock = self._clock()
|
|
208
|
+
now_wall = source_event.timestamp
|
|
209
|
+
ctx = _SessionContext(
|
|
210
|
+
session_id=sid,
|
|
211
|
+
file_path=source_event.path,
|
|
212
|
+
project_hash=self._extract_project_hash(source_event.path),
|
|
213
|
+
parser=parser,
|
|
214
|
+
collectors=collectors,
|
|
215
|
+
first_event_time=now_wall,
|
|
216
|
+
last_event_time=now_wall,
|
|
217
|
+
last_monotonic=now_clock,
|
|
218
|
+
)
|
|
219
|
+
self._sessions[sid] = ctx
|
|
220
|
+
return ctx
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _extract_project_hash(path: Path) -> str:
|
|
224
|
+
"""Pull the project-hash directory from the canonical path layout.
|
|
225
|
+
|
|
226
|
+
Layout is ``~/.claude/projects/<project-hash>/sessions/<id>.jsonl``;
|
|
227
|
+
we walk parents until we find one named ``projects`` and return the
|
|
228
|
+
directory immediately under it. Anything else falls back to the
|
|
229
|
+
empty string, which the registry resolves to ``""[:8] == ""``.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
parts = path.parts
|
|
233
|
+
for index, part in enumerate(parts):
|
|
234
|
+
if part == "projects" and index + 1 < len(parts):
|
|
235
|
+
return parts[index + 1]
|
|
236
|
+
return ""
|
|
237
|
+
|
|
238
|
+
def _instantiate_collectors(self, parser: SessionParser) -> dict[str, Collector]:
|
|
239
|
+
"""Build the per-session collector dict from the registry.
|
|
240
|
+
|
|
241
|
+
``parse_health`` is always instantiated, regardless of whether it
|
|
242
|
+
appears in the enabled list — it is the only un-disableable
|
|
243
|
+
collector and the validator already refuses configs that try to
|
|
244
|
+
turn it off, but enforcing it here too means a buggy code path that
|
|
245
|
+
bypasses the validator still cannot drop the integrity collector.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
instances: dict[str, Collector] = {}
|
|
249
|
+
names: list[str] = []
|
|
250
|
+
if _PARSE_HEALTH_NAME in self._registry:
|
|
251
|
+
names.append(_PARSE_HEALTH_NAME)
|
|
252
|
+
for name in self._enabled_collectors:
|
|
253
|
+
if name == _PARSE_HEALTH_NAME:
|
|
254
|
+
continue
|
|
255
|
+
if name in self._registry:
|
|
256
|
+
names.append(name)
|
|
257
|
+
for name in names:
|
|
258
|
+
cls = self._registry[name]
|
|
259
|
+
instance = cls()
|
|
260
|
+
bind = getattr(instance, "bind_stats", None)
|
|
261
|
+
if callable(bind):
|
|
262
|
+
bind(parser.stats)
|
|
263
|
+
instances[name] = instance
|
|
264
|
+
return instances
|
|
265
|
+
|
|
266
|
+
# -------------------------------------------------------------- ingest path
|
|
267
|
+
|
|
268
|
+
def _ingest_line(self, ctx: _SessionContext, line: str) -> None:
|
|
269
|
+
for event in ctx.parser.parse([line]):
|
|
270
|
+
ctx.event_count += 1
|
|
271
|
+
ctx.last_event_time = event.timestamp
|
|
272
|
+
ctx.last_monotonic = self._clock()
|
|
273
|
+
if ctx.state is SessionState.STALE:
|
|
274
|
+
# STALE → ACTIVE on a new APPEND (the "coffee break" rule).
|
|
275
|
+
# Collector state is intentionally preserved.
|
|
276
|
+
ctx.state = SessionState.ACTIVE
|
|
277
|
+
if event.kind is EventKind.SYSTEM:
|
|
278
|
+
self._project_registry.observe_system_event(ctx.project_hash, event)
|
|
279
|
+
self._fan_out_event(ctx, event)
|
|
280
|
+
|
|
281
|
+
def _fan_out_event(self, ctx: _SessionContext, event: Event) -> None:
|
|
282
|
+
for collector_name, collector in ctx.collectors.items():
|
|
283
|
+
try:
|
|
284
|
+
collector.ingest(event)
|
|
285
|
+
except CodevigilError as err:
|
|
286
|
+
# One collector raising must not poison its peers. We log
|
|
287
|
+
# the failure with source=COLLECTOR and continue — both the
|
|
288
|
+
# remaining collectors for this event and the next event
|
|
289
|
+
# for the same collector keep flowing.
|
|
290
|
+
self._record_collector_error(
|
|
291
|
+
err,
|
|
292
|
+
collector_name=collector_name,
|
|
293
|
+
session_id=ctx.session_id,
|
|
294
|
+
)
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
# A collector raising a non-CodevigilError is a bug, but
|
|
297
|
+
# the design's non-swallowing rule still requires we route
|
|
298
|
+
# it through the error channel rather than crash the loop.
|
|
299
|
+
self._record_collector_error(
|
|
300
|
+
CodevigilError(
|
|
301
|
+
level=ErrorLevel.ERROR,
|
|
302
|
+
source=ErrorSource.COLLECTOR,
|
|
303
|
+
code="aggregator.collector_unexpected_exception",
|
|
304
|
+
message=(
|
|
305
|
+
f"collector {collector_name!r} raised {type(exc).__name__}: {exc}"
|
|
306
|
+
),
|
|
307
|
+
context={
|
|
308
|
+
"collector": collector_name,
|
|
309
|
+
"session_id": ctx.session_id,
|
|
310
|
+
"exception_type": type(exc).__name__,
|
|
311
|
+
},
|
|
312
|
+
),
|
|
313
|
+
collector_name=collector_name,
|
|
314
|
+
session_id=ctx.session_id,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def _record_collector_error(
|
|
318
|
+
self,
|
|
319
|
+
err: CodevigilError,
|
|
320
|
+
*,
|
|
321
|
+
collector_name: str,
|
|
322
|
+
session_id: str,
|
|
323
|
+
) -> None:
|
|
324
|
+
ctx_payload = dict(err.context)
|
|
325
|
+
ctx_payload.setdefault("collector", collector_name)
|
|
326
|
+
ctx_payload.setdefault("session_id", session_id)
|
|
327
|
+
record(
|
|
328
|
+
CodevigilError(
|
|
329
|
+
level=err.level if err.level is not ErrorLevel.INFO else ErrorLevel.ERROR,
|
|
330
|
+
source=ErrorSource.COLLECTOR,
|
|
331
|
+
code=err.code or "aggregator.collector_error",
|
|
332
|
+
message=err.message,
|
|
333
|
+
context=ctx_payload,
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# ------------------------------------------------------------------ snapshot
|
|
338
|
+
|
|
339
|
+
def _snapshot_session(self, ctx: _SessionContext) -> list[MetricSnapshot]:
|
|
340
|
+
snapshots: list[MetricSnapshot] = []
|
|
341
|
+
for collector_name, collector in ctx.collectors.items():
|
|
342
|
+
try:
|
|
343
|
+
raw = collector.snapshot()
|
|
344
|
+
ctx.last_snapshots[collector_name] = raw
|
|
345
|
+
snapshots.append(self._apply_bootstrap_clamp(collector_name, raw))
|
|
346
|
+
except CodevigilError as err:
|
|
347
|
+
self._record_collector_error(
|
|
348
|
+
err,
|
|
349
|
+
collector_name=collector_name,
|
|
350
|
+
session_id=ctx.session_id,
|
|
351
|
+
)
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
self._record_collector_error(
|
|
354
|
+
CodevigilError(
|
|
355
|
+
level=ErrorLevel.ERROR,
|
|
356
|
+
source=ErrorSource.COLLECTOR,
|
|
357
|
+
code="aggregator.snapshot_unexpected_exception",
|
|
358
|
+
message=(
|
|
359
|
+
f"collector {collector_name!r} snapshot raised "
|
|
360
|
+
f"{type(exc).__name__}: {exc}"
|
|
361
|
+
),
|
|
362
|
+
context={
|
|
363
|
+
"collector": collector_name,
|
|
364
|
+
"session_id": ctx.session_id,
|
|
365
|
+
},
|
|
366
|
+
),
|
|
367
|
+
collector_name=collector_name,
|
|
368
|
+
session_id=ctx.session_id,
|
|
369
|
+
)
|
|
370
|
+
return snapshots
|
|
371
|
+
|
|
372
|
+
def _apply_bootstrap_clamp(
|
|
373
|
+
self,
|
|
374
|
+
collector_name: str,
|
|
375
|
+
snap: MetricSnapshot,
|
|
376
|
+
) -> MetricSnapshot:
|
|
377
|
+
"""Pin severity to OK and tag the label while bootstrap runs.
|
|
378
|
+
|
|
379
|
+
``parse_health`` is the only integrity signal and must keep its
|
|
380
|
+
real severity; every other collector is still experimental until
|
|
381
|
+
the bootstrap window closes, so we refuse to let them drive
|
|
382
|
+
alerts during calibration. The raw snapshot the collector
|
|
383
|
+
produced is preserved in ``ctx.last_snapshots`` for observation;
|
|
384
|
+
only the user-visible copy is rewritten.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
bootstrap = self._bootstrap
|
|
388
|
+
if bootstrap is None or not bootstrap.is_active():
|
|
389
|
+
return snap
|
|
390
|
+
if collector_name == _PARSE_HEALTH_NAME:
|
|
391
|
+
return snap
|
|
392
|
+
tag = f"[bootstrap {bootstrap.sessions_observed() + 1}/{bootstrap.target}]"
|
|
393
|
+
label = f"{snap.label} {tag}" if snap.label else tag
|
|
394
|
+
return MetricSnapshot(
|
|
395
|
+
name=snap.name,
|
|
396
|
+
value=snap.value,
|
|
397
|
+
label=label,
|
|
398
|
+
severity=Severity.OK,
|
|
399
|
+
detail=snap.detail,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def _observe_for_bootstrap(self, ctx: _SessionContext) -> None:
|
|
403
|
+
"""Hand the final per-collector snapshots to the bootstrap manager."""
|
|
404
|
+
|
|
405
|
+
bootstrap = self._bootstrap
|
|
406
|
+
if bootstrap is None or not bootstrap.is_active():
|
|
407
|
+
return
|
|
408
|
+
payload: dict[str, MetricSnapshot] = {}
|
|
409
|
+
for collector_name, snap in ctx.last_snapshots.items():
|
|
410
|
+
if collector_name == _PARSE_HEALTH_NAME:
|
|
411
|
+
continue
|
|
412
|
+
payload[collector_name] = snap
|
|
413
|
+
if not payload:
|
|
414
|
+
return
|
|
415
|
+
bootstrap.observe_session(ctx.session_id, payload)
|
|
416
|
+
if bootstrap.finalize_if_ready():
|
|
417
|
+
record(
|
|
418
|
+
CodevigilError(
|
|
419
|
+
level=ErrorLevel.INFO,
|
|
420
|
+
source=ErrorSource.AGGREGATOR,
|
|
421
|
+
code="aggregator.bootstrap_complete",
|
|
422
|
+
message=(
|
|
423
|
+
f"bootstrap window closed after {bootstrap.sessions_observed()} "
|
|
424
|
+
f"sessions; derived thresholds persisted to "
|
|
425
|
+
f"{bootstrap.state_path!s}"
|
|
426
|
+
),
|
|
427
|
+
context={
|
|
428
|
+
"state_path": str(bootstrap.state_path),
|
|
429
|
+
"sessions_observed": bootstrap.sessions_observed(),
|
|
430
|
+
},
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def _build_meta(self, ctx: _SessionContext) -> SessionMeta:
|
|
435
|
+
confidence = ctx.parser.stats.parse_confidence
|
|
436
|
+
return SessionMeta(
|
|
437
|
+
session_id=ctx.session_id,
|
|
438
|
+
project_hash=ctx.project_hash,
|
|
439
|
+
project_name=self._project_registry.resolve(ctx.project_hash),
|
|
440
|
+
file_path=ctx.file_path,
|
|
441
|
+
start_time=ctx.first_event_time,
|
|
442
|
+
last_event_time=ctx.last_event_time,
|
|
443
|
+
event_count=ctx.event_count,
|
|
444
|
+
parse_confidence=float(confidence),
|
|
445
|
+
state=ctx.state,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# ----------------------------------------------------------------- lifecycle
|
|
449
|
+
|
|
450
|
+
def _run_lifecycle_pass(self) -> None:
|
|
451
|
+
now = self._clock()
|
|
452
|
+
to_evict: list[str] = []
|
|
453
|
+
for sid, ctx in self._sessions.items():
|
|
454
|
+
silence = now - ctx.last_monotonic
|
|
455
|
+
if silence >= self._evict_after:
|
|
456
|
+
to_evict.append(sid)
|
|
457
|
+
continue
|
|
458
|
+
if silence >= self._stale_after and ctx.state is SessionState.ACTIVE:
|
|
459
|
+
ctx.state = SessionState.STALE
|
|
460
|
+
for sid in to_evict:
|
|
461
|
+
self._evict_session(sid)
|
|
462
|
+
|
|
463
|
+
def _evict_session(self, session_id: str) -> None:
|
|
464
|
+
ctx = self._sessions.pop(session_id, None)
|
|
465
|
+
if ctx is None:
|
|
466
|
+
return
|
|
467
|
+
ctx.state = SessionState.EVICTED
|
|
468
|
+
self._observe_for_bootstrap(ctx)
|
|
469
|
+
self._reset_collectors(ctx)
|
|
470
|
+
|
|
471
|
+
def _reset_collectors(self, ctx: _SessionContext) -> None:
|
|
472
|
+
for collector_name, collector in ctx.collectors.items():
|
|
473
|
+
try:
|
|
474
|
+
collector.reset()
|
|
475
|
+
except CodevigilError as err:
|
|
476
|
+
self._record_collector_error(
|
|
477
|
+
err,
|
|
478
|
+
collector_name=collector_name,
|
|
479
|
+
session_id=ctx.session_id,
|
|
480
|
+
)
|
|
481
|
+
except Exception as exc:
|
|
482
|
+
self._record_collector_error(
|
|
483
|
+
CodevigilError(
|
|
484
|
+
level=ErrorLevel.ERROR,
|
|
485
|
+
source=ErrorSource.COLLECTOR,
|
|
486
|
+
code="aggregator.reset_unexpected_exception",
|
|
487
|
+
message=(
|
|
488
|
+
f"collector {collector_name!r} reset raised {type(exc).__name__}: {exc}"
|
|
489
|
+
),
|
|
490
|
+
context={
|
|
491
|
+
"collector": collector_name,
|
|
492
|
+
"session_id": ctx.session_id,
|
|
493
|
+
},
|
|
494
|
+
),
|
|
495
|
+
collector_name=collector_name,
|
|
496
|
+
session_id=ctx.session_id,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _utc_now() -> datetime: # pragma: no cover - convenience for callers
|
|
501
|
+
return datetime.now(tz=UTC)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# Re-export ``field`` so downstream phases that grow ``_SessionContext`` can
|
|
505
|
+
# reach the dataclasses helper without a second import.
|
|
506
|
+
__all__ = ["SessionAggregator", "field"]
|