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,212 @@
|
|
|
1
|
+
"""OTel exporter sink — emit governance results as OpenTelemetry spans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from urllib.error import URLError
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
from traceforge.sinks.base import StorageSink
|
|
12
|
+
from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OtelExporterSink(StorageSink):
|
|
18
|
+
"""Exports enriched events as OTel spans via OTLP/HTTP JSON.
|
|
19
|
+
|
|
20
|
+
Uses a simplified OTLP JSON payload (not protobuf) to avoid heavy
|
|
21
|
+
dependencies. Compatible with any OTLP/HTTP collector.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
endpoint: str = "http://localhost:4318/v1/traces",
|
|
27
|
+
service_name: str = "traceforge",
|
|
28
|
+
headers: dict[str, str] | None = None,
|
|
29
|
+
max_backlog: int = 1024,
|
|
30
|
+
) -> None:
|
|
31
|
+
if not endpoint.startswith(("http://", "https://")):
|
|
32
|
+
raise ValueError(f"OTel endpoint must use http:// or https:// scheme, got: {endpoint}")
|
|
33
|
+
self._endpoint = endpoint
|
|
34
|
+
self._service_name = service_name
|
|
35
|
+
self._headers = headers or {}
|
|
36
|
+
self._batch: list[dict] = []
|
|
37
|
+
self._batch_size = 32
|
|
38
|
+
self._max_backlog = max_backlog
|
|
39
|
+
|
|
40
|
+
async def on_event(self, event: SessionEvent) -> None:
|
|
41
|
+
await self._enqueue(self._event_to_span(event))
|
|
42
|
+
|
|
43
|
+
async def _enqueue(self, span: dict) -> None:
|
|
44
|
+
self._batch.append(span)
|
|
45
|
+
|
|
46
|
+
if len(self._batch) >= self._max_backlog:
|
|
47
|
+
dropped = len(self._batch) - self._batch_size
|
|
48
|
+
self._batch = self._batch[-self._batch_size :]
|
|
49
|
+
logger.warning(
|
|
50
|
+
"OtelExporterSink: backlog exceeded %d, dropped %d oldest spans",
|
|
51
|
+
self._max_backlog,
|
|
52
|
+
dropped,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if len(self._batch) >= self._batch_size:
|
|
56
|
+
await self.flush()
|
|
57
|
+
|
|
58
|
+
async def flush(self) -> None:
|
|
59
|
+
if not self._batch:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
payload = {
|
|
63
|
+
"resourceSpans": [
|
|
64
|
+
{
|
|
65
|
+
"resource": {
|
|
66
|
+
"attributes": [
|
|
67
|
+
{"key": "service.name", "value": {"stringValue": self._service_name}},
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
"scopeSpans": [
|
|
71
|
+
{
|
|
72
|
+
"scope": {"name": "traceforge.governance"},
|
|
73
|
+
"spans": list(self._batch),
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
body = json.dumps(payload, default=str).encode("utf-8")
|
|
81
|
+
headers = {
|
|
82
|
+
"Content-Type": "application/json",
|
|
83
|
+
**self._headers,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
req = Request(self._endpoint, data=body, headers=headers, method="POST")
|
|
88
|
+
status = await asyncio.to_thread(self._do_request, req)
|
|
89
|
+
if status >= 300:
|
|
90
|
+
logger.warning("OtelExporterSink: OTLP endpoint returned %d", status)
|
|
91
|
+
return # keep batch for retry on next flush
|
|
92
|
+
self._batch.clear() # only clear after successful send
|
|
93
|
+
except (URLError, OSError, TimeoutError) as exc:
|
|
94
|
+
logger.error("OtelExporterSink: failed to export %d spans: %s", len(self._batch), exc)
|
|
95
|
+
|
|
96
|
+
def _do_request(self, req: Request) -> int:
|
|
97
|
+
"""Synchronous HTTP request — returns status code. Ensures response body is consumed."""
|
|
98
|
+
with urlopen(req, timeout=10) as resp:
|
|
99
|
+
resp.read()
|
|
100
|
+
return resp.status
|
|
101
|
+
|
|
102
|
+
async def close(self) -> None:
|
|
103
|
+
await self.flush()
|
|
104
|
+
|
|
105
|
+
def _event_to_span(self, event: SessionEvent) -> dict:
|
|
106
|
+
"""Convert a SessionEvent to an OTLP span dict."""
|
|
107
|
+
import uuid
|
|
108
|
+
|
|
109
|
+
ts_ns = int(event.timestamp.timestamp() * 1_000_000_000) if event.timestamp else 0
|
|
110
|
+
span_id = uuid.uuid4().hex[:16]
|
|
111
|
+
trace_id = uuid.uuid4().hex
|
|
112
|
+
|
|
113
|
+
attributes = [
|
|
114
|
+
{"key": "traceforge.event.kind", "value": {"stringValue": event.kind}},
|
|
115
|
+
{"key": "traceforge.session.id", "value": {"stringValue": event.session_id}},
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
if event.payload:
|
|
119
|
+
tool_name = event.payload.get("tool_name")
|
|
120
|
+
if tool_name:
|
|
121
|
+
attributes.append(
|
|
122
|
+
{"key": "gen_ai.tool.name", "value": {"stringValue": str(tool_name)}}
|
|
123
|
+
)
|
|
124
|
+
tool_args = event.payload.get("arguments") or event.payload.get("tool_input")
|
|
125
|
+
if tool_args:
|
|
126
|
+
attributes.append(
|
|
127
|
+
{
|
|
128
|
+
"key": "gen_ai.tool.call.arguments",
|
|
129
|
+
"value": {
|
|
130
|
+
"stringValue": str(tool_args)
|
|
131
|
+
if not isinstance(tool_args, str)
|
|
132
|
+
else tool_args
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
tool_result = event.payload.get("result") or event.payload.get("tool_result")
|
|
137
|
+
if tool_result:
|
|
138
|
+
attributes.append(
|
|
139
|
+
{"key": "gen_ai.tool.call.result", "value": {"stringValue": str(tool_result)}}
|
|
140
|
+
)
|
|
141
|
+
tool_call_id = event.payload.get("tool_call_id")
|
|
142
|
+
if tool_call_id:
|
|
143
|
+
attributes.append(
|
|
144
|
+
{"key": "gen_ai.tool.call.id", "value": {"stringValue": str(tool_call_id)}}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
gov = event.metadata.governance if event.metadata else None
|
|
148
|
+
if gov is not None:
|
|
149
|
+
if gov.risk_assessment is not None:
|
|
150
|
+
attributes.append(
|
|
151
|
+
{
|
|
152
|
+
"key": "traceforge.risk.score",
|
|
153
|
+
"value": {"intValue": gov.risk_assessment.score},
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
attributes.append(
|
|
157
|
+
{
|
|
158
|
+
"key": "traceforge.risk.level",
|
|
159
|
+
"value": {"stringValue": gov.risk_assessment.level},
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
if gov.recommendation is not None:
|
|
163
|
+
attributes.append(
|
|
164
|
+
{
|
|
165
|
+
"key": "traceforge.action",
|
|
166
|
+
"value": {"stringValue": gov.recommendation.recommended_action.value},
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"traceId": trace_id,
|
|
172
|
+
"spanId": span_id,
|
|
173
|
+
"name": f"traceforge.{event.kind}",
|
|
174
|
+
"kind": 1, # SPAN_KIND_INTERNAL
|
|
175
|
+
"startTimeUnixNano": str(ts_ns),
|
|
176
|
+
"endTimeUnixNano": str(ts_ns),
|
|
177
|
+
"attributes": attributes,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async def on_span(self, span: TelemetrySpan) -> None:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
async def on_usage(self, usage: UsageRecord) -> None:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
async def on_title_update(self, update: TitleUpdate) -> None:
|
|
187
|
+
await self._enqueue(self._title_update_to_span(update))
|
|
188
|
+
|
|
189
|
+
def _title_update_to_span(self, update: TitleUpdate) -> dict:
|
|
190
|
+
"""Convert a TitleUpdate to an OTLP span dict."""
|
|
191
|
+
import uuid
|
|
192
|
+
|
|
193
|
+
attributes = [
|
|
194
|
+
{"key": "traceforge.session.id", "value": {"stringValue": update.session_id}},
|
|
195
|
+
{"key": "traceforge.segment.id", "value": {"stringValue": update.segment_id}},
|
|
196
|
+
{"key": "traceforge.segment.kind", "value": {"stringValue": update.kind}},
|
|
197
|
+
{"key": "traceforge.segment.title", "value": {"stringValue": update.title}},
|
|
198
|
+
{"key": "traceforge.segment.title_version", "value": {"intValue": update.version}},
|
|
199
|
+
]
|
|
200
|
+
if update.parent_id is not None:
|
|
201
|
+
attributes.append(
|
|
202
|
+
{"key": "traceforge.segment.parent_id", "value": {"stringValue": update.parent_id}}
|
|
203
|
+
)
|
|
204
|
+
return {
|
|
205
|
+
"traceId": uuid.uuid4().hex,
|
|
206
|
+
"spanId": uuid.uuid4().hex[:16],
|
|
207
|
+
"name": f"traceforge.title.{update.kind}",
|
|
208
|
+
"kind": 1, # SPAN_KIND_INTERNAL
|
|
209
|
+
"startTimeUnixNano": "0",
|
|
210
|
+
"endTimeUnixNano": "0",
|
|
211
|
+
"attributes": attributes,
|
|
212
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Parquet output sink — per-session columnar files for analytics consumers.
|
|
2
|
+
|
|
3
|
+
Buffers SessionEvents in memory by session_id; flushes a parquet file when
|
|
4
|
+
SESSION_ENDED arrives, when the buffer exceeds ``max_buffered_events``, or
|
|
5
|
+
on ``close()``. One parquet file per session.
|
|
6
|
+
|
|
7
|
+
``pyarrow`` is a required core dependency — the canonical analytics format
|
|
8
|
+
is parquet, so we don't ship a parquet sink that pretends pyarrow is
|
|
9
|
+
optional.
|
|
10
|
+
|
|
11
|
+
See ``research/docs/06-pipeline-architecture.md`` for the design rationale,
|
|
12
|
+
schema, and consumer story.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import pyarrow as pa
|
|
25
|
+
import pyarrow.parquet as pq
|
|
26
|
+
|
|
27
|
+
from traceforge.phase.event_rows import event_to_feature_row as _row_from_event
|
|
28
|
+
from traceforge.sinks.base import StorageSink
|
|
29
|
+
from traceforge.types import EventKind, SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_DEFAULT_MAX_BUFFERED_EVENTS = 5_000
|
|
34
|
+
_DEFAULT_COMPRESSION = "zstd"
|
|
35
|
+
_DEFAULT_ROW_GROUP_SIZE = 10_000
|
|
36
|
+
_SAFE_SESSION_RE = re.compile(r"[^a-zA-Z0-9_\-]")
|
|
37
|
+
_SESSION_END_KINDS = frozenset({EventKind.SESSION_ENDED, EventKind.SESSION_PAUSED})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_title_schema() -> pa.Schema:
|
|
41
|
+
"""Schema for the per-session title sidecar (``<session>.titles.parquet``).
|
|
42
|
+
|
|
43
|
+
Titles are append-only :class:`~traceforge.types.TitleUpdate` records keyed to
|
|
44
|
+
a segment by ``segment_id``; consumers join them onto the events that carry
|
|
45
|
+
the matching ``activity_id``/``step_id``. Kept as a sidecar so the stable
|
|
46
|
+
event schema is never widened for late-arriving titles.
|
|
47
|
+
"""
|
|
48
|
+
return pa.schema(
|
|
49
|
+
[
|
|
50
|
+
pa.field("session_id", pa.string()),
|
|
51
|
+
pa.field("segment_id", pa.string()),
|
|
52
|
+
pa.field("kind", pa.dictionary(pa.int32(), pa.string())),
|
|
53
|
+
pa.field("title", pa.string()),
|
|
54
|
+
pa.field("version", pa.int64()),
|
|
55
|
+
pa.field("parent_id", pa.string()),
|
|
56
|
+
]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_schema() -> pa.Schema:
|
|
61
|
+
"""Stable column schema mirroring the canonical ``Classification`` and
|
|
62
|
+
``EventMetadata`` shapes.
|
|
63
|
+
|
|
64
|
+
Multi-valued classification dimensions (``scope``, ``role``, ``action``,
|
|
65
|
+
``capability``, ``structure``, ``source_labels``) are ``list<string>``
|
|
66
|
+
because the underlying type is ``frozenset[str]`` — not a scalar.
|
|
67
|
+
|
|
68
|
+
New fields land in ``payload_json`` / ``metadata_json`` until promoted to
|
|
69
|
+
typed columns.
|
|
70
|
+
"""
|
|
71
|
+
return pa.schema(
|
|
72
|
+
[
|
|
73
|
+
pa.field("event_id", pa.string()),
|
|
74
|
+
pa.field("session_id", pa.string()),
|
|
75
|
+
pa.field("kind", pa.dictionary(pa.int32(), pa.string())),
|
|
76
|
+
pa.field("timestamp_ns", pa.timestamp("ns", tz="UTC")),
|
|
77
|
+
pa.field("seq", pa.int64()),
|
|
78
|
+
pa.field("tool_name", pa.dictionary(pa.int32(), pa.string())),
|
|
79
|
+
pa.field("mechanism", pa.dictionary(pa.int32(), pa.string())),
|
|
80
|
+
pa.field("effect", pa.dictionary(pa.int32(), pa.string())),
|
|
81
|
+
pa.field("scope", pa.list_(pa.string())),
|
|
82
|
+
pa.field("role", pa.list_(pa.string())),
|
|
83
|
+
pa.field("action", pa.list_(pa.string())),
|
|
84
|
+
pa.field("capability", pa.list_(pa.string())),
|
|
85
|
+
pa.field("structure", pa.list_(pa.string())),
|
|
86
|
+
pa.field("source_labels", pa.list_(pa.string())),
|
|
87
|
+
pa.field("shell_dialect", pa.dictionary(pa.int32(), pa.string())),
|
|
88
|
+
pa.field("binaries", pa.list_(pa.string())),
|
|
89
|
+
pa.field("phase_signals", pa.list_(pa.string())),
|
|
90
|
+
pa.field("activity", pa.string()),
|
|
91
|
+
pa.field("motivation", pa.string()),
|
|
92
|
+
pa.field("payload_json", pa.string()),
|
|
93
|
+
pa.field("metadata_json", pa.string()),
|
|
94
|
+
pa.field("duration_ms", pa.int64()),
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ParquetSink(StorageSink):
|
|
100
|
+
"""Per-session parquet sink.
|
|
101
|
+
|
|
102
|
+
Buffers events in memory keyed by ``session_id``; emits one parquet file
|
|
103
|
+
per session. Flushes when:
|
|
104
|
+
|
|
105
|
+
- the session ends (``EventKind.SESSION_ENDED``)
|
|
106
|
+
- the per-session buffer exceeds ``max_buffered_events``
|
|
107
|
+
- ``flush()`` or ``close()`` is called
|
|
108
|
+
|
|
109
|
+
Output path supports ``{session_id}`` as a template variable. If the
|
|
110
|
+
template has no ``{session_id}``, ``"<session_id>.parquet"`` is appended.
|
|
111
|
+
|
|
112
|
+
The schema is intentionally stable — see ``_build_schema`` and
|
|
113
|
+
``research/docs/06-pipeline-architecture.md``. New fields land in
|
|
114
|
+
``payload_json`` / ``metadata_json`` until promoted.
|
|
115
|
+
|
|
116
|
+
Resumed sessions: if a session emits more events after a flush
|
|
117
|
+
(``SESSION_RESUMED`` then more events), this sink writes to
|
|
118
|
+
``<session_id>.<n>.parquet`` for n=1, 2, ... so existing files are not
|
|
119
|
+
overwritten.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
path: str | Path,
|
|
125
|
+
max_buffered_events: int = _DEFAULT_MAX_BUFFERED_EVENTS,
|
|
126
|
+
compression: str = _DEFAULT_COMPRESSION,
|
|
127
|
+
row_group_size: int = _DEFAULT_ROW_GROUP_SIZE,
|
|
128
|
+
) -> None:
|
|
129
|
+
self._path_template = str(path)
|
|
130
|
+
self._max_buffered_events = max_buffered_events
|
|
131
|
+
self._compression = compression
|
|
132
|
+
self._row_group_size = row_group_size
|
|
133
|
+
|
|
134
|
+
self._schema = _build_schema()
|
|
135
|
+
self._title_schema = _build_title_schema()
|
|
136
|
+
|
|
137
|
+
self._buffers: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
138
|
+
self._title_buffers: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
139
|
+
self._seq_counters: dict[str, int] = defaultdict(int)
|
|
140
|
+
self._flush_index: dict[str, int] = defaultdict(int)
|
|
141
|
+
self._lock = asyncio.Lock()
|
|
142
|
+
|
|
143
|
+
def _resolve_path(self, session_id: str, flush_idx: int) -> Path:
|
|
144
|
+
sanitized = _SAFE_SESSION_RE.sub("_", session_id)[:128]
|
|
145
|
+
suffix = f".{flush_idx}" if flush_idx > 0 else ""
|
|
146
|
+
|
|
147
|
+
if "{session_id}" in self._path_template:
|
|
148
|
+
resolved = self._path_template.replace("{session_id}", sanitized)
|
|
149
|
+
base = Path(resolved)
|
|
150
|
+
if suffix:
|
|
151
|
+
base = base.with_suffix(f"{suffix}{base.suffix}")
|
|
152
|
+
else:
|
|
153
|
+
base = Path(self._path_template) / f"{sanitized}{suffix}.parquet"
|
|
154
|
+
|
|
155
|
+
path = base.expanduser().resolve()
|
|
156
|
+
|
|
157
|
+
# Containment check: resolved path must stay under the template's
|
|
158
|
+
# parent directory.
|
|
159
|
+
if "{session_id}" in self._path_template:
|
|
160
|
+
base_dir = Path(self._path_template.split("{session_id}")[0]).expanduser().resolve()
|
|
161
|
+
else:
|
|
162
|
+
base_dir = Path(self._path_template).expanduser().resolve()
|
|
163
|
+
if not str(path).startswith(str(base_dir)):
|
|
164
|
+
raise ValueError(f"ParquetSink: resolved path escapes base directory: {path}")
|
|
165
|
+
return path
|
|
166
|
+
|
|
167
|
+
async def on_event(self, event: SessionEvent) -> None:
|
|
168
|
+
async with self._lock:
|
|
169
|
+
sid = event.session_id
|
|
170
|
+
seq = self._seq_counters[sid]
|
|
171
|
+
self._seq_counters[sid] = seq + 1
|
|
172
|
+
|
|
173
|
+
row = _row_from_event(event, seq)
|
|
174
|
+
self._buffers[sid].append(row)
|
|
175
|
+
|
|
176
|
+
should_flush = (
|
|
177
|
+
event.kind in _SESSION_END_KINDS
|
|
178
|
+
or len(self._buffers[sid]) >= self._max_buffered_events
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if should_flush:
|
|
182
|
+
await self._flush_session(sid)
|
|
183
|
+
|
|
184
|
+
async def _flush_session(self, session_id: str) -> None:
|
|
185
|
+
"""Write the buffered rows for one session and clear them."""
|
|
186
|
+
async with self._lock:
|
|
187
|
+
rows = self._buffers.pop(session_id, [])
|
|
188
|
+
titles = self._title_buffers.pop(session_id, [])
|
|
189
|
+
if not rows and not titles:
|
|
190
|
+
return
|
|
191
|
+
flush_idx = self._flush_index[session_id]
|
|
192
|
+
self._flush_index[session_id] = flush_idx + 1
|
|
193
|
+
|
|
194
|
+
if rows:
|
|
195
|
+
await asyncio.to_thread(self._write_rows, session_id, flush_idx, rows)
|
|
196
|
+
if titles:
|
|
197
|
+
await asyncio.to_thread(self._write_titles, session_id, flush_idx, titles)
|
|
198
|
+
|
|
199
|
+
def _write_rows(self, session_id: str, flush_idx: int, rows: list[dict[str, Any]]) -> None:
|
|
200
|
+
path = self._resolve_path(session_id, flush_idx)
|
|
201
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
|
|
203
|
+
# Conversion errors are programming bugs (schema vs. row shape) and
|
|
204
|
+
# must surface — silently dropping a session's data is worse than a
|
|
205
|
+
# loud failure. OSError is the only "expected" runtime failure
|
|
206
|
+
# (disk full, permission denied) and gets a structured log.
|
|
207
|
+
table = pa.Table.from_pylist(rows, schema=self._schema)
|
|
208
|
+
try:
|
|
209
|
+
pq.write_table(
|
|
210
|
+
table,
|
|
211
|
+
path,
|
|
212
|
+
compression=self._compression,
|
|
213
|
+
row_group_size=self._row_group_size,
|
|
214
|
+
)
|
|
215
|
+
except OSError as exc:
|
|
216
|
+
logger.error("ParquetSink: failed to write %s: %s", path, exc)
|
|
217
|
+
raise
|
|
218
|
+
|
|
219
|
+
async def on_span(self, span: TelemetrySpan) -> None:
|
|
220
|
+
# Spans are not part of the per-event schema; sidecar parquet for
|
|
221
|
+
# spans is a future addition.
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
async def on_usage(self, usage: UsageRecord) -> None:
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
async def on_title_update(self, update: TitleUpdate) -> None:
|
|
228
|
+
async with self._lock:
|
|
229
|
+
self._title_buffers[update.session_id].append(
|
|
230
|
+
{
|
|
231
|
+
"session_id": update.session_id,
|
|
232
|
+
"segment_id": update.segment_id,
|
|
233
|
+
"kind": update.kind,
|
|
234
|
+
"title": update.title,
|
|
235
|
+
"version": update.version,
|
|
236
|
+
"parent_id": update.parent_id,
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _write_titles(self, session_id: str, flush_idx: int, rows: list[dict[str, Any]]) -> None:
|
|
241
|
+
evt_path = self._resolve_path(session_id, flush_idx)
|
|
242
|
+
path = evt_path.with_name(f"{evt_path.stem}.titles{evt_path.suffix}")
|
|
243
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
|
|
245
|
+
table = pa.Table.from_pylist(rows, schema=self._title_schema)
|
|
246
|
+
try:
|
|
247
|
+
pq.write_table(table, path, compression=self._compression)
|
|
248
|
+
except OSError as exc:
|
|
249
|
+
logger.error("ParquetSink: failed to write titles %s: %s", path, exc)
|
|
250
|
+
raise
|
|
251
|
+
|
|
252
|
+
async def flush(self) -> None:
|
|
253
|
+
"""Flush every buffered session (events and/or titles)."""
|
|
254
|
+
async with self._lock:
|
|
255
|
+
sids = set(self._buffers) | set(self._title_buffers)
|
|
256
|
+
for sid in sids:
|
|
257
|
+
await self._flush_session(sid)
|
|
258
|
+
|
|
259
|
+
async def close(self) -> None:
|
|
260
|
+
await self.flush()
|
traceforge/sinks/s3.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""S3 sink — buffer events and flush as JSONL objects to S3-compatible storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
from traceforge.sinks.base import StorageSink
|
|
14
|
+
from traceforge.types import SessionEvent, TelemetrySpan, TitleUpdate, UsageRecord
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_DEFAULT_BUFFER_SIZE = 100
|
|
19
|
+
_DEFAULT_FLUSH_INTERVAL_SECONDS = 60.0
|
|
20
|
+
_SAFE_SESSION_RE = re.compile(r"[^a-zA-Z0-9_\-]")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _require_boto3():
|
|
24
|
+
"""Import and return boto3, raising a helpful error if missing."""
|
|
25
|
+
try:
|
|
26
|
+
import boto3
|
|
27
|
+
|
|
28
|
+
return boto3
|
|
29
|
+
except ImportError:
|
|
30
|
+
raise ImportError(
|
|
31
|
+
"boto3 is required for S3Sink. Install it with: pip install traceforge-toolkit[s3]"
|
|
32
|
+
) from None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class S3Sink(StorageSink):
|
|
36
|
+
"""Buffers events in memory and flushes to S3 as JSONL objects.
|
|
37
|
+
|
|
38
|
+
Object key format: {prefix}{session_id}/{date}/{timestamp}-{uuid_short}.jsonl
|
|
39
|
+
|
|
40
|
+
Requires boto3 (optional dependency). Install via ``pip install traceforge-toolkit[s3]``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
bucket: str,
|
|
46
|
+
prefix: str = "",
|
|
47
|
+
region: str | None = None,
|
|
48
|
+
endpoint_url: str | None = None,
|
|
49
|
+
buffer_size: int = _DEFAULT_BUFFER_SIZE,
|
|
50
|
+
flush_interval: float = _DEFAULT_FLUSH_INTERVAL_SECONDS,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._bucket = bucket
|
|
53
|
+
self._prefix = prefix
|
|
54
|
+
self._region = region
|
|
55
|
+
self._endpoint_url = endpoint_url
|
|
56
|
+
self._buffer_size = buffer_size
|
|
57
|
+
self._flush_interval = flush_interval
|
|
58
|
+
|
|
59
|
+
self._buffer: list[dict] = []
|
|
60
|
+
self._last_flush_time: float = time.monotonic()
|
|
61
|
+
self._session_id: str | None = None
|
|
62
|
+
self._lock = asyncio.Lock()
|
|
63
|
+
|
|
64
|
+
# Validate boto3 is available at construction time
|
|
65
|
+
self._boto3 = _require_boto3()
|
|
66
|
+
self._client = None
|
|
67
|
+
|
|
68
|
+
def _get_client(self):
|
|
69
|
+
"""Lazily create the S3 client."""
|
|
70
|
+
if self._client is None:
|
|
71
|
+
kwargs: dict = {}
|
|
72
|
+
if self._region:
|
|
73
|
+
kwargs["region_name"] = self._region
|
|
74
|
+
if self._endpoint_url:
|
|
75
|
+
kwargs["endpoint_url"] = self._endpoint_url
|
|
76
|
+
self._client = self._boto3.client("s3", **kwargs)
|
|
77
|
+
return self._client
|
|
78
|
+
|
|
79
|
+
def _make_object_key(self, session_id: str) -> str:
|
|
80
|
+
"""Generate the S3 object key for the current flush.
|
|
81
|
+
|
|
82
|
+
Sanitizes session_id to prevent unexpected characters in S3 keys.
|
|
83
|
+
"""
|
|
84
|
+
sanitized = _SAFE_SESSION_RE.sub("_", session_id)[:128]
|
|
85
|
+
sanitized = sanitized or "unknown"
|
|
86
|
+
now = datetime.now(timezone.utc)
|
|
87
|
+
date_str = now.strftime("%Y-%m-%d")
|
|
88
|
+
timestamp_str = now.strftime("%Y%m%dT%H%M%S")
|
|
89
|
+
short_uuid = uuid.uuid4().hex[:8]
|
|
90
|
+
return f"{self._prefix}{sanitized}/{date_str}/{timestamp_str}-{short_uuid}.jsonl"
|
|
91
|
+
|
|
92
|
+
def _serialize_event(self, event: SessionEvent) -> dict:
|
|
93
|
+
return {
|
|
94
|
+
"id": event.id,
|
|
95
|
+
"kind": event.kind,
|
|
96
|
+
"session_id": event.session_id,
|
|
97
|
+
"timestamp": event.timestamp.isoformat(),
|
|
98
|
+
"payload": event.payload,
|
|
99
|
+
"metadata": event.metadata.model_dump(exclude_none=True) if event.metadata else None,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def _serialize_span(self, span: TelemetrySpan) -> dict:
|
|
103
|
+
return {
|
|
104
|
+
"type": "span",
|
|
105
|
+
"name": span.name,
|
|
106
|
+
"session_id": span.session_id,
|
|
107
|
+
"start_time": span.start_time.isoformat(),
|
|
108
|
+
"end_time": span.end_time.isoformat(),
|
|
109
|
+
"attributes": span.attributes,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def _serialize_usage(self, usage: UsageRecord) -> dict:
|
|
113
|
+
return {
|
|
114
|
+
"type": "usage",
|
|
115
|
+
"session_id": usage.session_id,
|
|
116
|
+
"timestamp": usage.timestamp.isoformat(),
|
|
117
|
+
"model": usage.model,
|
|
118
|
+
"input_tokens": usage.input_tokens,
|
|
119
|
+
"output_tokens": usage.output_tokens,
|
|
120
|
+
"cost_usd": usage.cost_usd,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
def _serialize_title_update(self, update: TitleUpdate) -> dict:
|
|
124
|
+
return {
|
|
125
|
+
"type": "title_update",
|
|
126
|
+
"session_id": update.session_id,
|
|
127
|
+
"segment_id": update.segment_id,
|
|
128
|
+
"kind": update.kind,
|
|
129
|
+
"title": update.title,
|
|
130
|
+
"version": update.version,
|
|
131
|
+
"parent_id": update.parent_id,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async def on_event(self, event: SessionEvent) -> None:
|
|
135
|
+
async with self._lock:
|
|
136
|
+
if self._session_id is None:
|
|
137
|
+
self._session_id = event.session_id
|
|
138
|
+
self._buffer.append(self._serialize_event(event))
|
|
139
|
+
if self._should_flush():
|
|
140
|
+
await self._flush_buffer()
|
|
141
|
+
|
|
142
|
+
async def on_span(self, span: TelemetrySpan) -> None:
|
|
143
|
+
async with self._lock:
|
|
144
|
+
if self._session_id is None:
|
|
145
|
+
self._session_id = span.session_id
|
|
146
|
+
self._buffer.append(self._serialize_span(span))
|
|
147
|
+
if self._should_flush():
|
|
148
|
+
await self._flush_buffer()
|
|
149
|
+
|
|
150
|
+
async def on_usage(self, usage: UsageRecord) -> None:
|
|
151
|
+
async with self._lock:
|
|
152
|
+
if self._session_id is None:
|
|
153
|
+
self._session_id = usage.session_id
|
|
154
|
+
self._buffer.append(self._serialize_usage(usage))
|
|
155
|
+
if self._should_flush():
|
|
156
|
+
await self._flush_buffer()
|
|
157
|
+
|
|
158
|
+
async def on_title_update(self, update: TitleUpdate) -> None:
|
|
159
|
+
async with self._lock:
|
|
160
|
+
if self._session_id is None:
|
|
161
|
+
self._session_id = update.session_id
|
|
162
|
+
self._buffer.append(self._serialize_title_update(update))
|
|
163
|
+
if self._should_flush():
|
|
164
|
+
await self._flush_buffer()
|
|
165
|
+
|
|
166
|
+
def _should_flush(self) -> bool:
|
|
167
|
+
if len(self._buffer) >= self._buffer_size:
|
|
168
|
+
return True
|
|
169
|
+
elapsed = time.monotonic() - self._last_flush_time
|
|
170
|
+
if elapsed >= self._flush_interval:
|
|
171
|
+
return True
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
async def _flush_buffer(self) -> None:
|
|
175
|
+
"""Upload buffered items to S3 and reset the buffer."""
|
|
176
|
+
if not self._buffer:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
session_id = self._session_id or "unknown"
|
|
180
|
+
key = self._make_object_key(session_id)
|
|
181
|
+
body = "\n".join(json.dumps(item, default=str) for item in self._buffer) + "\n"
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
client = self._get_client()
|
|
185
|
+
await asyncio.to_thread(
|
|
186
|
+
client.put_object,
|
|
187
|
+
Bucket=self._bucket,
|
|
188
|
+
Key=key,
|
|
189
|
+
Body=body.encode("utf-8"),
|
|
190
|
+
ContentType="application/x-ndjson",
|
|
191
|
+
)
|
|
192
|
+
logger.debug(
|
|
193
|
+
"S3Sink: flushed %d items to s3://%s/%s", len(self._buffer), self._bucket, key
|
|
194
|
+
)
|
|
195
|
+
except Exception as exc:
|
|
196
|
+
logger.error("S3Sink: failed to upload to s3://%s/%s: %s", self._bucket, key, exc)
|
|
197
|
+
|
|
198
|
+
self._buffer.clear()
|
|
199
|
+
self._last_flush_time = time.monotonic()
|
|
200
|
+
|
|
201
|
+
async def flush(self) -> None:
|
|
202
|
+
async with self._lock:
|
|
203
|
+
await self._flush_buffer()
|
|
204
|
+
|
|
205
|
+
async def close(self) -> None:
|
|
206
|
+
await self.flush()
|