traceforge-toolkit 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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""SQLite polling source for reading agent session databases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import sqlite3
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from traceforge.sources.base import RawRecord, Source
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SqliteSource(Source):
|
|
21
|
+
"""Poll a SQLite database for new rows in a target table.
|
|
22
|
+
|
|
23
|
+
Tracks position via a monotonically increasing column (e.g. rowid, id,
|
|
24
|
+
or timestamp). Each new row becomes a RawRecord whose payload is the
|
|
25
|
+
JSON-serialized row dict.
|
|
26
|
+
|
|
27
|
+
Designed for:
|
|
28
|
+
- Copilot CLI session-store.db (turns, forge_trajectory_events)
|
|
29
|
+
- Any agent that stores conversation data in SQLite
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
path: str | Path,
|
|
35
|
+
name: str,
|
|
36
|
+
table: str = "turns",
|
|
37
|
+
order_column: str = "id",
|
|
38
|
+
columns: list[str] | None = None,
|
|
39
|
+
where: str | None = None,
|
|
40
|
+
interval: float = 2.0,
|
|
41
|
+
start_at: Literal["beginning", "end"] = "end",
|
|
42
|
+
session_filter: str | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Initialize SqliteSource.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
path: Path to the SQLite database file.
|
|
48
|
+
name: Source name for record attribution.
|
|
49
|
+
table: Table to poll for new rows.
|
|
50
|
+
order_column: Column used for ordering and cursor tracking (must be monotonic).
|
|
51
|
+
columns: Specific columns to select (None = all).
|
|
52
|
+
where: Additional WHERE clause (without 'WHERE' keyword).
|
|
53
|
+
interval: Seconds between polls.
|
|
54
|
+
start_at: "beginning" reads all existing rows; "end" starts from latest.
|
|
55
|
+
session_filter: If set, adds WHERE session_id = ? filter.
|
|
56
|
+
"""
|
|
57
|
+
if interval < 0:
|
|
58
|
+
raise ValueError("interval must be non-negative")
|
|
59
|
+
self.path = Path(path).resolve()
|
|
60
|
+
self.name = name
|
|
61
|
+
self.table = table
|
|
62
|
+
self.order_column = order_column
|
|
63
|
+
self.columns = columns
|
|
64
|
+
self.where = where
|
|
65
|
+
self.interval = interval
|
|
66
|
+
self.start_at = start_at
|
|
67
|
+
self.session_filter = session_filter
|
|
68
|
+
self._cursor: int | str | None = None
|
|
69
|
+
self._sequence = 0
|
|
70
|
+
self._conn: sqlite3.Connection | None = None
|
|
71
|
+
self._iterating = False
|
|
72
|
+
|
|
73
|
+
async def __aenter__(self) -> "SqliteSource":
|
|
74
|
+
if not self.path.exists():
|
|
75
|
+
raise FileNotFoundError(f"SqliteSource database not found: {self.path}")
|
|
76
|
+
self._conn = await asyncio.to_thread(self._connect)
|
|
77
|
+
if self.start_at == "end":
|
|
78
|
+
self._cursor = await asyncio.to_thread(self._get_max_cursor)
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
async def __aexit__(
|
|
82
|
+
self,
|
|
83
|
+
exc_type: type[BaseException] | None,
|
|
84
|
+
exc: BaseException | None,
|
|
85
|
+
tb: TracebackType | None,
|
|
86
|
+
) -> None:
|
|
87
|
+
if self._conn is not None:
|
|
88
|
+
self._conn.close()
|
|
89
|
+
self._conn = None
|
|
90
|
+
self._iterating = False
|
|
91
|
+
|
|
92
|
+
async def _iter_records(self) -> AsyncIterator[RawRecord]:
|
|
93
|
+
if self._conn is None:
|
|
94
|
+
raise RuntimeError("SqliteSource must be entered before iteration")
|
|
95
|
+
if self._iterating:
|
|
96
|
+
raise RuntimeError("SqliteSource does not support concurrent iteration")
|
|
97
|
+
self._iterating = True
|
|
98
|
+
try:
|
|
99
|
+
while True:
|
|
100
|
+
rows = await asyncio.to_thread(self._poll_new_rows)
|
|
101
|
+
for row in rows:
|
|
102
|
+
yield self._make_record(row)
|
|
103
|
+
await asyncio.sleep(self.interval)
|
|
104
|
+
finally:
|
|
105
|
+
self._iterating = False
|
|
106
|
+
|
|
107
|
+
def __aiter__(self) -> AsyncIterator[RawRecord]:
|
|
108
|
+
return self._iter_records()
|
|
109
|
+
|
|
110
|
+
def _connect(self) -> sqlite3.Connection:
|
|
111
|
+
conn = sqlite3.connect(str(self.path), timeout=5, check_same_thread=False)
|
|
112
|
+
conn.row_factory = sqlite3.Row
|
|
113
|
+
# WAL mode for concurrent reads without blocking writers
|
|
114
|
+
try:
|
|
115
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
116
|
+
except sqlite3.OperationalError:
|
|
117
|
+
pass
|
|
118
|
+
return conn
|
|
119
|
+
|
|
120
|
+
def _get_max_cursor(self) -> int | str | None:
|
|
121
|
+
"""Get the current maximum value of the order column."""
|
|
122
|
+
assert self._conn is not None
|
|
123
|
+
query = f"SELECT MAX({self.order_column}) FROM {self.table}" # noqa: S608
|
|
124
|
+
filters, params = self._build_filters()
|
|
125
|
+
if filters:
|
|
126
|
+
query += f" WHERE {' AND '.join(filters)}"
|
|
127
|
+
row = self._conn.execute(query, params).fetchone()
|
|
128
|
+
return row[0] if row else None
|
|
129
|
+
|
|
130
|
+
def _poll_new_rows(self) -> list[dict[str, object]]:
|
|
131
|
+
"""Query for rows newer than the current cursor."""
|
|
132
|
+
assert self._conn is not None
|
|
133
|
+
|
|
134
|
+
col_spec = ", ".join(self.columns) if self.columns else "*"
|
|
135
|
+
query = f"SELECT {col_spec} FROM {self.table}" # noqa: S608
|
|
136
|
+
|
|
137
|
+
filters, params = self._build_filters()
|
|
138
|
+
if self._cursor is not None:
|
|
139
|
+
filters.append(f"{self.order_column} > ?")
|
|
140
|
+
params.append(self._cursor)
|
|
141
|
+
|
|
142
|
+
if filters:
|
|
143
|
+
query += f" WHERE {' AND '.join(filters)}"
|
|
144
|
+
query += f" ORDER BY {self.order_column} ASC"
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
rows = self._conn.execute(query, params).fetchall()
|
|
148
|
+
except sqlite3.OperationalError as exc:
|
|
149
|
+
logger.warning("SqliteSource %s: query failed: %s", self.name, exc)
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
results: list[dict[str, object]] = []
|
|
153
|
+
for row in rows:
|
|
154
|
+
row_dict = dict(row)
|
|
155
|
+
cursor_val = row_dict.get(self.order_column)
|
|
156
|
+
if cursor_val is not None:
|
|
157
|
+
self._cursor = cursor_val
|
|
158
|
+
# Serialize to JSON for the RawRecord payload
|
|
159
|
+
results.append({k: v for k, v in row_dict.items() if v is not None})
|
|
160
|
+
|
|
161
|
+
if results:
|
|
162
|
+
logger.debug("SqliteSource %s: polled %d new rows", self.name, len(results))
|
|
163
|
+
|
|
164
|
+
return results
|
|
165
|
+
|
|
166
|
+
def _build_filters(self) -> tuple[list[str], list[object]]:
|
|
167
|
+
"""Build WHERE clause filters and parameters."""
|
|
168
|
+
filters: list[str] = []
|
|
169
|
+
params: list[object] = []
|
|
170
|
+
if self.session_filter:
|
|
171
|
+
filters.append("session_id = ?")
|
|
172
|
+
params.append(self.session_filter)
|
|
173
|
+
if self.where:
|
|
174
|
+
filters.append(f"({self.where})")
|
|
175
|
+
return filters, params
|
|
176
|
+
|
|
177
|
+
def _make_record(self, row_dict: dict[str, object]) -> RawRecord:
|
|
178
|
+
|
|
179
|
+
record = RawRecord(
|
|
180
|
+
payload=json.dumps(row_dict, default=str),
|
|
181
|
+
source_name=self.name,
|
|
182
|
+
mode="sqlite",
|
|
183
|
+
sequence=self._sequence,
|
|
184
|
+
received_at=datetime.now(timezone.utc),
|
|
185
|
+
)
|
|
186
|
+
self._sequence += 1
|
|
187
|
+
return record
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Server-sent events source transport (WHATWG spec compliant)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from traceforge.sources.base import RawRecord, Source
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class SSEEvent:
|
|
18
|
+
"""A parsed SSE event per the WHATWG spec."""
|
|
19
|
+
|
|
20
|
+
data: str
|
|
21
|
+
event_type: str = "message"
|
|
22
|
+
last_event_id: str = ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SSESource(Source):
|
|
26
|
+
"""Read SSE events from an endpoint with spec-compliant parsing and reconnect.
|
|
27
|
+
|
|
28
|
+
Implements the WHATWG Server-Sent Events specification:
|
|
29
|
+
- Parses data, event, id, retry fields
|
|
30
|
+
- Sends Last-Event-ID on reconnect
|
|
31
|
+
- Validates Content-Type: text/event-stream
|
|
32
|
+
- Exponential backoff with server-controlled retry
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
url: str,
|
|
38
|
+
name: str,
|
|
39
|
+
headers: dict[str, str] | None = None,
|
|
40
|
+
reconnect_delay: float = 1.0,
|
|
41
|
+
max_reconnects: int | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.url = url
|
|
44
|
+
self.name = name
|
|
45
|
+
self.headers = dict(headers or {})
|
|
46
|
+
self.reconnect_delay = reconnect_delay
|
|
47
|
+
self.max_reconnects = max_reconnects
|
|
48
|
+
self._client: httpx.AsyncClient | None = None
|
|
49
|
+
self._sequence = 0
|
|
50
|
+
self._last_event_id: str = ""
|
|
51
|
+
self._iterating = False
|
|
52
|
+
|
|
53
|
+
async def __aenter__(self) -> "SSESource":
|
|
54
|
+
self._client = httpx.AsyncClient(timeout=None)
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
async def __aexit__(
|
|
58
|
+
self,
|
|
59
|
+
exc_type: type[BaseException] | None,
|
|
60
|
+
exc: BaseException | None,
|
|
61
|
+
tb: TracebackType | None,
|
|
62
|
+
) -> None:
|
|
63
|
+
if self._client is not None:
|
|
64
|
+
await self._client.aclose()
|
|
65
|
+
self._client = None
|
|
66
|
+
self._iterating = False
|
|
67
|
+
|
|
68
|
+
async def _iter_records(self) -> AsyncIterator[RawRecord]:
|
|
69
|
+
if self._client is None:
|
|
70
|
+
raise RuntimeError("SSESource must be entered before iteration")
|
|
71
|
+
if self._iterating:
|
|
72
|
+
raise RuntimeError("SSESource does not support concurrent iteration")
|
|
73
|
+
self._iterating = True
|
|
74
|
+
try:
|
|
75
|
+
reconnects = 0
|
|
76
|
+
while True:
|
|
77
|
+
if self.max_reconnects is not None and reconnects > self.max_reconnects:
|
|
78
|
+
raise RuntimeError(f"Exceeded maximum reconnects for SSE source {self.name}")
|
|
79
|
+
try:
|
|
80
|
+
async for sse_event in self._stream_once():
|
|
81
|
+
reconnects = 0
|
|
82
|
+
yield self._make_record(sse_event)
|
|
83
|
+
except asyncio.CancelledError:
|
|
84
|
+
raise
|
|
85
|
+
except Exception:
|
|
86
|
+
reconnects += 1
|
|
87
|
+
if self.max_reconnects is not None and reconnects > self.max_reconnects:
|
|
88
|
+
raise
|
|
89
|
+
await asyncio.sleep(self._backoff_delay(reconnects))
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
reconnects += 1
|
|
93
|
+
if self.max_reconnects is not None and reconnects > self.max_reconnects:
|
|
94
|
+
return
|
|
95
|
+
await asyncio.sleep(self._backoff_delay(reconnects))
|
|
96
|
+
finally:
|
|
97
|
+
self._iterating = False
|
|
98
|
+
|
|
99
|
+
def __aiter__(self) -> AsyncIterator[RawRecord]:
|
|
100
|
+
return self._iter_records()
|
|
101
|
+
|
|
102
|
+
async def _stream_once(self) -> AsyncIterator[SSEEvent]:
|
|
103
|
+
"""Connect and yield parsed SSE events until the stream ends."""
|
|
104
|
+
if self._client is None:
|
|
105
|
+
raise RuntimeError("SSESource must be entered before iteration")
|
|
106
|
+
|
|
107
|
+
request_headers = {"Accept": "text/event-stream", **self.headers}
|
|
108
|
+
if self._last_event_id:
|
|
109
|
+
request_headers["Last-Event-ID"] = self._last_event_id
|
|
110
|
+
|
|
111
|
+
async with self._client.stream("GET", self.url, headers=request_headers) as response:
|
|
112
|
+
response.raise_for_status()
|
|
113
|
+
content_type = response.headers.get("content-type", "")
|
|
114
|
+
if "text/event-stream" not in content_type:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"SSE endpoint returned Content-Type '{content_type}', "
|
|
117
|
+
f"expected 'text/event-stream'"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
data_buf: list[str] = []
|
|
121
|
+
event_type = ""
|
|
122
|
+
last_id: str | None = None
|
|
123
|
+
|
|
124
|
+
async for line in response.aiter_lines():
|
|
125
|
+
if line == "":
|
|
126
|
+
# Dispatch event
|
|
127
|
+
if data_buf:
|
|
128
|
+
data = "\n".join(data_buf)
|
|
129
|
+
# SSE is at-least-once: after a reconnect the server may
|
|
130
|
+
# redeliver an already-seen id (e.g. resumed via Last-Event-ID).
|
|
131
|
+
# Drop such redeliveries; only emit ids newer than the last one
|
|
132
|
+
# we emitted, advancing _last_event_id solely for emitted events.
|
|
133
|
+
if last_id is None or not self._is_duplicate(last_id):
|
|
134
|
+
if last_id is not None:
|
|
135
|
+
self._last_event_id = last_id
|
|
136
|
+
yield SSEEvent(
|
|
137
|
+
data=data,
|
|
138
|
+
event_type=event_type or "message",
|
|
139
|
+
last_event_id=self._last_event_id,
|
|
140
|
+
)
|
|
141
|
+
# Reset per-event state
|
|
142
|
+
data_buf = []
|
|
143
|
+
event_type = ""
|
|
144
|
+
last_id = None
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
if line.startswith(":"):
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
field, _, value = line.partition(":")
|
|
151
|
+
if value.startswith(" "):
|
|
152
|
+
value = value[1:]
|
|
153
|
+
|
|
154
|
+
if field == "data":
|
|
155
|
+
data_buf.append(value)
|
|
156
|
+
elif field == "event":
|
|
157
|
+
event_type = value
|
|
158
|
+
elif field == "id":
|
|
159
|
+
# Per spec: empty id resets; id with NUL is ignored
|
|
160
|
+
if "\x00" not in value:
|
|
161
|
+
last_id = value
|
|
162
|
+
elif field == "retry":
|
|
163
|
+
if value.isdigit():
|
|
164
|
+
self.reconnect_delay = max(int(value) / 1000.0, 0.0)
|
|
165
|
+
|
|
166
|
+
def _is_duplicate(self, event_id: str) -> bool:
|
|
167
|
+
"""Return True if ``event_id`` was already emitted (at-least-once redelivery).
|
|
168
|
+
|
|
169
|
+
After a reconnect an SSE server may redeliver events the client has already
|
|
170
|
+
seen. When both the last-emitted id and the candidate are integers, treat any
|
|
171
|
+
id not strictly greater than the last-seen id as a duplicate (monotonic ids,
|
|
172
|
+
the common case). For ids that cannot be ordered numerically, drop only an id
|
|
173
|
+
that exactly equals the last-emitted one (the immediately-preceding event).
|
|
174
|
+
"""
|
|
175
|
+
last = self._last_event_id
|
|
176
|
+
if not last:
|
|
177
|
+
return False
|
|
178
|
+
if event_id == last:
|
|
179
|
+
return True
|
|
180
|
+
try:
|
|
181
|
+
return int(event_id) <= int(last)
|
|
182
|
+
except ValueError:
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
def _backoff_delay(self, reconnects: int) -> float:
|
|
186
|
+
exponent = max(reconnects - 1, 0)
|
|
187
|
+
return self.reconnect_delay * min(2**exponent, 30)
|
|
188
|
+
|
|
189
|
+
def _make_record(self, sse_event: SSEEvent) -> RawRecord:
|
|
190
|
+
record = RawRecord(
|
|
191
|
+
payload=sse_event.data,
|
|
192
|
+
source_name=self.name,
|
|
193
|
+
mode="stream",
|
|
194
|
+
sequence=self._sequence,
|
|
195
|
+
received_at=datetime.now(timezone.utc),
|
|
196
|
+
)
|
|
197
|
+
self._sequence += 1
|
|
198
|
+
return record
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Opt-in, near-zero-footprint self-metrics for :class:`~traceforge.pipeline.EventPipeline`.
|
|
2
|
+
|
|
3
|
+
This package is traceforge's *self*-observability seam (SPEC §14). It is distinct
|
|
4
|
+
from OTLP *export* (``sinks/otel_exporter.OtelExporterSink``, which ships events /
|
|
5
|
+
spans / usage to an external collector): this measures the pipeline's **own**
|
|
6
|
+
operation — throughput, enrichment latency, per-sink write time, and dropped /
|
|
7
|
+
failed-sink counts.
|
|
8
|
+
|
|
9
|
+
Design contract (why this stays honest to the "must not add per-event cost"
|
|
10
|
+
constraint of #48):
|
|
11
|
+
|
|
12
|
+
- **Opt-in.** A pipeline created without a :class:`PipelineMetrics` instance does
|
|
13
|
+
no timing and makes no metrics allocations on the hot path — the pipeline
|
|
14
|
+
guards every instrumentation site on ``metrics is not None`` and never calls a
|
|
15
|
+
clock when disabled. Attaching an instance is the *only* way to turn it on.
|
|
16
|
+
- **In-process, no dependencies.** No ``opentelemetry-sdk``, no ``prometheus``,
|
|
17
|
+
no background threads, no parallel transport. It is a plain accumulator read
|
|
18
|
+
back off the same object you passed in (surfaced on ``flush()`` / ``close()``).
|
|
19
|
+
- **Bounded.** Every field is a scalar counter or timing sum, plus exactly one
|
|
20
|
+
entry per sink. Nothing grows per event, so a long-lived daemon never
|
|
21
|
+
accumulates unbounded state.
|
|
22
|
+
|
|
23
|
+
Usage::
|
|
24
|
+
|
|
25
|
+
from traceforge import EventPipeline
|
|
26
|
+
from traceforge.telemetry import PipelineMetrics
|
|
27
|
+
|
|
28
|
+
metrics = PipelineMetrics()
|
|
29
|
+
pipeline = EventPipeline(sinks=[...], metrics=metrics)
|
|
30
|
+
...
|
|
31
|
+
await pipeline.close()
|
|
32
|
+
snap = metrics.snapshot()
|
|
33
|
+
print(snap.events_per_second, snap.mean_enrichment_ms)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import time
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
|
|
41
|
+
__all__ = ["MetricsSnapshot", "PipelineMetrics", "SinkMetrics"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SinkMetrics:
|
|
46
|
+
"""Immutable per-sink write totals within a :class:`MetricsSnapshot`.
|
|
47
|
+
|
|
48
|
+
``label`` identifies the sink by class name and position in the pipeline's
|
|
49
|
+
sink list (e.g. ``"JsonlSink#1"``), so two sinks of the same class stay
|
|
50
|
+
distinguishable.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
label: str
|
|
54
|
+
writes: int
|
|
55
|
+
failures: int
|
|
56
|
+
write_seconds: float
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def mean_write_ms(self) -> float:
|
|
60
|
+
"""Mean wall time this sink spent per write, in milliseconds."""
|
|
61
|
+
return (self.write_seconds / self.writes * 1000.0) if self.writes else 0.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class MetricsSnapshot:
|
|
66
|
+
"""An immutable point-in-time read of a :class:`PipelineMetrics`.
|
|
67
|
+
|
|
68
|
+
Returned by :meth:`PipelineMetrics.snapshot`. Raw counters are captured
|
|
69
|
+
verbatim; rates and means are exposed as derived properties so no divide-by-
|
|
70
|
+
zero can leak to callers.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
events: int
|
|
74
|
+
dropped_events: int
|
|
75
|
+
sink_failures: int
|
|
76
|
+
enrichment_calls: int
|
|
77
|
+
enrichment_seconds: float
|
|
78
|
+
active_seconds: float
|
|
79
|
+
sinks: tuple[SinkMetrics, ...]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def events_per_second(self) -> float:
|
|
83
|
+
"""Throughput over the span from the first to the most recent event.
|
|
84
|
+
|
|
85
|
+
``0.0`` until at least two events have been recorded far enough apart for
|
|
86
|
+
the monotonic clock to advance (a single event has no measurable span).
|
|
87
|
+
"""
|
|
88
|
+
return (self.events / self.active_seconds) if self.active_seconds > 0 else 0.0
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def mean_enrichment_ms(self) -> float:
|
|
92
|
+
"""Mean wall time spent in the enricher per event, in milliseconds."""
|
|
93
|
+
return (
|
|
94
|
+
(self.enrichment_seconds / self.enrichment_calls * 1000.0)
|
|
95
|
+
if self.enrichment_calls
|
|
96
|
+
else 0.0
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PipelineMetrics:
|
|
101
|
+
"""Mutable, in-process accumulator of :class:`EventPipeline` self-metrics.
|
|
102
|
+
|
|
103
|
+
Attach one instance per pipeline via ``EventPipeline(..., metrics=...)`` to
|
|
104
|
+
turn metrics on; leave it unset (the default) and the pipeline stays a true
|
|
105
|
+
no-op on the hot path. The pipeline calls the ``record_*`` methods; consumers
|
|
106
|
+
call :meth:`snapshot` (any time — the counters update live) to read a stable,
|
|
107
|
+
immutable summary.
|
|
108
|
+
|
|
109
|
+
Not thread-safe by design: the pipeline is asyncio single-threaded, so every
|
|
110
|
+
``record_*`` call runs on the one event loop; there is nothing to lock.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
__slots__ = (
|
|
114
|
+
"_dropped_events",
|
|
115
|
+
"_enrichment_calls",
|
|
116
|
+
"_enrichment_seconds",
|
|
117
|
+
"_events",
|
|
118
|
+
"_first_perf",
|
|
119
|
+
"_last_perf",
|
|
120
|
+
"_sink_failures",
|
|
121
|
+
"_sink_seconds",
|
|
122
|
+
"_sink_writes",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def __init__(self) -> None:
|
|
126
|
+
self._events = 0
|
|
127
|
+
self._dropped_events = 0
|
|
128
|
+
self._enrichment_calls = 0
|
|
129
|
+
self._enrichment_seconds = 0.0
|
|
130
|
+
self._first_perf: float | None = None
|
|
131
|
+
self._last_perf = 0.0
|
|
132
|
+
# Per-sink accumulators, keyed by the pipeline-assigned sink label. Bounded
|
|
133
|
+
# by the number of sinks; never grows per event.
|
|
134
|
+
self._sink_writes: dict[str, int] = {}
|
|
135
|
+
self._sink_failures: dict[str, int] = {}
|
|
136
|
+
self._sink_seconds: dict[str, float] = {}
|
|
137
|
+
|
|
138
|
+
def record_event(self) -> None:
|
|
139
|
+
"""Count one event reaching the sink fan-out and stamp its arrival time."""
|
|
140
|
+
now = time.perf_counter()
|
|
141
|
+
if self._first_perf is None:
|
|
142
|
+
self._first_perf = now
|
|
143
|
+
self._last_perf = now
|
|
144
|
+
self._events += 1
|
|
145
|
+
|
|
146
|
+
def record_drop(self) -> None:
|
|
147
|
+
"""Count one event dropped by the enricher (``process`` returned ``None``)."""
|
|
148
|
+
self._dropped_events += 1
|
|
149
|
+
|
|
150
|
+
def record_enrichment(self, seconds: float) -> None:
|
|
151
|
+
"""Record one enrichment call and the wall time it took."""
|
|
152
|
+
self._enrichment_calls += 1
|
|
153
|
+
self._enrichment_seconds += seconds
|
|
154
|
+
|
|
155
|
+
def record_sink_write(self, label: str, seconds: float, *, failed: bool) -> None:
|
|
156
|
+
"""Record one sink write: its wall time and whether it raised.
|
|
157
|
+
|
|
158
|
+
A failed write still counts as a write (the sink was invoked); ``failed``
|
|
159
|
+
additionally bumps that sink's failure tally.
|
|
160
|
+
"""
|
|
161
|
+
self._sink_writes[label] = self._sink_writes.get(label, 0) + 1
|
|
162
|
+
self._sink_seconds[label] = self._sink_seconds.get(label, 0.0) + seconds
|
|
163
|
+
if failed:
|
|
164
|
+
self._sink_failures[label] = self._sink_failures.get(label, 0) + 1
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def active_seconds(self) -> float:
|
|
168
|
+
"""Span from the first to the most recent recorded event, in seconds."""
|
|
169
|
+
if self._first_perf is None:
|
|
170
|
+
return 0.0
|
|
171
|
+
return max(0.0, self._last_perf - self._first_perf)
|
|
172
|
+
|
|
173
|
+
def snapshot(self) -> MetricsSnapshot:
|
|
174
|
+
"""Return an immutable summary of the metrics recorded so far."""
|
|
175
|
+
sinks = tuple(
|
|
176
|
+
SinkMetrics(
|
|
177
|
+
label=label,
|
|
178
|
+
writes=writes,
|
|
179
|
+
failures=self._sink_failures.get(label, 0),
|
|
180
|
+
write_seconds=self._sink_seconds.get(label, 0.0),
|
|
181
|
+
)
|
|
182
|
+
for label, writes in self._sink_writes.items()
|
|
183
|
+
)
|
|
184
|
+
return MetricsSnapshot(
|
|
185
|
+
events=self._events,
|
|
186
|
+
dropped_events=self._dropped_events,
|
|
187
|
+
sink_failures=sum(self._sink_failures.values()),
|
|
188
|
+
enrichment_calls=self._enrichment_calls,
|
|
189
|
+
enrichment_seconds=self._enrichment_seconds,
|
|
190
|
+
active_seconds=self.active_seconds,
|
|
191
|
+
sinks=sinks,
|
|
192
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Torch-free, CPU-only title generation for activity/step spans.
|
|
2
|
+
|
|
3
|
+
See :mod:`traceforge.title.inference` for the served titler. Title hygiene
|
|
4
|
+
(decode cleanup, parent/child de-dup) lives in :mod:`traceforge.title.hygiene`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .context import distilled_context
|
|
10
|
+
from .hygiene import best_of, clean_title, norm_key, pick_distinct
|
|
11
|
+
from .inference import TitleModel
|
|
12
|
+
from .inferencer import SessionTitleStream, TitleInferencer
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"SessionTitleStream",
|
|
16
|
+
"TitleInferencer",
|
|
17
|
+
"TitleModel",
|
|
18
|
+
"best_of",
|
|
19
|
+
"clean_title",
|
|
20
|
+
"distilled_context",
|
|
21
|
+
"norm_key",
|
|
22
|
+
"pick_distinct",
|
|
23
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Locate the packaged titler weights (the activity/step *span* head).
|
|
2
|
+
|
|
3
|
+
The ONNX weights ship in the separate :mod:`traceforge_title_model` package, which
|
|
4
|
+
is a hard dependency of traceforge, so a normal install always has them. This
|
|
5
|
+
module resolves the span head's directory, in order:
|
|
6
|
+
|
|
7
|
+
1. the installed :mod:`traceforge_title_model` package (the shipped path);
|
|
8
|
+
2. an in-tree dev fallback (``src/traceforge/title/data/``) so a source checkout
|
|
9
|
+
with the ONNX dropped in place still serves without the installed package;
|
|
10
|
+
3. ``None`` -> the caller raises :data:`INSTALL_HINT`.
|
|
11
|
+
|
|
12
|
+
Session naming no longer uses a packaged head (the distilled request head was
|
|
13
|
+
dropped as proven-weak); it is served by :mod:`traceforge.title.naming` instead.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
#: The three files :meth:`traceforge.title.TitleModel.load` reads for one head.
|
|
21
|
+
TRIAD = ("encoder.onnx", "decoder.onnx", "tokenizer.json")
|
|
22
|
+
|
|
23
|
+
_LFS_MAGIC = b"version https://git-lfs.github.com/spec/v1"
|
|
24
|
+
#: Real ONNX heads are tens of MB; a Git LFS pointer is ~133 bytes. This floor
|
|
25
|
+
#: cleanly separates a smudged binary from a pointer (or a truncated artifact).
|
|
26
|
+
_MIN_ONNX_BYTES = 1_000_000
|
|
27
|
+
|
|
28
|
+
_HERE = Path(__file__).resolve().parent
|
|
29
|
+
#: In-tree dev fallback. Empty in a normal install (weights live in the data
|
|
30
|
+
#: package); populated only if a developer drops the ONNX here by hand.
|
|
31
|
+
_DEV_SPAN = _HERE / "data"
|
|
32
|
+
|
|
33
|
+
INSTALL_HINT = (
|
|
34
|
+
"traceforge titler weights are not installed. They ship with the "
|
|
35
|
+
"'traceforge-title-model' package (a dependency of traceforge), so reinstalling "
|
|
36
|
+
"should restore them:\n"
|
|
37
|
+
" pip install --force-reinstall traceforge-title-model\n"
|
|
38
|
+
"or, if PyPI is unavailable, pull the GitHub-release mirror:\n"
|
|
39
|
+
" traceforge download-model --source gh"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _usable(f: Path) -> bool:
|
|
44
|
+
"""A weight file that is present *and* a real binary (not an LFS pointer).
|
|
45
|
+
|
|
46
|
+
Guards the pointer-in-wheel failure mode: if a package was built from a
|
|
47
|
+
checkout that never smudged LFS, the files exist but are ~133-byte pointer
|
|
48
|
+
stubs. Existence alone would then resolve as "installed" and fail only later,
|
|
49
|
+
deep inside onnxruntime. Reject pointers (and implausibly small ONNX) here so
|
|
50
|
+
the caller falls back / surfaces :data:`INSTALL_HINT` instead.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
if not f.is_file():
|
|
54
|
+
return False
|
|
55
|
+
if f.suffix == ".onnx" and f.stat().st_size < _MIN_ONNX_BYTES:
|
|
56
|
+
return False
|
|
57
|
+
with f.open("rb") as fh:
|
|
58
|
+
return not fh.read(len(_LFS_MAGIC)).startswith(_LFS_MAGIC)
|
|
59
|
+
except OSError:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _complete(d: Path | None) -> bool:
|
|
64
|
+
return d is not None and all(_usable(d / f) for f in TRIAD)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _pkg_dir() -> Path | None:
|
|
68
|
+
"""The span head dir from the installed data package, if complete."""
|
|
69
|
+
try:
|
|
70
|
+
import traceforge_title_model as m
|
|
71
|
+
except ImportError:
|
|
72
|
+
return None
|
|
73
|
+
d = m.span_dir()
|
|
74
|
+
return d if _complete(d) else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def span_dir() -> Path | None:
|
|
78
|
+
"""Resolved span (activity/step) head dir, or ``None`` if unavailable."""
|
|
79
|
+
return _pkg_dir() or (_DEV_SPAN if _complete(_DEV_SPAN) else None)
|