forgesight-clickhouse 0.1.0__tar.gz

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.
@@ -0,0 +1,38 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ *.so
9
+
10
+ # venv / tooling
11
+ .venv/
12
+ venv/
13
+ .uv/
14
+ uv.lock
15
+
16
+ # test / type / lint caches
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ .coverage
21
+ .coverage.*
22
+ coverage.xml
23
+ htmlcov/
24
+
25
+ # secrets / local env (never commit)
26
+ .env
27
+ .env.*
28
+
29
+ # editor / OS
30
+ .DS_Store
31
+ .idea/
32
+ .vscode/
33
+
34
+ # local-only session working state (per the workspace pipeline)
35
+ .claude/state/
36
+
37
+ # local-only launch planning (not part of the published repo)
38
+ /launch/
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: forgesight-clickhouse
3
+ Version: 0.1.0
4
+ Summary: ForgeSight ClickHouse exporter — columnar batch insert of immutable records into a denormalized MergeTree.
5
+ Project-URL: Homepage, https://github.com/Scaffoldic/forgesight
6
+ Project-URL: Repository, https://github.com/Scaffoldic/forgesight
7
+ Project-URL: Issues, https://github.com/Scaffoldic/forgesight/issues
8
+ Project-URL: Changelog, https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md
9
+ Author: kjoshi
10
+ License-Expression: Apache-2.0
11
+ Keywords: ai-agents,analytics,clickhouse,columnar,forgesight,observability
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Information Technology
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: System :: Monitoring
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: clickhouse-connect>=0.7
24
+ Requires-Dist: forgesight-core
25
+ Description-Content-Type: text/markdown
26
+
27
+ # forgesight-clickhouse
28
+
29
+ The ClickHouse exporter for [ForgeSight](https://github.com/Scaffoldic/forgesight).
30
+ Writes each ForgeSight record as one row in a **denormalized single-table MergeTree**,
31
+ batched columnar — so a platform team can *query* its agent telemetry in SQL: spend by
32
+ team, p99 LLM latency by model, tokens per workflow over 90 days.
33
+
34
+ ```bash
35
+ pip install forgesight-clickhouse
36
+ ```
37
+
38
+ ```python
39
+ import forgesight
40
+ from forgesight_clickhouse import ClickHouseExporter
41
+
42
+ forgesight.configure(exporters=[
43
+ ClickHouseExporter(
44
+ dsn="clickhouse://user:pass@ch-host:8443/agents?secure=true",
45
+ table="agentforge_records",
46
+ create_table=True, # dev convenience; production runs the shipped migration
47
+ ),
48
+ ])
49
+ ```
50
+
51
+ Or by name: `exporters: [{name: clickhouse, config: {dsn: "${FORGESIGHT_CLICKHOUSE_DSN}"}}]`.
52
+
53
+ ## Why a columnar exporter
54
+
55
+ A Record is written once and never updated (OTel's immutable span model), so trace-level
56
+ **business metadata is denormalized onto every row** — no join table, so analytical
57
+ queries never pay a join:
58
+
59
+ ```sql
60
+ SELECT agent_name, quantile(0.99)(duration_ms)
61
+ FROM agentforge_records WHERE kind = 'llm' GROUP BY agent_name;
62
+
63
+ SELECT metadata.team, sum(cost_usd)
64
+ FROM agentforge_records
65
+ WHERE kind = 'llm' AND start_time >= now() - INTERVAL 30 DAY
66
+ GROUP BY metadata.team;
67
+ ```
68
+
69
+ Inserts are **batched** by the SDK's export pipeline (ClickHouse hates row-at-a-time): one
70
+ columnar INSERT per batch, on the export worker, fault-isolated. A ClickHouse outage makes
71
+ `export()` return `FAILURE` (counted, never raised — P6); it never blocks the agent.
72
+
73
+ ## Schema & migrations
74
+
75
+ The DDL ships in the package (`migrations/0001_init.sql`). `create_table=True` runs
76
+ `CREATE TABLE IF NOT EXISTS` on first export for dev; production applies the migration
77
+ out-of-band. New domain fields arrive as new nullable columns via numbered migrations —
78
+ old queries keep working.
79
+
80
+ ## Configuration
81
+
82
+ | Key | Env | Default |
83
+ |---|---|---|
84
+ | `dsn` | `FORGESIGHT_CLICKHOUSE_DSN` | — (required) |
85
+ | `table` | `FORGESIGHT_CLICKHOUSE_TABLE` | `agentforge_records` |
86
+ | `batch_size` | `FORGESIGHT_CLICKHOUSE_BATCH_SIZE` | `512` (clamped to the pipeline max) |
87
+ | `async_insert` | `FORGESIGHT_CLICKHOUSE_ASYNC_INSERT` | `true` |
88
+ | `wait_for_async_insert` | `FORGESIGHT_CLICKHOUSE_WAIT_ASYNC` | `false` |
89
+ | `create_table` | `FORGESIGHT_CLICKHOUSE_CREATE_TABLE` | `false` |
90
+
91
+ Constructor kwargs win over env (FR-12). Prompt/response **content is never stored** in
92
+ the base table; it is gated SDK-wide by `capture_content` (off by default, P7).
93
+
94
+ ## License
95
+
96
+ Apache-2.0
@@ -0,0 +1,70 @@
1
+ # forgesight-clickhouse
2
+
3
+ The ClickHouse exporter for [ForgeSight](https://github.com/Scaffoldic/forgesight).
4
+ Writes each ForgeSight record as one row in a **denormalized single-table MergeTree**,
5
+ batched columnar — so a platform team can *query* its agent telemetry in SQL: spend by
6
+ team, p99 LLM latency by model, tokens per workflow over 90 days.
7
+
8
+ ```bash
9
+ pip install forgesight-clickhouse
10
+ ```
11
+
12
+ ```python
13
+ import forgesight
14
+ from forgesight_clickhouse import ClickHouseExporter
15
+
16
+ forgesight.configure(exporters=[
17
+ ClickHouseExporter(
18
+ dsn="clickhouse://user:pass@ch-host:8443/agents?secure=true",
19
+ table="agentforge_records",
20
+ create_table=True, # dev convenience; production runs the shipped migration
21
+ ),
22
+ ])
23
+ ```
24
+
25
+ Or by name: `exporters: [{name: clickhouse, config: {dsn: "${FORGESIGHT_CLICKHOUSE_DSN}"}}]`.
26
+
27
+ ## Why a columnar exporter
28
+
29
+ A Record is written once and never updated (OTel's immutable span model), so trace-level
30
+ **business metadata is denormalized onto every row** — no join table, so analytical
31
+ queries never pay a join:
32
+
33
+ ```sql
34
+ SELECT agent_name, quantile(0.99)(duration_ms)
35
+ FROM agentforge_records WHERE kind = 'llm' GROUP BY agent_name;
36
+
37
+ SELECT metadata.team, sum(cost_usd)
38
+ FROM agentforge_records
39
+ WHERE kind = 'llm' AND start_time >= now() - INTERVAL 30 DAY
40
+ GROUP BY metadata.team;
41
+ ```
42
+
43
+ Inserts are **batched** by the SDK's export pipeline (ClickHouse hates row-at-a-time): one
44
+ columnar INSERT per batch, on the export worker, fault-isolated. A ClickHouse outage makes
45
+ `export()` return `FAILURE` (counted, never raised — P6); it never blocks the agent.
46
+
47
+ ## Schema & migrations
48
+
49
+ The DDL ships in the package (`migrations/0001_init.sql`). `create_table=True` runs
50
+ `CREATE TABLE IF NOT EXISTS` on first export for dev; production applies the migration
51
+ out-of-band. New domain fields arrive as new nullable columns via numbered migrations —
52
+ old queries keep working.
53
+
54
+ ## Configuration
55
+
56
+ | Key | Env | Default |
57
+ |---|---|---|
58
+ | `dsn` | `FORGESIGHT_CLICKHOUSE_DSN` | — (required) |
59
+ | `table` | `FORGESIGHT_CLICKHOUSE_TABLE` | `agentforge_records` |
60
+ | `batch_size` | `FORGESIGHT_CLICKHOUSE_BATCH_SIZE` | `512` (clamped to the pipeline max) |
61
+ | `async_insert` | `FORGESIGHT_CLICKHOUSE_ASYNC_INSERT` | `true` |
62
+ | `wait_for_async_insert` | `FORGESIGHT_CLICKHOUSE_WAIT_ASYNC` | `false` |
63
+ | `create_table` | `FORGESIGHT_CLICKHOUSE_CREATE_TABLE` | `false` |
64
+
65
+ Constructor kwargs win over env (FR-12). Prompt/response **content is never stored** in
66
+ the base table; it is gated SDK-wide by `capture_content` (off by default, P7).
67
+
68
+ ## License
69
+
70
+ Apache-2.0
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "forgesight-clickhouse"
3
+ version = "0.1.0"
4
+ description = "ForgeSight ClickHouse exporter — columnar batch insert of immutable records into a denormalized MergeTree."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "kjoshi" }]
9
+ keywords = ["observability", "clickhouse", "analytics", "ai-agents", "forgesight", "columnar"]
10
+ classifiers = [
11
+ "Development Status :: 2 - Pre-Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Intended Audience :: Information Technology",
14
+ "Topic :: System :: Monitoring",
15
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = ["forgesight-core", "clickhouse-connect>=0.7"]
23
+
24
+ [project.entry-points."forgesight.exporters"]
25
+ clickhouse = "forgesight_clickhouse.exporter:ClickHouseExporter"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/Scaffoldic/forgesight"
29
+ Repository = "https://github.com/Scaffoldic/forgesight"
30
+ Issues = "https://github.com/Scaffoldic/forgesight/issues"
31
+ Changelog = "https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/forgesight_clickhouse"]
39
+
40
+ [tool.uv.sources]
41
+ forgesight-core = { workspace = true }
@@ -0,0 +1,17 @@
1
+ """ForgeSight ClickHouse exporter — columnar batch insert into a denormalized MergeTree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .exporter import COLUMNS, ClickHouseClient, ClickHouseExporter
6
+ from .testing import InMemoryClickHouseClient, InsertCall
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "COLUMNS",
12
+ "ClickHouseClient",
13
+ "ClickHouseExporter",
14
+ "InMemoryClickHouseClient",
15
+ "InsertCall",
16
+ "__version__",
17
+ ]
@@ -0,0 +1,297 @@
1
+ """``ClickHouseExporter`` — columnar batch insert of immutable records into ClickHouse.
2
+
3
+ A :class:`~forgesight_api.TelemetryExporter` (so it resolves via the
4
+ ``forgesight.exporters`` entry point and passes the conformance suite) that writes each
5
+ :class:`~forgesight_api.Record` as one row in a **denormalized single-table MergeTree**.
6
+ Because a Record is written once and never updated (OTel's immutable span model),
7
+ trace-level business metadata is denormalized onto every row — no join table, so
8
+ analytical queries (``sum(cost_usd) GROUP BY team``) never pay a join.
9
+
10
+ It runs on the export worker (feat-003), never the hot path: ``export()`` receives a
11
+ batch already sized by the pipeline and issues **one** columnar INSERT per ``batch_size``
12
+ chunk. ``export`` never raises (P6): a ClickHouse outage returns ``ExportResult.FAILURE``,
13
+ counted by the pipeline, invisible to the agent.
14
+
15
+ The vendor driver (``clickhouse-connect``) is imported lazily and built from the DSN on
16
+ first export, so construction never touches the network. Tests inject a client double
17
+ (:class:`~forgesight_clickhouse.testing.InMemoryClickHouseClient`).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import re
26
+ from collections.abc import Mapping, Sequence
27
+ from importlib.resources import files
28
+ from typing import Protocol, cast, runtime_checkable
29
+
30
+ from forgesight_api import ExportResult, Kind, Record, RunStatus
31
+
32
+ _log = logging.getLogger("forgesight.clickhouse")
33
+
34
+ DEFAULT_TABLE = "agentforge_records"
35
+ DEFAULT_BATCH_SIZE = 512
36
+ DEFAULT_MAX_EXPORT_BATCH_SIZE = 512 # pipeline default (exporter-pipeline.md §4.8)
37
+
38
+ # operation.name values (gen_ai.operation.name) — kept local; no forgesight-otel dep (P1)
39
+ _OP_INVOKE_AGENT = "invoke_agent"
40
+ _OP_INVOKE_WORKFLOW = "invoke_workflow"
41
+ _OP_CHAT = "chat"
42
+ _OP_EXECUTE_TOOL = "execute_tool"
43
+ _MCP_TOOLS_CALL = "tools/call"
44
+
45
+ # structured keys feat-002 stashes in Record.attributes — lifted to their own columns,
46
+ # so they are not duplicated into the denormalized `metadata` JSON blob.
47
+ _AGENT_VERSION_KEY = "agent.version"
48
+ _PARENT_RUN_ID_KEY = "parent.run_id"
49
+ _CONTEXT_ID_KEY = "context.id"
50
+ _STRUCTURED_KEYS = frozenset({_AGENT_VERSION_KEY, _PARENT_RUN_ID_KEY, _CONTEXT_ID_KEY})
51
+
52
+ _OK_STATUSES = frozenset({RunStatus.OK, RunStatus.RUNNING})
53
+ _ENV_PREFIX = "FORGESIGHT_CLICKHOUSE_"
54
+ # table name (optionally db-qualified), validated to keep it out of the INSERT verbatim.
55
+ _IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$")
56
+
57
+ # Column order — must match migrations/0001_init.sql exactly.
58
+ COLUMNS: tuple[str, ...] = (
59
+ "run_id",
60
+ "trace_id",
61
+ "parent_run_id",
62
+ "context_id",
63
+ "kind",
64
+ "op",
65
+ "agent_name",
66
+ "agent_version",
67
+ "provider",
68
+ "model",
69
+ "tool_name",
70
+ "mcp_server",
71
+ "mcp_method",
72
+ "input_tokens",
73
+ "output_tokens",
74
+ "cache_read_tokens",
75
+ "cache_creation_tokens",
76
+ "reasoning_tokens",
77
+ "total_tokens",
78
+ "cost_usd",
79
+ "status",
80
+ "error_type",
81
+ "start_time",
82
+ "end_time",
83
+ "duration_ms",
84
+ "metadata",
85
+ )
86
+
87
+
88
+ @runtime_checkable
89
+ class ClickHouseClient(Protocol):
90
+ """The slice of the ``clickhouse-connect`` client this exporter uses."""
91
+
92
+ def insert(
93
+ self,
94
+ table: str,
95
+ data: Sequence[Sequence[object]],
96
+ *,
97
+ column_names: Sequence[str],
98
+ settings: Mapping[str, object],
99
+ ) -> object: ...
100
+
101
+ def command(self, statement: str) -> object: ...
102
+
103
+ def close(self) -> None: ...
104
+
105
+
106
+ def _env(key: str) -> str | None:
107
+ return os.environ.get(f"{_ENV_PREFIX}{key}")
108
+
109
+
110
+ def _env_bool(key: str, default: bool) -> bool:
111
+ raw = _env(key)
112
+ if raw is None:
113
+ return default
114
+ return raw.strip().lower() in ("1", "true", "yes", "on")
115
+
116
+
117
+ def _env_int(key: str, default: int) -> int:
118
+ raw = _env(key)
119
+ return int(raw) if raw is not None else default
120
+
121
+
122
+ def _op(record: Record) -> str:
123
+ kind = record.kind
124
+ if kind is Kind.AGENT:
125
+ return _OP_INVOKE_AGENT
126
+ if kind is Kind.WORKFLOW:
127
+ return _OP_INVOKE_WORKFLOW
128
+ if kind is Kind.LLM:
129
+ return _OP_CHAT
130
+ if kind is Kind.TOOL:
131
+ return _OP_EXECUTE_TOOL
132
+ if kind is Kind.MCP and record.mcp is not None and record.mcp.method == _MCP_TOOLS_CALL:
133
+ return _OP_EXECUTE_TOOL
134
+ return "" # STEP, or a non-tools/call MCP method: no GenAI operation
135
+
136
+
137
+ def _error_type(record: Record) -> str | None:
138
+ if record.error is not None:
139
+ return record.error.error_type
140
+ if record.status not in _OK_STATUSES:
141
+ return record.status.value
142
+ return None
143
+
144
+
145
+ def _chunks(rows: list[list[object]], size: int) -> list[list[list[object]]]:
146
+ return [rows[i : i + size] for i in range(0, len(rows), size)]
147
+
148
+
149
+ class ClickHouseExporter:
150
+ """Columnar batch insert of immutable Records into a denormalized MergeTree table."""
151
+
152
+ def __init__(
153
+ self,
154
+ *,
155
+ dsn: str | None = None,
156
+ table: str | None = None,
157
+ batch_size: int | None = None,
158
+ async_insert: bool | None = None,
159
+ wait_for_async_insert: bool | None = None,
160
+ create_table: bool | None = None,
161
+ max_export_batch_size: int = DEFAULT_MAX_EXPORT_BATCH_SIZE,
162
+ client: ClickHouseClient | None = None,
163
+ ) -> None:
164
+ self._dsn = dsn if dsn is not None else _env("DSN")
165
+ self._client = client
166
+ if self._client is None and not self._dsn:
167
+ raise ValueError(
168
+ "ClickHouseExporter requires a dsn (or FORGESIGHT_CLICKHOUSE_DSN), "
169
+ "e.g. clickhouse://user:pass@host:8443/db"
170
+ )
171
+
172
+ self._table = table if table is not None else (_env("TABLE") or DEFAULT_TABLE)
173
+ if not _IDENT.match(self._table):
174
+ raise ValueError(f"invalid ClickHouse table identifier {self._table!r}")
175
+
176
+ size = batch_size if batch_size is not None else _env_int("BATCH_SIZE", DEFAULT_BATCH_SIZE)
177
+ if size < 1:
178
+ raise ValueError(f"batch_size must be >= 1, got {size}")
179
+ if size > max_export_batch_size:
180
+ _log.warning(
181
+ "batch_size %d exceeds pipeline max_export_batch_size %d; clamping",
182
+ size,
183
+ max_export_batch_size,
184
+ )
185
+ size = max_export_batch_size
186
+ self._batch_size = size
187
+
188
+ self._async_insert = (
189
+ async_insert if async_insert is not None else _env_bool("ASYNC_INSERT", True)
190
+ )
191
+ self._wait = (
192
+ wait_for_async_insert
193
+ if wait_for_async_insert is not None
194
+ else _env_bool("WAIT_ASYNC", False)
195
+ )
196
+ self._create_table = (
197
+ create_table if create_table is not None else _env_bool("CREATE_TABLE", False)
198
+ )
199
+ self._table_ready = False
200
+
201
+ # --- TelemetryExporter Protocol --------------------------------------
202
+ def export(self, records: Sequence[Record]) -> ExportResult:
203
+ if not records:
204
+ return ExportResult.SUCCESS
205
+ try:
206
+ client = self._get_client()
207
+ self._ensure_table(client)
208
+ rows = [self._to_row(record) for record in records]
209
+ settings: dict[str, object] = {
210
+ "async_insert": 1 if self._async_insert else 0,
211
+ "wait_for_async_insert": 1 if self._wait else 0,
212
+ }
213
+ for chunk in _chunks(rows, self._batch_size):
214
+ client.insert(self._table, chunk, column_names=COLUMNS, settings=settings)
215
+ except Exception: # export must never raise (P6) — a CH outage is counted, not raised
216
+ _log.warning("clickhouse export failed", exc_info=True)
217
+ return ExportResult.FAILURE
218
+ return ExportResult.SUCCESS
219
+
220
+ def force_flush(self, timeout_millis: int = 30_000) -> bool:
221
+ # No client-side buffer: rows are handed to the server per export(); with
222
+ # async_insert the server owns the buffer. Nothing to flush, so this can't fail.
223
+ return True
224
+
225
+ def shutdown(self, timeout_millis: int = 30_000) -> None:
226
+ client = self._client
227
+ if client is None:
228
+ return
229
+ try:
230
+ client.close()
231
+ except Exception: # pragma: no cover - best-effort close, must never raise
232
+ _log.warning("clickhouse client close failed", exc_info=True)
233
+
234
+ # --- internals --------------------------------------------------------
235
+ def _get_client(self) -> ClickHouseClient:
236
+ if self._client is None:
237
+ import clickhouse_connect # type: ignore[import-untyped] # pragma: no cover
238
+
239
+ self._client = cast( # pragma: no cover
240
+ ClickHouseClient, clickhouse_connect.get_client(dsn=self._dsn)
241
+ )
242
+ return self._client
243
+
244
+ def _ensure_table(self, client: ClickHouseClient) -> None:
245
+ if self._table_ready:
246
+ return
247
+ if self._create_table:
248
+ client.command(_load_ddl(self._table))
249
+ self._table_ready = True
250
+
251
+ def _to_row(self, record: Record) -> list[object]:
252
+ attrs = record.attributes
253
+ llm = record.llm
254
+ tool = record.tool
255
+ mcp = record.mcp
256
+ usage = llm.usage if llm is not None else None
257
+ metadata = {key: value for key, value in attrs.items() if key not in _STRUCTURED_KEYS}
258
+ return [
259
+ record.run_id,
260
+ record.trace_id,
261
+ _opt_str(attrs.get(_PARENT_RUN_ID_KEY)),
262
+ _opt_str(attrs.get(_CONTEXT_ID_KEY)),
263
+ record.kind.value,
264
+ _op(record),
265
+ record.name if record.kind is Kind.AGENT else "",
266
+ _opt_str(attrs.get(_AGENT_VERSION_KEY)),
267
+ llm.provider if llm is not None else None,
268
+ (llm.response_model or llm.request_model) if llm is not None else None,
269
+ tool.name if tool is not None else (mcp.tool if mcp is not None else None),
270
+ mcp.server if mcp is not None else None,
271
+ mcp.method if mcp is not None else None,
272
+ usage.input if usage is not None else None,
273
+ usage.output if usage is not None else None,
274
+ usage.cache_read if usage is not None else None,
275
+ usage.cache_creation if usage is not None else None,
276
+ usage.reasoning if usage is not None else None,
277
+ usage.total if usage is not None else None,
278
+ llm.cost_usd if llm is not None else None,
279
+ record.status.value,
280
+ _error_type(record),
281
+ record.start_unix_nanos,
282
+ record.end_unix_nanos,
283
+ record.duration_ms,
284
+ json.dumps(metadata, default=str, sort_keys=True),
285
+ ]
286
+
287
+
288
+ def _opt_str(value: object) -> str | None:
289
+ return None if value is None else str(value)
290
+
291
+
292
+ def _load_ddl(table: str) -> str:
293
+ """Read the shipped DDL, retargeted at ``table`` (default ``agentforge_records``)."""
294
+ sql = (files("forgesight_clickhouse") / "migrations" / "0001_init.sql").read_text(
295
+ encoding="utf-8"
296
+ )
297
+ return sql.replace(DEFAULT_TABLE, table) if table != DEFAULT_TABLE else sql
@@ -0,0 +1,37 @@
1
+ -- forgesight-clickhouse — initial schema (feat-014).
2
+ --
3
+ -- One denormalized, immutable, MergeTree table: a Record is written once and never
4
+ -- updated, so trace-level business metadata is denormalized onto every row (no join
5
+ -- table, so analytical queries never pay a join). `${TABLE}` is substituted by the
6
+ -- exporter with the configured table name (default `agentforge_records`).
7
+ CREATE TABLE IF NOT EXISTS agentforge_records (
8
+ run_id String, -- ULID
9
+ trace_id String, -- W3C trace id
10
+ parent_run_id Nullable(String),
11
+ context_id Nullable(String),
12
+ kind LowCardinality(String), -- workflow|agent|step|llm|tool|mcp
13
+ op LowCardinality(String), -- gen_ai.operation.name (chat|execute_tool|…)
14
+ agent_name LowCardinality(String),
15
+ agent_version Nullable(String),
16
+ provider LowCardinality(Nullable(String)), -- gen_ai.provider.name
17
+ model LowCardinality(Nullable(String)), -- request/response model
18
+ tool_name Nullable(String),
19
+ mcp_server Nullable(String),
20
+ mcp_method LowCardinality(Nullable(String)),
21
+ input_tokens Nullable(UInt32),
22
+ output_tokens Nullable(UInt32),
23
+ cache_read_tokens Nullable(UInt32),
24
+ cache_creation_tokens Nullable(UInt32),
25
+ reasoning_tokens Nullable(UInt32),
26
+ total_tokens Nullable(UInt32),
27
+ cost_usd Nullable(Float64), -- forgesight.usage.cost_usd
28
+ status LowCardinality(String), -- running|ok|error|cancelled|budget_exceeded|guardrail
29
+ error_type Nullable(String),
30
+ start_time DateTime64(9), -- start_unix_nanos
31
+ end_time Nullable(DateTime64(9)),
32
+ duration_ms Nullable(Float64),
33
+ metadata JSON -- denormalized business metadata (FR-5)
34
+ )
35
+ ENGINE = MergeTree
36
+ PARTITION BY toYYYYMM(start_time)
37
+ ORDER BY (trace_id, start_time, run_id);
@@ -0,0 +1,73 @@
1
+ """A client double for testing the ClickHouse exporter without a live server.
2
+
3
+ :class:`InMemoryClickHouseClient` satisfies the ``ClickHouseClient`` protocol and records
4
+ every INSERT / command so a test (or a consuming team's pipeline test) can assert the rows,
5
+ the column order, the per-insert settings, and that a batch became **one** columnar INSERT —
6
+ never row-at-a-time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping, Sequence
12
+ from dataclasses import dataclass
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class InsertCall:
17
+ """One captured ``insert()`` — a whole pipeline batch as a single columnar INSERT."""
18
+
19
+ table: str
20
+ rows: list[list[object]]
21
+ column_names: list[str]
22
+ settings: dict[str, object]
23
+
24
+
25
+ class InMemoryClickHouseClient:
26
+ """In-memory stand-in for a ``clickhouse-connect`` client. For tests/local inspection."""
27
+
28
+ def __init__(self) -> None:
29
+ self.inserts: list[InsertCall] = []
30
+ self.commands: list[str] = []
31
+ self.closed = False
32
+
33
+ def insert(
34
+ self,
35
+ table: str,
36
+ data: Sequence[Sequence[object]],
37
+ *,
38
+ column_names: Sequence[str],
39
+ settings: Mapping[str, object],
40
+ ) -> object:
41
+ self.inserts.append(
42
+ InsertCall(
43
+ table=table,
44
+ rows=[list(row) for row in data],
45
+ column_names=list(column_names),
46
+ settings=dict(settings),
47
+ )
48
+ )
49
+ return None
50
+
51
+ def command(self, statement: str) -> object:
52
+ self.commands.append(statement)
53
+ return None
54
+
55
+ def close(self) -> None:
56
+ self.closed = True
57
+
58
+ # --- convenience accessors -------------------------------------------
59
+ @property
60
+ def rows(self) -> list[list[object]]:
61
+ """Every inserted row across all INSERTs, in arrival order."""
62
+ return [row for call in self.inserts for row in call.rows]
63
+
64
+ def rows_as_dicts(self) -> list[dict[str, object]]:
65
+ """Rows zipped with their column names — for readable assertions."""
66
+ out: list[dict[str, object]] = []
67
+ for call in self.inserts:
68
+ for row in call.rows:
69
+ out.append(dict(zip(call.column_names, row, strict=True)))
70
+ return out
71
+
72
+
73
+ __all__ = ["InMemoryClickHouseClient", "InsertCall"]
@@ -0,0 +1,376 @@
1
+ """Tests for the ClickHouse exporter: mapping, denormalization, batching, conformance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+
9
+ from forgesight_api import (
10
+ ErrorInfo,
11
+ ExportResult,
12
+ Kind,
13
+ LLMCall,
14
+ MCPCall,
15
+ Record,
16
+ RunStatus,
17
+ TokenUsage,
18
+ ToolCall,
19
+ )
20
+ from forgesight_clickhouse import ClickHouseExporter, InMemoryClickHouseClient
21
+ from forgesight_clickhouse.exporter import COLUMNS, _load_ddl
22
+ from forgesight_core import configure, reset_runtime, telemetry
23
+ from forgesight_core.testing.conformance import run_exporter_conformance
24
+
25
+ TRACE = "4bf92f3577b34da6a3ce929d0e0e4736"
26
+ DSN = "clickhouse://user:pass@host:8443/agents"
27
+
28
+
29
+ def _exporter(**kw: object) -> tuple[ClickHouseExporter, InMemoryClickHouseClient]:
30
+ client = InMemoryClickHouseClient()
31
+ return ClickHouseExporter(client=client, **kw), client
32
+
33
+
34
+ def _llm_record(span: str = "00f067aa0ba902b7") -> Record:
35
+ return Record(
36
+ kind=Kind.LLM,
37
+ run_id="01J9Z3K7P8QF2R5V6W7X8Y9Z0A",
38
+ trace_id=TRACE,
39
+ span_id=span,
40
+ parent_span_id="aaaaaaaaaaaaaaaa",
41
+ name="claude-sonnet-4-5",
42
+ status=RunStatus.OK,
43
+ start_unix_nanos=1_000_000_000,
44
+ end_unix_nanos=3_000_000_000,
45
+ llm=LLMCall(
46
+ provider="anthropic",
47
+ request_model="claude-sonnet-4-5",
48
+ response_model="claude-sonnet-4-5-20990101",
49
+ usage=TokenUsage(input=100, output=50, cache_read=10, reasoning=5),
50
+ cost_usd=0.01,
51
+ ),
52
+ )
53
+
54
+
55
+ # --- construction / validation ------------------------------------------------
56
+ def test_requires_dsn_or_client() -> None:
57
+ with pytest.raises(ValueError, match="requires a dsn"):
58
+ ClickHouseExporter()
59
+
60
+
61
+ def test_invalid_table_rejected() -> None:
62
+ with pytest.raises(ValueError, match="invalid ClickHouse table"):
63
+ ClickHouseExporter(dsn=DSN, table="bad table; DROP")
64
+
65
+
66
+ def test_batch_size_must_be_positive() -> None:
67
+ with pytest.raises(ValueError, match="batch_size must be"):
68
+ ClickHouseExporter(dsn=DSN, batch_size=0)
69
+
70
+
71
+ def test_batch_size_clamped_to_pipeline_max(caplog: pytest.LogCaptureFixture) -> None:
72
+ exporter, _ = _exporter(batch_size=5000, max_export_batch_size=512)
73
+ assert exporter._batch_size == 512
74
+ assert any("clamping" in r.message for r in caplog.records)
75
+
76
+
77
+ # --- conformance --------------------------------------------------------------
78
+ def test_conformance() -> None:
79
+ run_exporter_conformance(lambda: ClickHouseExporter(client=InMemoryClickHouseClient()))
80
+
81
+
82
+ # --- record → column mapping --------------------------------------------------
83
+ def test_llm_record_maps_to_columns() -> None:
84
+ exporter, client = _exporter()
85
+ assert exporter.export([_llm_record()]) is ExportResult.SUCCESS
86
+ [row] = client.rows_as_dicts()
87
+ assert row["kind"] == "llm"
88
+ assert row["op"] == "chat"
89
+ assert row["provider"] == "anthropic"
90
+ assert row["model"] == "claude-sonnet-4-5-20990101" # response model wins
91
+ assert row["input_tokens"] == 100
92
+ assert row["output_tokens"] == 50
93
+ assert row["cache_read_tokens"] == 10
94
+ assert row["reasoning_tokens"] == 5
95
+ assert row["total_tokens"] == 165
96
+ assert row["cost_usd"] == 0.01 # forgesight.usage.cost_usd
97
+ assert row["status"] == "ok"
98
+ assert row["start_time"] == 1_000_000_000
99
+ assert row["end_time"] == 3_000_000_000
100
+ assert row["duration_ms"] == 2000.0
101
+
102
+
103
+ def test_tool_and_mcp_and_step_mapping() -> None:
104
+ exporter, client = _exporter()
105
+ tool = Record(
106
+ kind=Kind.TOOL,
107
+ run_id="r",
108
+ trace_id=TRACE,
109
+ span_id="1111111111111111",
110
+ parent_span_id=None,
111
+ name="search",
112
+ status=RunStatus.OK,
113
+ start_unix_nanos=1,
114
+ end_unix_nanos=2,
115
+ tool=ToolCall(name="search"),
116
+ )
117
+ mcp = Record(
118
+ kind=Kind.MCP,
119
+ run_id="r",
120
+ trace_id=TRACE,
121
+ span_id="2222222222222222",
122
+ parent_span_id=None,
123
+ name="tools/call",
124
+ status=RunStatus.OK,
125
+ start_unix_nanos=1,
126
+ end_unix_nanos=2,
127
+ mcp=MCPCall(server="files", method="tools/call", tool="read_file"),
128
+ )
129
+ step = Record(
130
+ kind=Kind.STEP,
131
+ run_id="r",
132
+ trace_id=TRACE,
133
+ span_id="3333333333333333",
134
+ parent_span_id=None,
135
+ name="react-1",
136
+ status=RunStatus.OK,
137
+ start_unix_nanos=1,
138
+ end_unix_nanos=2,
139
+ )
140
+ exporter.export([tool, mcp, step])
141
+ by_kind = {row["kind"]: row for row in client.rows_as_dicts()}
142
+ assert by_kind["tool"]["tool_name"] == "search"
143
+ assert by_kind["tool"]["op"] == "execute_tool"
144
+ assert by_kind["mcp"]["mcp_server"] == "files"
145
+ assert by_kind["mcp"]["mcp_method"] == "tools/call"
146
+ assert by_kind["mcp"]["tool_name"] == "read_file" # tools/call lifts the tool name
147
+ assert by_kind["mcp"]["op"] == "execute_tool"
148
+ assert by_kind["step"]["op"] == "" # a step has no GenAI operation
149
+ assert by_kind["step"]["tool_name"] is None
150
+
151
+
152
+ def test_error_record_carries_error_type() -> None:
153
+ exporter, client = _exporter()
154
+ rec = Record(
155
+ kind=Kind.AGENT,
156
+ run_id="r",
157
+ trace_id=TRACE,
158
+ span_id="4444444444444444",
159
+ parent_span_id=None,
160
+ name="classifier",
161
+ status=RunStatus.ERROR,
162
+ start_unix_nanos=1,
163
+ end_unix_nanos=2,
164
+ )
165
+ exporter.export([rec])
166
+ [row] = client.rows_as_dicts()
167
+ assert row["status"] == "error"
168
+ assert row["error_type"] == "error" # status fallback when no ErrorInfo
169
+
170
+
171
+ def test_error_info_error_type_wins_over_status() -> None:
172
+ exporter, client = _exporter()
173
+ rec = Record(
174
+ kind=Kind.TOOL,
175
+ run_id="r",
176
+ trace_id=TRACE,
177
+ span_id="5555555555555555",
178
+ parent_span_id=None,
179
+ name="search",
180
+ status=RunStatus.ERROR,
181
+ start_unix_nanos=1,
182
+ end_unix_nanos=2,
183
+ tool=ToolCall(name="search", status=RunStatus.ERROR),
184
+ error=ErrorInfo(error_type="TimeoutError", message="boom"),
185
+ )
186
+ exporter.export([rec])
187
+ [row] = client.rows_as_dicts()
188
+ assert row["error_type"] == "TimeoutError" # ErrorInfo wins over the status fallback
189
+
190
+
191
+ def test_workflow_record_maps_op() -> None:
192
+ exporter, client = _exporter()
193
+ rec = Record(
194
+ kind=Kind.WORKFLOW,
195
+ run_id="r",
196
+ trace_id=TRACE,
197
+ span_id="6666666666666666",
198
+ parent_span_id=None,
199
+ name="nightly",
200
+ status=RunStatus.OK,
201
+ start_unix_nanos=1,
202
+ end_unix_nanos=2,
203
+ )
204
+ exporter.export([rec])
205
+ [row] = client.rows_as_dicts()
206
+ assert row["op"] == "invoke_workflow"
207
+ assert row["agent_name"] == "" # only AGENT records carry an agent name
208
+ assert client.rows[0][0] == "r" # raw-row accessor: run_id is the first column
209
+
210
+
211
+ # --- denormalization (the headline) ------------------------------------------
212
+ def test_business_metadata_denormalized_onto_child_rows() -> None:
213
+ exporter, client = _exporter()
214
+ configure(exporters=[exporter], sync_export=True)
215
+ try:
216
+ with telemetry.agent_run("classifier", version="1.2.0") as run:
217
+ run.set_metadata(team="payments")
218
+ with run.llm_call("anthropic", "claude-sonnet-4-5") as call:
219
+ call.record_usage(input=1000, output=500)
220
+ finally:
221
+ reset_runtime()
222
+
223
+ rows = client.rows_as_dicts()
224
+ assert rows, "expected exported rows"
225
+ # every row — run AND the child llm call — carries the denormalized team
226
+ for row in rows:
227
+ meta = json.loads(str(row["metadata"]))
228
+ assert meta["team"] == "payments"
229
+ agent_row = next(r for r in rows if r["kind"] == "agent")
230
+ assert agent_row["agent_name"] == "classifier"
231
+ assert agent_row["agent_version"] == "1.2.0"
232
+ # the structured fields are lifted to columns, not duplicated into metadata JSON
233
+ assert "agent.version" not in json.loads(str(agent_row["metadata"]))
234
+
235
+
236
+ def test_parent_run_id_and_context_id_lift_to_columns() -> None:
237
+ exporter, client = _exporter()
238
+ configure(exporters=[exporter], sync_export=True)
239
+ try:
240
+ with (
241
+ telemetry.agent_run("outer"),
242
+ telemetry.agent_run("inner", context_id="conv-7"),
243
+ ):
244
+ pass
245
+ finally:
246
+ reset_runtime()
247
+ inner = next(r for r in client.rows_as_dicts() if r["agent_name"] == "inner")
248
+ assert inner["context_id"] == "conv-7"
249
+ assert inner["parent_run_id"] is not None
250
+
251
+
252
+ # --- batching -----------------------------------------------------------------
253
+ def test_one_insert_per_batch_not_row_at_a_time() -> None:
254
+ exporter, client = _exporter()
255
+ exporter.export([_llm_record(span=f"{i:016x}") for i in range(50)])
256
+ assert len(client.inserts) == 1 # one columnar INSERT for the whole batch
257
+ assert len(client.inserts[0].rows) == 50
258
+ assert client.inserts[0].column_names == list(COLUMNS)
259
+
260
+
261
+ def test_oversized_batch_chunks_by_batch_size() -> None:
262
+ exporter, client = _exporter(batch_size=10, max_export_batch_size=512)
263
+ exporter.export([_llm_record(span=f"{i:016x}") for i in range(25)])
264
+ assert [len(c.rows) for c in client.inserts] == [10, 10, 5]
265
+
266
+
267
+ def test_async_insert_settings_passed() -> None:
268
+ exporter, client = _exporter(async_insert=True, wait_for_async_insert=False)
269
+ exporter.export([_llm_record()])
270
+ settings = client.inserts[0].settings
271
+ assert settings["async_insert"] == 1
272
+ assert settings["wait_for_async_insert"] == 0
273
+
274
+
275
+ def test_empty_batch_is_a_noop() -> None:
276
+ exporter, client = _exporter()
277
+ assert exporter.export([]) is ExportResult.SUCCESS
278
+ assert client.inserts == []
279
+
280
+
281
+ # --- create_table / migrations ------------------------------------------------
282
+ def test_create_table_runs_ddl_once() -> None:
283
+ exporter, client = _exporter(create_table=True)
284
+ exporter.export([_llm_record()])
285
+ exporter.export([_llm_record()])
286
+ assert len(client.commands) == 1 # DDL emitted once, on first export
287
+ assert "CREATE TABLE IF NOT EXISTS agentforge_records" in client.commands[0]
288
+
289
+
290
+ def test_create_table_false_emits_no_ddl() -> None:
291
+ exporter, client = _exporter(create_table=False)
292
+ exporter.export([_llm_record()])
293
+ assert client.commands == []
294
+
295
+
296
+ def test_load_ddl_retargets_table_name() -> None:
297
+ sql = _load_ddl("custom_records")
298
+ assert "CREATE TABLE IF NOT EXISTS custom_records" in sql
299
+ assert "agentforge_records" not in sql
300
+ # column contract intact after retarget
301
+ assert "cost_usd" in sql
302
+ assert "ORDER BY (trace_id, start_time, run_id)" in sql
303
+
304
+
305
+ def test_custom_table_used_in_insert_and_ddl() -> None:
306
+ exporter, client = _exporter(table="my_db.records", create_table=True)
307
+ exporter.export([_llm_record()])
308
+ assert client.inserts[0].table == "my_db.records"
309
+ assert "my_db.records" in client.commands[0]
310
+
311
+
312
+ # --- fault isolation (P6) -----------------------------------------------------
313
+ class _FailingClient:
314
+ def __init__(self) -> None:
315
+ self.closed = False
316
+
317
+ def insert(self, *args: object, **kwargs: object) -> object:
318
+ raise ConnectionError("clickhouse unreachable")
319
+
320
+ def command(self, statement: str) -> object:
321
+ raise ConnectionError("clickhouse unreachable")
322
+
323
+ def close(self) -> None:
324
+ self.closed = True
325
+
326
+
327
+ def test_clickhouse_outage_is_isolated() -> None:
328
+ exporter = ClickHouseExporter(client=_FailingClient())
329
+ assert exporter.export([_llm_record()]) is ExportResult.FAILURE # counted, never raised
330
+
331
+
332
+ # --- lifecycle ----------------------------------------------------------------
333
+ def test_shutdown_closes_client_and_is_idempotent() -> None:
334
+ exporter, client = _exporter()
335
+ exporter.shutdown()
336
+ assert client.closed is True
337
+ exporter.shutdown() # idempotent, must not raise
338
+
339
+
340
+ def test_force_flush_returns_true() -> None:
341
+ exporter, _ = _exporter()
342
+ assert exporter.force_flush() is True
343
+
344
+
345
+ def test_shutdown_without_a_built_client_is_a_noop() -> None:
346
+ exporter = ClickHouseExporter(dsn=DSN) # never exported ⇒ no client built
347
+ exporter.shutdown() # must not raise
348
+
349
+
350
+ # --- config: env resolution ---------------------------------------------------
351
+ def test_env_resolves_dsn_table_and_flags(monkeypatch: pytest.MonkeyPatch) -> None:
352
+ monkeypatch.setenv("FORGESIGHT_CLICKHOUSE_DSN", DSN)
353
+ monkeypatch.setenv("FORGESIGHT_CLICKHOUSE_TABLE", "env_records")
354
+ monkeypatch.setenv("FORGESIGHT_CLICKHOUSE_BATCH_SIZE", "64")
355
+ monkeypatch.setenv("FORGESIGHT_CLICKHOUSE_ASYNC_INSERT", "false")
356
+ monkeypatch.setenv("FORGESIGHT_CLICKHOUSE_CREATE_TABLE", "true")
357
+ exporter = ClickHouseExporter()
358
+ assert exporter._dsn == DSN
359
+ assert exporter._table == "env_records"
360
+ assert exporter._batch_size == 64
361
+ assert exporter._async_insert is False
362
+ assert exporter._create_table is True
363
+
364
+
365
+ def test_kwargs_win_over_env(monkeypatch: pytest.MonkeyPatch) -> None:
366
+ monkeypatch.setenv("FORGESIGHT_CLICKHOUSE_TABLE", "env_records")
367
+ exporter, _ = _exporter(table="explicit_records")
368
+ assert exporter._table == "explicit_records"
369
+
370
+
371
+ # --- resolves by entry-point name ---------------------------------------------
372
+ def test_resolves_by_name() -> None:
373
+ from forgesight_core.config import resolve
374
+
375
+ exporter = resolve("exporters", "clickhouse", {"dsn": DSN})
376
+ assert isinstance(exporter, ClickHouseExporter)