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.
- forgesight_clickhouse-0.1.0/.gitignore +38 -0
- forgesight_clickhouse-0.1.0/PKG-INFO +96 -0
- forgesight_clickhouse-0.1.0/README.md +70 -0
- forgesight_clickhouse-0.1.0/pyproject.toml +41 -0
- forgesight_clickhouse-0.1.0/src/forgesight_clickhouse/__init__.py +17 -0
- forgesight_clickhouse-0.1.0/src/forgesight_clickhouse/exporter.py +297 -0
- forgesight_clickhouse-0.1.0/src/forgesight_clickhouse/migrations/0001_init.sql +37 -0
- forgesight_clickhouse-0.1.0/src/forgesight_clickhouse/py.typed +0 -0
- forgesight_clickhouse-0.1.0/src/forgesight_clickhouse/testing.py +73 -0
- forgesight_clickhouse-0.1.0/tests/test_exporter.py +376 -0
|
@@ -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);
|
|
File without changes
|
|
@@ -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)
|