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.
Files changed (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. 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()