fuju-trace-sql 0.1.10__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.
- fuju_trace_sql-0.1.10/.gitignore +41 -0
- fuju_trace_sql-0.1.10/PKG-INFO +127 -0
- fuju_trace_sql-0.1.10/README.md +110 -0
- fuju_trace_sql-0.1.10/fuju_trace_sql/__init__.py +475 -0
- fuju_trace_sql-0.1.10/pyproject.toml +25 -0
- fuju_trace_sql-0.1.10/tests/live_smoke.py +133 -0
- fuju_trace_sql-0.1.10/tests/run_local_postgresql.sh +31 -0
- fuju_trace_sql-0.1.10/tests/test_sql.py +190 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Rust 构建产物(巨大,绝不入库)
|
|
2
|
+
target/
|
|
3
|
+
**/target/
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*.egg-info/
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
|
|
13
|
+
# Node / TypeScript
|
|
14
|
+
node_modules/
|
|
15
|
+
dist/
|
|
16
|
+
*.tsbuildinfo
|
|
17
|
+
fuju-trace-console/.test-dist/
|
|
18
|
+
fuju-trace-node/artifacts/
|
|
19
|
+
|
|
20
|
+
# C / 扩展构建产物(tracevault-extension)
|
|
21
|
+
*.o
|
|
22
|
+
*.so
|
|
23
|
+
*.a
|
|
24
|
+
*.node
|
|
25
|
+
|
|
26
|
+
# 运行时/测试落盘产物(WAL / 段 / manifest / 向量文件,正常写在 temp_dir,这里兜底)
|
|
27
|
+
*.wal
|
|
28
|
+
*.vortex
|
|
29
|
+
manifest.dat
|
|
30
|
+
vectors.dat
|
|
31
|
+
|
|
32
|
+
# 编辑器 / 操作系统
|
|
33
|
+
.DS_Store
|
|
34
|
+
*.swp
|
|
35
|
+
*~
|
|
36
|
+
.idea/
|
|
37
|
+
.vscode/
|
|
38
|
+
.gstack/
|
|
39
|
+
|
|
40
|
+
# 控制台前端构建产物(vite build 后拷入,编译期内嵌)
|
|
41
|
+
fuju-trace-engine/crates/fuju-trace-engine/console_dist/
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fuju-trace-sql
|
|
3
|
+
Version: 0.1.10
|
|
4
|
+
Summary: SQLite, DuckDB and PostgreSQL storage adapters for Fuju Trace
|
|
5
|
+
Project-URL: Homepage, https://github.com/vibeinging/fuju-trace
|
|
6
|
+
Project-URL: Repository, https://github.com/vibeinging/fuju-trace
|
|
7
|
+
Project-URL: Issues, https://github.com/vibeinging/fuju-trace/issues
|
|
8
|
+
Author: Fuju Trace
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: fuju-trace==0.1.10
|
|
12
|
+
Provides-Extra: duckdb
|
|
13
|
+
Requires-Dist: duckdb<3,>=1.0; extra == 'duckdb'
|
|
14
|
+
Provides-Extra: postgresql
|
|
15
|
+
Requires-Dist: psycopg2-binary<3,>=2.9.5; extra == 'postgresql'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Fuju Trace SQL adapters
|
|
19
|
+
|
|
20
|
+
This package adds three direct database adapters to the Fuju Trace Python
|
|
21
|
+
SDK: SQLite, DuckDB, and PostgreSQL. They have the same event ingestion
|
|
22
|
+
and basic read API as the VexDB adapter, but their current text search is a
|
|
23
|
+
portable **substring scan**. They do not provide BM25, ANN, vector writes, or
|
|
24
|
+
hybrid search. Use VexDB or the local TraceDB when those search features matter.
|
|
25
|
+
|
|
26
|
+
Install an adapter through a Fuju Trace extra:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python -m pip install 'fuju-trace[sqlite]==0.1.10'
|
|
30
|
+
python -m pip install 'fuju-trace[duckdb]==0.1.10'
|
|
31
|
+
python -m pip install 'fuju-trace[postgresql]==0.1.10'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Install from this checkout
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
python -m pip install -e ./fuju-trace-sdk/python -e ./fuju-trace-sql
|
|
38
|
+
# Add one or more optional drivers when needed:
|
|
39
|
+
python -m pip install 'duckdb>=1.0,<3' 'psycopg2-binary>=2.9.5,<3'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
SQLite uses Python's standard library. The `[duckdb]` and `[postgresql]`
|
|
43
|
+
extras install their respective database drivers.
|
|
44
|
+
|
|
45
|
+
## Connect
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from fuju_trace import DbExporter, Tracer, connect
|
|
49
|
+
|
|
50
|
+
with connect(sqlite_path="./trace.sqlite", tenant_id=1, initialize=True) as db:
|
|
51
|
+
tracer = Tracer(exporter=DbExporter(db, tenant_id=1), node_id=1)
|
|
52
|
+
with tracer.trace("request", session_id=42, tenant_id=1) as trace:
|
|
53
|
+
with trace.span("tool call") as span:
|
|
54
|
+
span.log("query failed")
|
|
55
|
+
tracer.close()
|
|
56
|
+
print(db.search(text="failed", k=10))
|
|
57
|
+
print(db.list_spans(filters={"externalSessionId": 42}))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Other connection forms:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
connect(duckdb_path="./trace.duckdb", tenant_id=1, initialize=True)
|
|
64
|
+
connect(postgresql_dsn="dbname=app user=trace", tenant_id=1, initialize=True)
|
|
65
|
+
connect(postgresql_params={"host": "localhost", "dbname": "app",
|
|
66
|
+
"user": "trace"}, tenant_id=1, initialize=True)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Supply credentials from your secret manager or environment, never from source
|
|
70
|
+
control. Set `initialize=True` only for first setup; it creates private
|
|
71
|
+
`fuju_trace_*` tables and indexes. Use `table_prefix` to isolate installations.
|
|
72
|
+
Opening a database without the schema requires initialization first.
|
|
73
|
+
|
|
74
|
+
## API and limits
|
|
75
|
+
|
|
76
|
+
- `ingest(events)` is transactional and deduplicates
|
|
77
|
+
`(ext_span_id, seq, event_type)` within the bound tenant. The source event
|
|
78
|
+
and folded span are committed together.
|
|
79
|
+
- `trace(id)`, `span(trace_id, span_id)`, `list_spans(filters=...)`, and
|
|
80
|
+
`list_trace_ids(filters=...)` read the folded model.
|
|
81
|
+
- `search(text=..., k=..., filter=...)` searches log and input/output text.
|
|
82
|
+
Supported filters include trace ID, session ID, time, agent, status, and
|
|
83
|
+
exact attributes. `%` and `_` in a query are treated literally.
|
|
84
|
+
- `capabilities()` reports `text="substring_scan"` and `vector=None`.
|
|
85
|
+
Passing a vector raises `NotImplementedError`.
|
|
86
|
+
- Each store binds one tenant and serializes one connection across threads.
|
|
87
|
+
Give each process its own connection and each writer process a distinct
|
|
88
|
+
`node_id` (0–1023).
|
|
89
|
+
- SQLite enables WAL for file databases; concurrent writes still serialize.
|
|
90
|
+
DuckDB is best used by one writer process for a database file. PostgreSQL
|
|
91
|
+
uses row locks when folding spans.
|
|
92
|
+
- The substring search has no text index and can scan many spans. It is a
|
|
93
|
+
correctness-first baseline, not a large-scale search benchmark result.
|
|
94
|
+
|
|
95
|
+
For DuckDB's file concurrency behavior and FTS index refresh requirement, see
|
|
96
|
+
its [concurrency](https://duckdb.org/docs/stable/connect/concurrency.html) and
|
|
97
|
+
[full-text search](https://duckdb.org/docs/current/core_extensions/full_text_search)
|
|
98
|
+
documentation.
|
|
99
|
+
|
|
100
|
+
## Verification
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
python -m unittest discover -s fuju-trace-sql/tests -p 'test_*.py'
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
SQLite and DuckDB tests use real database files. They cover writes, retries,
|
|
107
|
+
filters, tenant isolation, and reopen. The PostgreSQL test below uses a real
|
|
108
|
+
server and covers the same path plus two connections writing one span at the
|
|
109
|
+
same time. No MySQL server is needed or supported.
|
|
110
|
+
|
|
111
|
+
To start a temporary local PostgreSQL instance, run the following from the
|
|
112
|
+
repository root. It listens only on a private Unix socket and deletes its data
|
|
113
|
+
directory after the test. `initdb`, `pg_ctl`, `createdb`, and a Python with
|
|
114
|
+
`psycopg2` must be installed:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
FUJU_SQL_PYTHON=python3 ./fuju-trace-sql/tests/run_local_postgresql.sh
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
For an existing **disposable test database** with table create/drop permission,
|
|
121
|
+
set `POSTGRESQL_DSN` and run `python fuju-trace-sql/tests/live_smoke.py`.
|
|
122
|
+
The test creates and removes only four tables under its own random
|
|
123
|
+
`fuju_smoke_*` prefix. CI runs it against a PostgreSQL 16 service container.
|
|
124
|
+
These checks verify storage behavior; they do not benchmark search performance
|
|
125
|
+
or exercise VexDB-Lite vector extensions.
|
|
126
|
+
The [real database test report](../docs/reports/2026-09-25_sql-adapter-real-tests.md)
|
|
127
|
+
records the versions and results from the local run.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Fuju Trace SQL adapters
|
|
2
|
+
|
|
3
|
+
This package adds three direct database adapters to the Fuju Trace Python
|
|
4
|
+
SDK: SQLite, DuckDB, and PostgreSQL. They have the same event ingestion
|
|
5
|
+
and basic read API as the VexDB adapter, but their current text search is a
|
|
6
|
+
portable **substring scan**. They do not provide BM25, ANN, vector writes, or
|
|
7
|
+
hybrid search. Use VexDB or the local TraceDB when those search features matter.
|
|
8
|
+
|
|
9
|
+
Install an adapter through a Fuju Trace extra:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
python -m pip install 'fuju-trace[sqlite]==0.1.10'
|
|
13
|
+
python -m pip install 'fuju-trace[duckdb]==0.1.10'
|
|
14
|
+
python -m pip install 'fuju-trace[postgresql]==0.1.10'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Install from this checkout
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python -m pip install -e ./fuju-trace-sdk/python -e ./fuju-trace-sql
|
|
21
|
+
# Add one or more optional drivers when needed:
|
|
22
|
+
python -m pip install 'duckdb>=1.0,<3' 'psycopg2-binary>=2.9.5,<3'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
SQLite uses Python's standard library. The `[duckdb]` and `[postgresql]`
|
|
26
|
+
extras install their respective database drivers.
|
|
27
|
+
|
|
28
|
+
## Connect
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from fuju_trace import DbExporter, Tracer, connect
|
|
32
|
+
|
|
33
|
+
with connect(sqlite_path="./trace.sqlite", tenant_id=1, initialize=True) as db:
|
|
34
|
+
tracer = Tracer(exporter=DbExporter(db, tenant_id=1), node_id=1)
|
|
35
|
+
with tracer.trace("request", session_id=42, tenant_id=1) as trace:
|
|
36
|
+
with trace.span("tool call") as span:
|
|
37
|
+
span.log("query failed")
|
|
38
|
+
tracer.close()
|
|
39
|
+
print(db.search(text="failed", k=10))
|
|
40
|
+
print(db.list_spans(filters={"externalSessionId": 42}))
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Other connection forms:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
connect(duckdb_path="./trace.duckdb", tenant_id=1, initialize=True)
|
|
47
|
+
connect(postgresql_dsn="dbname=app user=trace", tenant_id=1, initialize=True)
|
|
48
|
+
connect(postgresql_params={"host": "localhost", "dbname": "app",
|
|
49
|
+
"user": "trace"}, tenant_id=1, initialize=True)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Supply credentials from your secret manager or environment, never from source
|
|
53
|
+
control. Set `initialize=True` only for first setup; it creates private
|
|
54
|
+
`fuju_trace_*` tables and indexes. Use `table_prefix` to isolate installations.
|
|
55
|
+
Opening a database without the schema requires initialization first.
|
|
56
|
+
|
|
57
|
+
## API and limits
|
|
58
|
+
|
|
59
|
+
- `ingest(events)` is transactional and deduplicates
|
|
60
|
+
`(ext_span_id, seq, event_type)` within the bound tenant. The source event
|
|
61
|
+
and folded span are committed together.
|
|
62
|
+
- `trace(id)`, `span(trace_id, span_id)`, `list_spans(filters=...)`, and
|
|
63
|
+
`list_trace_ids(filters=...)` read the folded model.
|
|
64
|
+
- `search(text=..., k=..., filter=...)` searches log and input/output text.
|
|
65
|
+
Supported filters include trace ID, session ID, time, agent, status, and
|
|
66
|
+
exact attributes. `%` and `_` in a query are treated literally.
|
|
67
|
+
- `capabilities()` reports `text="substring_scan"` and `vector=None`.
|
|
68
|
+
Passing a vector raises `NotImplementedError`.
|
|
69
|
+
- Each store binds one tenant and serializes one connection across threads.
|
|
70
|
+
Give each process its own connection and each writer process a distinct
|
|
71
|
+
`node_id` (0–1023).
|
|
72
|
+
- SQLite enables WAL for file databases; concurrent writes still serialize.
|
|
73
|
+
DuckDB is best used by one writer process for a database file. PostgreSQL
|
|
74
|
+
uses row locks when folding spans.
|
|
75
|
+
- The substring search has no text index and can scan many spans. It is a
|
|
76
|
+
correctness-first baseline, not a large-scale search benchmark result.
|
|
77
|
+
|
|
78
|
+
For DuckDB's file concurrency behavior and FTS index refresh requirement, see
|
|
79
|
+
its [concurrency](https://duckdb.org/docs/stable/connect/concurrency.html) and
|
|
80
|
+
[full-text search](https://duckdb.org/docs/current/core_extensions/full_text_search)
|
|
81
|
+
documentation.
|
|
82
|
+
|
|
83
|
+
## Verification
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
python -m unittest discover -s fuju-trace-sql/tests -p 'test_*.py'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
SQLite and DuckDB tests use real database files. They cover writes, retries,
|
|
90
|
+
filters, tenant isolation, and reopen. The PostgreSQL test below uses a real
|
|
91
|
+
server and covers the same path plus two connections writing one span at the
|
|
92
|
+
same time. No MySQL server is needed or supported.
|
|
93
|
+
|
|
94
|
+
To start a temporary local PostgreSQL instance, run the following from the
|
|
95
|
+
repository root. It listens only on a private Unix socket and deletes its data
|
|
96
|
+
directory after the test. `initdb`, `pg_ctl`, `createdb`, and a Python with
|
|
97
|
+
`psycopg2` must be installed:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
FUJU_SQL_PYTHON=python3 ./fuju-trace-sql/tests/run_local_postgresql.sh
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
For an existing **disposable test database** with table create/drop permission,
|
|
104
|
+
set `POSTGRESQL_DSN` and run `python fuju-trace-sql/tests/live_smoke.py`.
|
|
105
|
+
The test creates and removes only four tables under its own random
|
|
106
|
+
`fuju_smoke_*` prefix. CI runs it against a PostgreSQL 16 service container.
|
|
107
|
+
These checks verify storage behavior; they do not benchmark search performance
|
|
108
|
+
or exercise VexDB-Lite vector extensions.
|
|
109
|
+
The [real database test report](../docs/reports/2026-09-25_sql-adapter-real-tests.md)
|
|
110
|
+
records the versions and results from the local run.
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Direct SQL stores for Fuju Trace.
|
|
2
|
+
|
|
3
|
+
Each store owns private tables under a validated prefix. Source events and the
|
|
4
|
+
folded span projection change in one transaction. Text search is a portable
|
|
5
|
+
substring query; native FTS and vector indexes are separate future capabilities.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sqlite3
|
|
13
|
+
import threading
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable, Iterator, Mapping, Sequence
|
|
17
|
+
|
|
18
|
+
from fuju_trace.event import EventType, event_id
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"SQLiteTraceStore", "DuckDBTraceStore", "PostgreSQLTraceStore",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
_PREFIX = re.compile(r"^[a-z][a-z0-9_]{0,35}$")
|
|
25
|
+
_FIELDS = (
|
|
26
|
+
"parent_span_id", "status", "duration_ns", "input_tokens", "output_tokens",
|
|
27
|
+
"cache_read_tokens", "cache_write_tokens", "session_id", "tenant_id",
|
|
28
|
+
"external_trace_id", "external_span_id", "external_parent_span_id",
|
|
29
|
+
"external_session_id", "span_name", "display_name", "agent_name",
|
|
30
|
+
"tool_name", "model", "input_text", "output_text", "eval_score", "eval_label",
|
|
31
|
+
)
|
|
32
|
+
_ALIASES = {"projectId": "project_id", "callSite": "call_site"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _json(value: Any) -> str:
|
|
36
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True,
|
|
37
|
+
separators=(",", ":"), allow_nan=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _key(value: Any) -> str:
|
|
41
|
+
return hashlib.sha256(str(value).encode("utf-8")).hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fold(events: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
|
45
|
+
ordered = sorted(events, key=lambda e: (int(e["seq"]), int(e["event_type"])))
|
|
46
|
+
first = ordered[0]
|
|
47
|
+
span: dict[str, Any] = {
|
|
48
|
+
"trace_id": first["trace_id"], "span_id": first["span_id"],
|
|
49
|
+
"has_start": False, "has_end": False, "event_count": len(ordered),
|
|
50
|
+
"ts": min(int(e["ts"]) for e in ordered), "logs": [], "attrs": {},
|
|
51
|
+
}
|
|
52
|
+
seen_logs: set[str] = set()
|
|
53
|
+
for event in ordered:
|
|
54
|
+
kind = int(event["event_type"])
|
|
55
|
+
span["has_start"] |= kind == 1
|
|
56
|
+
span["has_end"] |= kind == 2
|
|
57
|
+
for field in _FIELDS:
|
|
58
|
+
value = event.get(field)
|
|
59
|
+
if value is not None and (field not in {"span_name", "display_name"} or kind == 1):
|
|
60
|
+
span[field] = value
|
|
61
|
+
for log in event.get("logs") or []:
|
|
62
|
+
if log not in seen_logs:
|
|
63
|
+
seen_logs.add(log)
|
|
64
|
+
span["logs"].append(log)
|
|
65
|
+
span["attrs"].update(event.get("attrs") or {})
|
|
66
|
+
return span
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _search_text(span: Mapping[str, Any]) -> str:
|
|
70
|
+
# Display fields must not silently become search terms.
|
|
71
|
+
return "\n".join(str(value) for value in (
|
|
72
|
+
span.get("input_text"), span.get("output_text"), *(span.get("logs") or []),
|
|
73
|
+
) if value) or " "
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _like(value: str) -> str:
|
|
77
|
+
return "%" + value.replace("!", "!!").replace("%", "!%").replace("_", "!_") + "%"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SQLTraceStore:
|
|
81
|
+
"""One tenant, one DB-API connection, guarded by a thread lock."""
|
|
82
|
+
|
|
83
|
+
dialect = ""
|
|
84
|
+
|
|
85
|
+
def __init__(self, connection: Any, *, tenant_id: str | int,
|
|
86
|
+
table_prefix: str = "fuju_trace") -> None:
|
|
87
|
+
if not _PREFIX.fullmatch(table_prefix):
|
|
88
|
+
raise ValueError("table_prefix must be a short lowercase SQL identifier")
|
|
89
|
+
if tenant_id is None or str(tenant_id) == "":
|
|
90
|
+
raise ValueError("tenant_id is required")
|
|
91
|
+
self._conn = connection
|
|
92
|
+
self._lock = threading.RLock()
|
|
93
|
+
self._closed = False
|
|
94
|
+
self._tenant = str(tenant_id)
|
|
95
|
+
self._tenant_key = _key(self._tenant)
|
|
96
|
+
self.prefix = table_prefix
|
|
97
|
+
self._config = f"{table_prefix}_config"
|
|
98
|
+
self._events = f"{table_prefix}_events"
|
|
99
|
+
self._spans = f"{table_prefix}_spans"
|
|
100
|
+
self._attrs = f"{table_prefix}_attrs"
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def _bind(self) -> str:
|
|
104
|
+
return "?" if self.dialect in ("sqlite", "duckdb") else "%s"
|
|
105
|
+
|
|
106
|
+
def _execute(self, cur: Any, sql: str, params: Sequence[Any] = ()) -> Any:
|
|
107
|
+
return cur.execute(sql.replace("?", self._bind), tuple(params))
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def _tx(self, *, write: bool = False) -> Iterator[Any]:
|
|
111
|
+
with self._lock:
|
|
112
|
+
if self._closed:
|
|
113
|
+
raise RuntimeError("trace store is closed")
|
|
114
|
+
# DuckDB cursor() creates another connection. An unnamed in-memory
|
|
115
|
+
# database would then appear empty to that connection.
|
|
116
|
+
cur = self._conn if self.dialect == "duckdb" else self._conn.cursor()
|
|
117
|
+
try:
|
|
118
|
+
if write and self.dialect == "sqlite":
|
|
119
|
+
cur.execute("BEGIN IMMEDIATE")
|
|
120
|
+
elif write and self.dialect == "duckdb":
|
|
121
|
+
cur.execute("BEGIN TRANSACTION")
|
|
122
|
+
yield cur
|
|
123
|
+
if self.dialect != "duckdb" or write:
|
|
124
|
+
self._conn.commit()
|
|
125
|
+
except Exception:
|
|
126
|
+
if self.dialect != "duckdb" or write:
|
|
127
|
+
self._conn.rollback()
|
|
128
|
+
raise
|
|
129
|
+
finally:
|
|
130
|
+
if cur is not self._conn:
|
|
131
|
+
cur.close()
|
|
132
|
+
|
|
133
|
+
def _tenant_for(self, requested: str | int | None) -> str:
|
|
134
|
+
if requested is not None and str(requested) != self._tenant:
|
|
135
|
+
raise ValueError("tenant_id does not match this store")
|
|
136
|
+
return self._tenant_key
|
|
137
|
+
|
|
138
|
+
def initialize(self) -> None:
|
|
139
|
+
"""Create a private, versioned schema; never mutate another prefix."""
|
|
140
|
+
with self._tx(write=True) as cur:
|
|
141
|
+
self._execute(cur, f"CREATE TABLE IF NOT EXISTS {self._config} (name TEXT PRIMARY KEY, value VARCHAR(255) NOT NULL)")
|
|
142
|
+
self._execute(cur, f"SELECT value FROM {self._config} WHERE name='schema_version'")
|
|
143
|
+
row = cur.fetchone()
|
|
144
|
+
if row is None:
|
|
145
|
+
self._execute(cur, f"INSERT INTO {self._config} (name,value) VALUES ('schema_version','1')")
|
|
146
|
+
elif row[0] != "1":
|
|
147
|
+
raise ValueError(f"unsupported {self.prefix} schema version")
|
|
148
|
+
self._execute(cur, f"""CREATE TABLE IF NOT EXISTS {self._events} (
|
|
149
|
+
tenant_key CHAR(64) NOT NULL, event_key CHAR(64) NOT NULL,
|
|
150
|
+
trace_key CHAR(64) NOT NULL, span_key CHAR(64) NOT NULL,
|
|
151
|
+
seq BIGINT NOT NULL, ts BIGINT NOT NULL, event_json TEXT NOT NULL,
|
|
152
|
+
PRIMARY KEY (tenant_key,event_key))""")
|
|
153
|
+
self._execute(cur, f"""CREATE TABLE IF NOT EXISTS {self._spans} (
|
|
154
|
+
tenant_key CHAR(64) NOT NULL, trace_key CHAR(64) NOT NULL,
|
|
155
|
+
span_key CHAR(64) NOT NULL, trace_id TEXT NOT NULL,
|
|
156
|
+
span_id TEXT NOT NULL, ts BIGINT NOT NULL,
|
|
157
|
+
agent_name TEXT, status INTEGER, session_key CHAR(64),
|
|
158
|
+
search_text TEXT NOT NULL, data TEXT NOT NULL,
|
|
159
|
+
PRIMARY KEY (tenant_key,trace_key,span_key))""")
|
|
160
|
+
self._execute(cur, f"""CREATE TABLE IF NOT EXISTS {self._attrs} (
|
|
161
|
+
tenant_key CHAR(64) NOT NULL, trace_key CHAR(64) NOT NULL,
|
|
162
|
+
span_key CHAR(64) NOT NULL, attr_key_hash CHAR(64) NOT NULL,
|
|
163
|
+
value_hash CHAR(64) NOT NULL, attr_key TEXT NOT NULL,
|
|
164
|
+
value_json TEXT NOT NULL,
|
|
165
|
+
PRIMARY KEY (tenant_key,trace_key,span_key,attr_key_hash))""")
|
|
166
|
+
self._index(cur, f"{self.prefix}_event_span_idx", self._events,
|
|
167
|
+
"(tenant_key,trace_key,span_key,seq)")
|
|
168
|
+
self._index(cur, f"{self.prefix}_span_time_idx", self._spans,
|
|
169
|
+
"(tenant_key,ts)")
|
|
170
|
+
self._index(cur, f"{self.prefix}_span_session_idx", self._spans,
|
|
171
|
+
"(tenant_key,session_key,ts)")
|
|
172
|
+
self._index(cur, f"{self.prefix}_attrs_lookup_idx", self._attrs,
|
|
173
|
+
"(tenant_key,attr_key_hash,value_hash)")
|
|
174
|
+
|
|
175
|
+
def _index(self, cur: Any, name: str, table: str, columns: str) -> None:
|
|
176
|
+
self._execute(cur, f"CREATE INDEX IF NOT EXISTS {name} ON {table} {columns}")
|
|
177
|
+
|
|
178
|
+
def ingest(self, events: Sequence[Mapping[str, Any]], *,
|
|
179
|
+
tenant_id: str | int | None = None) -> dict[str, int]:
|
|
180
|
+
tenant = self._tenant_for(tenant_id)
|
|
181
|
+
prepared: list[tuple[str, str, str, str, str, int, int, str]] = []
|
|
182
|
+
for raw in events:
|
|
183
|
+
event = dict(raw)
|
|
184
|
+
if event.get("tenant_id") is not None and str(event["tenant_id"]) != self._tenant:
|
|
185
|
+
raise ValueError("event tenant_id does not match this store")
|
|
186
|
+
kind, seq = int(event["event_type"]), int(event["seq"])
|
|
187
|
+
if kind not in (1, 2, 3, 4, 5) or not 0 <= seq < 2**63:
|
|
188
|
+
raise ValueError("invalid event_type or seq")
|
|
189
|
+
ext_id = str(event["ext_span_id"]) if event.get("ext_span_id") is not None else ""
|
|
190
|
+
if not ext_id:
|
|
191
|
+
raise ValueError("ext_span_id is required")
|
|
192
|
+
expected_id = event_id(ext_id, seq, EventType(kind))
|
|
193
|
+
if event.get("event_id") is not None and int(event["event_id"]) != expected_id:
|
|
194
|
+
raise ValueError("event_id does not match ext_span_id, seq and event_type")
|
|
195
|
+
trace_id = str(event["trace_id"]) if event.get("trace_id") is not None else ""
|
|
196
|
+
span_id = str(event["span_id"]) if event.get("span_id") is not None else ""
|
|
197
|
+
if not trace_id or not span_id:
|
|
198
|
+
raise ValueError("trace_id and span_id are required")
|
|
199
|
+
event["tenant_id"] = self._tenant
|
|
200
|
+
identity = _key(_json([ext_id, seq, kind]))
|
|
201
|
+
prepared.append((identity, _key(trace_id), _key(span_id),
|
|
202
|
+
trace_id, span_id, seq, int(event["ts"]), _json(event)))
|
|
203
|
+
if not prepared:
|
|
204
|
+
return {"ingested": 0}
|
|
205
|
+
group_ids = {(trace_key, span_key): (trace_id, span_id)
|
|
206
|
+
for _, trace_key, span_key, trace_id, span_id, _, _, _ in prepared}
|
|
207
|
+
groups = sorted(group_ids)
|
|
208
|
+
inserted = 0
|
|
209
|
+
with self._tx(write=True) as cur:
|
|
210
|
+
for trace_key, span_key in groups:
|
|
211
|
+
trace_id, span_id = group_ids[(trace_key, span_key)]
|
|
212
|
+
self._execute(cur, f"""INSERT INTO {self._spans}
|
|
213
|
+
(tenant_key,trace_key,span_key,trace_id,span_id,ts,search_text,data)
|
|
214
|
+
VALUES (?,?,?,?,?,0,' ','{{}}') ON CONFLICT DO NOTHING""",
|
|
215
|
+
(tenant, trace_key, span_key, trace_id, span_id))
|
|
216
|
+
lock = " FOR UPDATE" if self.dialect == "postgresql" else ""
|
|
217
|
+
self._execute(cur, f"""SELECT 1 FROM {self._spans}
|
|
218
|
+
WHERE tenant_key=? AND trace_key=? AND span_key=?{lock}""",
|
|
219
|
+
(tenant, trace_key, span_key))
|
|
220
|
+
cur.fetchone()
|
|
221
|
+
for identity, trace_key, span_key, _trace_id, _span_id, seq, ts, body in prepared:
|
|
222
|
+
self._execute(cur, f"""SELECT trace_key,span_key FROM {self._events}
|
|
223
|
+
WHERE tenant_key=? AND event_key=?""", (tenant, identity))
|
|
224
|
+
existing = cur.fetchone()
|
|
225
|
+
if existing is not None:
|
|
226
|
+
if (str(existing[0]), str(existing[1])) != (trace_key, span_key):
|
|
227
|
+
raise ValueError("duplicate event identity belongs to another span")
|
|
228
|
+
continue
|
|
229
|
+
self._execute(cur, f"""INSERT INTO {self._events}
|
|
230
|
+
(tenant_key,event_key,trace_key,span_key,seq,ts,event_json)
|
|
231
|
+
VALUES (?,?,?,?,?,?,?)""",
|
|
232
|
+
(tenant, identity, trace_key, span_key, seq, ts, body))
|
|
233
|
+
inserted += 1
|
|
234
|
+
for trace_key, span_key in groups:
|
|
235
|
+
self._execute(cur, f"""SELECT event_json FROM {self._events}
|
|
236
|
+
WHERE tenant_key=? AND trace_key=? AND span_key=?
|
|
237
|
+
ORDER BY seq,event_key""", (tenant, trace_key, span_key))
|
|
238
|
+
sources = [json.loads(row[0]) for row in cur.fetchall()]
|
|
239
|
+
if not sources:
|
|
240
|
+
self._execute(cur, f"""DELETE FROM {self._spans}
|
|
241
|
+
WHERE tenant_key=? AND trace_key=? AND span_key=? AND data='{{}}'""",
|
|
242
|
+
(tenant, trace_key, span_key))
|
|
243
|
+
continue
|
|
244
|
+
span = _fold(sources)
|
|
245
|
+
session = span.get("external_session_id") or span.get("session_id")
|
|
246
|
+
self._execute(cur, f"""UPDATE {self._spans} SET
|
|
247
|
+
ts=?,agent_name=?,status=?,session_key=?,search_text=?,data=?
|
|
248
|
+
WHERE tenant_key=? AND trace_key=? AND span_key=?""",
|
|
249
|
+
(span["ts"], span.get("agent_name"), span.get("status"),
|
|
250
|
+
_key(session) if session is not None else None,
|
|
251
|
+
_search_text(span), _json(span), tenant, trace_key, span_key))
|
|
252
|
+
self._execute(cur, f"""DELETE FROM {self._attrs}
|
|
253
|
+
WHERE tenant_key=? AND trace_key=? AND span_key=?""",
|
|
254
|
+
(tenant, trace_key, span_key))
|
|
255
|
+
for attr_key, value in sorted((span.get("attrs") or {}).items()):
|
|
256
|
+
encoded = _json(value)
|
|
257
|
+
self._execute(cur, f"""INSERT INTO {self._attrs}
|
|
258
|
+
(tenant_key,trace_key,span_key,attr_key_hash,value_hash,attr_key,value_json)
|
|
259
|
+
VALUES (?,?,?,?,?,?,?)""",
|
|
260
|
+
(tenant, trace_key, span_key, _key(attr_key), _key(encoded),
|
|
261
|
+
str(attr_key), encoded))
|
|
262
|
+
return {"ingested": inserted}
|
|
263
|
+
|
|
264
|
+
def _where(self, filters: Mapping[str, Any]) -> tuple[str, list[Any]]:
|
|
265
|
+
clauses = ["s.tenant_key=?", "s.data<>'{}'"]
|
|
266
|
+
params: list[Any] = [self._tenant_key]
|
|
267
|
+
attrs: dict[str, Any] = {}
|
|
268
|
+
for key, value in filters.items():
|
|
269
|
+
if key in {"tenant_id", "tenantId"}:
|
|
270
|
+
raise ValueError("tenant cannot be set in a filter")
|
|
271
|
+
if value is None and key != "attrs":
|
|
272
|
+
continue
|
|
273
|
+
if key in {"trace_id", "externalTraceId", "external_trace_id"}:
|
|
274
|
+
clauses.append("s.trace_key=?")
|
|
275
|
+
params.append(_key(value))
|
|
276
|
+
elif key in {"externalSessionId", "external_session_id"}:
|
|
277
|
+
clauses.append("s.session_key=?")
|
|
278
|
+
params.append(_key(value))
|
|
279
|
+
elif key == "agent_name":
|
|
280
|
+
clauses.append("s.agent_name=?")
|
|
281
|
+
params.append(value)
|
|
282
|
+
elif key == "status":
|
|
283
|
+
clauses.append("s.status=?")
|
|
284
|
+
params.append(value)
|
|
285
|
+
elif key in {"time_from", "time_to"}:
|
|
286
|
+
clauses.append(f"s.ts {'>=' if key == 'time_from' else '<='}?")
|
|
287
|
+
params.append(int(value))
|
|
288
|
+
elif key == "attrs":
|
|
289
|
+
if not isinstance(value, Mapping):
|
|
290
|
+
raise ValueError("filter.attrs must be an object")
|
|
291
|
+
attrs.update(value)
|
|
292
|
+
elif key in {"project_id", "projectId", "skill", "mode", "call_site", "callSite"}:
|
|
293
|
+
attrs[_ALIASES.get(key, key)] = value
|
|
294
|
+
else:
|
|
295
|
+
raise ValueError(f"unsupported search filter: {key}")
|
|
296
|
+
for key, value in sorted(attrs.items()):
|
|
297
|
+
encoded = _json(value)
|
|
298
|
+
clauses.append(f"""EXISTS (SELECT 1 FROM {self._attrs} a
|
|
299
|
+
WHERE a.tenant_key=s.tenant_key AND a.trace_key=s.trace_key
|
|
300
|
+
AND a.span_key=s.span_key AND a.attr_key_hash=?
|
|
301
|
+
AND a.value_hash=? AND a.attr_key=? AND a.value_json=?)""")
|
|
302
|
+
params.extend((_key(key), _key(encoded), str(key), encoded))
|
|
303
|
+
return " AND ".join(clauses), params
|
|
304
|
+
|
|
305
|
+
def search(self, query: Mapping[str, Any] | None = None, *,
|
|
306
|
+
tenant_id: str | int | None = None, **kwargs: Any) -> list[dict[str, Any]]:
|
|
307
|
+
self._tenant_for(tenant_id)
|
|
308
|
+
body = {**dict(query or {}), **kwargs}
|
|
309
|
+
if set(body) - {"text", "vector", "k", "filter"}:
|
|
310
|
+
raise ValueError("unsupported search field")
|
|
311
|
+
if body.get("vector") is not None:
|
|
312
|
+
raise NotImplementedError(f"{self.dialect} vector search is not configured")
|
|
313
|
+
text = body.get("text")
|
|
314
|
+
if not isinstance(text, str) or not text:
|
|
315
|
+
raise ValueError("search requires non-empty text")
|
|
316
|
+
k = int(body.get("k", 10))
|
|
317
|
+
if not 1 <= k <= 1000:
|
|
318
|
+
raise ValueError("k must be between 1 and 1000")
|
|
319
|
+
filters = body.get("filter") or {}
|
|
320
|
+
if not isinstance(filters, Mapping):
|
|
321
|
+
raise ValueError("filter must be an object")
|
|
322
|
+
where, params = self._where(filters)
|
|
323
|
+
with self._tx() as cur:
|
|
324
|
+
self._execute(cur, f"""SELECT s.data FROM {self._spans} s
|
|
325
|
+
WHERE {where} AND s.search_text LIKE ? ESCAPE '!'
|
|
326
|
+
ORDER BY s.ts DESC,s.trace_key,s.span_key LIMIT ?""",
|
|
327
|
+
(*params, _like(text), k))
|
|
328
|
+
return [dict(json.loads(row[0]), score=1.0) for row in cur.fetchall()]
|
|
329
|
+
|
|
330
|
+
def trace(self, trace_id: str | int, *, tenant_id: str | int | None = None) -> list[dict[str, Any]]:
|
|
331
|
+
tenant = self._tenant_for(tenant_id)
|
|
332
|
+
with self._tx() as cur:
|
|
333
|
+
self._execute(cur, f"""SELECT data FROM {self._spans}
|
|
334
|
+
WHERE tenant_key=? AND trace_key=? AND data<>'{{}}'
|
|
335
|
+
ORDER BY ts,span_key""", (tenant, _key(trace_id)))
|
|
336
|
+
return [json.loads(row[0]) for row in cur.fetchall()]
|
|
337
|
+
|
|
338
|
+
def span(self, trace_id: str | int, span_id: str | int, *,
|
|
339
|
+
tenant_id: str | int | None = None) -> dict[str, Any] | None:
|
|
340
|
+
tenant = self._tenant_for(tenant_id)
|
|
341
|
+
with self._tx() as cur:
|
|
342
|
+
self._execute(cur, f"""SELECT data FROM {self._spans}
|
|
343
|
+
WHERE tenant_key=? AND trace_key=? AND span_key=? AND data<>'{{}}'""",
|
|
344
|
+
(tenant, _key(trace_id), _key(span_id)))
|
|
345
|
+
row = cur.fetchone()
|
|
346
|
+
return json.loads(row[0]) if row else None
|
|
347
|
+
|
|
348
|
+
def list_spans(self, *, filters: Mapping[str, Any] | None = None,
|
|
349
|
+
limit: int = 100, cursor: int = 0,
|
|
350
|
+
tenant_id: str | int | None = None) -> dict[str, Any]:
|
|
351
|
+
self._tenant_for(tenant_id)
|
|
352
|
+
if not 1 <= limit <= 1000 or cursor < 0:
|
|
353
|
+
raise ValueError("limit must be 1..1000 and cursor nonnegative")
|
|
354
|
+
where, params = self._where(filters or {})
|
|
355
|
+
with self._tx() as cur:
|
|
356
|
+
self._execute(cur, f"SELECT COUNT(*) FROM {self._spans} s WHERE {where}", params)
|
|
357
|
+
total = int(cur.fetchone()[0])
|
|
358
|
+
self._execute(cur, f"""SELECT s.data FROM {self._spans} s WHERE {where}
|
|
359
|
+
ORDER BY s.ts,s.trace_key,s.span_key LIMIT ? OFFSET ?""",
|
|
360
|
+
(*params, limit, cursor))
|
|
361
|
+
return {"items": [json.loads(row[0]) for row in cur.fetchall()], "total": total}
|
|
362
|
+
|
|
363
|
+
def list_trace_ids(self, *, filters: Mapping[str, Any] | None = None,
|
|
364
|
+
limit: int = 20, cursor: int = 0,
|
|
365
|
+
tenant_id: str | int | None = None) -> dict[str, Any]:
|
|
366
|
+
self._tenant_for(tenant_id)
|
|
367
|
+
if not 1 <= limit <= 1000 or cursor < 0:
|
|
368
|
+
raise ValueError("limit must be 1..1000 and cursor nonnegative")
|
|
369
|
+
where, params = self._where(filters or {})
|
|
370
|
+
with self._tx() as cur:
|
|
371
|
+
self._execute(cur, f"SELECT COUNT(DISTINCT s.trace_key) FROM {self._spans} s WHERE {where}", params)
|
|
372
|
+
total = int(cur.fetchone()[0])
|
|
373
|
+
self._execute(cur, f"""SELECT MIN(s.trace_id) FROM {self._spans} s WHERE {where}
|
|
374
|
+
GROUP BY s.trace_key ORDER BY MIN(s.ts),s.trace_key LIMIT ? OFFSET ?""",
|
|
375
|
+
(*params, limit, cursor))
|
|
376
|
+
return {"items": [str(row[0]) for row in cur.fetchall()], "total": total}
|
|
377
|
+
|
|
378
|
+
def capabilities(self) -> dict[str, Any]:
|
|
379
|
+
return {"backend": self.dialect, "text": "substring_scan",
|
|
380
|
+
"vector": None, "hybrid": None, "attrs_filter": "exact_sql",
|
|
381
|
+
"transactional_projection": True, "session_filter": True}
|
|
382
|
+
|
|
383
|
+
def ping(self) -> bool:
|
|
384
|
+
with self._tx() as cur:
|
|
385
|
+
self._execute(cur, f"SELECT value FROM {self._config} WHERE name='schema_version'")
|
|
386
|
+
row = cur.fetchone()
|
|
387
|
+
if row is None or row[0] != "1":
|
|
388
|
+
return False
|
|
389
|
+
for table in (self._events, self._spans, self._attrs):
|
|
390
|
+
self._execute(cur, f"SELECT 1 FROM {table} LIMIT 0")
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
def close(self) -> None:
|
|
394
|
+
with self._lock:
|
|
395
|
+
if not self._closed:
|
|
396
|
+
self._closed = True
|
|
397
|
+
self._conn.close()
|
|
398
|
+
|
|
399
|
+
def __enter__(self) -> SQLTraceStore:
|
|
400
|
+
return self
|
|
401
|
+
|
|
402
|
+
def __exit__(self, *_: Any) -> None:
|
|
403
|
+
self.close()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class SQLiteTraceStore(SQLTraceStore):
|
|
407
|
+
dialect = "sqlite"
|
|
408
|
+
|
|
409
|
+
@classmethod
|
|
410
|
+
def open(cls, path: str | Path, *, tenant_id: str | int,
|
|
411
|
+
initialize: bool = False, table_prefix: str = "fuju_trace") -> SQLiteTraceStore:
|
|
412
|
+
conn = sqlite3.connect(str(path), timeout=30, check_same_thread=False)
|
|
413
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
414
|
+
if str(path) != ":memory:":
|
|
415
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
416
|
+
store = cls(conn, tenant_id=tenant_id, table_prefix=table_prefix)
|
|
417
|
+
try:
|
|
418
|
+
if initialize:
|
|
419
|
+
store.initialize()
|
|
420
|
+
return store
|
|
421
|
+
except Exception:
|
|
422
|
+
store.close()
|
|
423
|
+
raise
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class DuckDBTraceStore(SQLTraceStore):
|
|
427
|
+
dialect = "duckdb"
|
|
428
|
+
|
|
429
|
+
@classmethod
|
|
430
|
+
def open(cls, path: str | Path, *, tenant_id: str | int,
|
|
431
|
+
initialize: bool = False, table_prefix: str = "fuju_trace") -> DuckDBTraceStore:
|
|
432
|
+
try:
|
|
433
|
+
import duckdb
|
|
434
|
+
except ImportError as exc:
|
|
435
|
+
raise RuntimeError("install fuju-trace-sql[duckdb]") from exc
|
|
436
|
+
store = cls(duckdb.connect(str(path)), tenant_id=tenant_id, table_prefix=table_prefix)
|
|
437
|
+
try:
|
|
438
|
+
if initialize:
|
|
439
|
+
store.initialize()
|
|
440
|
+
return store
|
|
441
|
+
except Exception:
|
|
442
|
+
store.close()
|
|
443
|
+
raise
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class PostgreSQLTraceStore(SQLTraceStore):
|
|
447
|
+
dialect = "postgresql"
|
|
448
|
+
|
|
449
|
+
@classmethod
|
|
450
|
+
def open(cls, dsn: str | None = None, *, tenant_id: str | int,
|
|
451
|
+
connection_params: Mapping[str, Any] | None = None,
|
|
452
|
+
initialize: bool = False, table_prefix: str = "fuju_trace",
|
|
453
|
+
connection_factory: Callable[..., Any] | None = None) -> PostgreSQLTraceStore:
|
|
454
|
+
if (dsn is None) == (connection_params is None):
|
|
455
|
+
raise ValueError("provide PostgreSQL dsn or connection_params")
|
|
456
|
+
if dsn == "" or connection_params == {}:
|
|
457
|
+
raise ValueError("PostgreSQL connection target must not be empty")
|
|
458
|
+
if connection_factory is None:
|
|
459
|
+
try:
|
|
460
|
+
import psycopg2
|
|
461
|
+
except ImportError as exc:
|
|
462
|
+
raise RuntimeError("install fuju-trace-sql[postgresql]") from exc
|
|
463
|
+
connection_factory = psycopg2.connect
|
|
464
|
+
conn = (connection_factory(dsn) if dsn is not None
|
|
465
|
+
else connection_factory(**dict(connection_params)))
|
|
466
|
+
if hasattr(conn, "autocommit"):
|
|
467
|
+
conn.autocommit = False
|
|
468
|
+
store = cls(conn, tenant_id=tenant_id, table_prefix=table_prefix)
|
|
469
|
+
try:
|
|
470
|
+
if initialize:
|
|
471
|
+
store.initialize()
|
|
472
|
+
return store
|
|
473
|
+
except Exception:
|
|
474
|
+
store.close()
|
|
475
|
+
raise
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fuju-trace-sql"
|
|
7
|
+
version = "0.1.10"
|
|
8
|
+
description = "SQLite, DuckDB and PostgreSQL storage adapters for Fuju Trace"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Fuju Trace" }]
|
|
13
|
+
dependencies = ["fuju-trace==0.1.10"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
duckdb = ["duckdb>=1.0,<3"]
|
|
17
|
+
postgresql = ["psycopg2-binary>=2.9.5,<3"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/vibeinging/fuju-trace"
|
|
21
|
+
Repository = "https://github.com/vibeinging/fuju-trace"
|
|
22
|
+
Issues = "https://github.com/vibeinging/fuju-trace/issues"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["fuju_trace_sql"]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Integration checks against a disposable PostgreSQL database.
|
|
2
|
+
|
|
3
|
+
Creates and removes four tables under a random fuju_smoke_* prefix.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import uuid
|
|
11
|
+
from contextlib import closing
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
15
|
+
sys.path.insert(0, str(ROOT))
|
|
16
|
+
sys.path.insert(0, str(ROOT.parent / "fuju-trace-sdk" / "python"))
|
|
17
|
+
|
|
18
|
+
from fuju_trace import DbExporter, Tracer, connect
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _event(trace_id: str, span_id: str, ext_span_id: str, seq: int,
|
|
22
|
+
event_type: int, **fields: object) -> dict[str, object]:
|
|
23
|
+
return {
|
|
24
|
+
"trace_id": trace_id, "span_id": span_id, "ext_span_id": ext_span_id,
|
|
25
|
+
"seq": seq, "event_type": event_type, "ts": 100 + seq,
|
|
26
|
+
"tenant_id": 1, **fields,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> int:
|
|
31
|
+
prefix = "fuju_smoke_" + uuid.uuid4().hex[:12]
|
|
32
|
+
dsn = os.environ.get("POSTGRESQL_DSN")
|
|
33
|
+
if not dsn:
|
|
34
|
+
raise SystemExit("POSTGRESQL_DSN is required")
|
|
35
|
+
db = None
|
|
36
|
+
try:
|
|
37
|
+
db = connect(postgresql_dsn=dsn, tenant_id=1, initialize=True,
|
|
38
|
+
table_prefix=prefix)
|
|
39
|
+
assert db.ping()
|
|
40
|
+
|
|
41
|
+
trace_id = "live-" + uuid.uuid4().hex
|
|
42
|
+
span_id = "span-1"
|
|
43
|
+
source_id = "source-" + uuid.uuid4().hex
|
|
44
|
+
events = [
|
|
45
|
+
_event(trace_id, span_id, source_id, 0, 1, span_name="review",
|
|
46
|
+
session_id=42, attrs={"project_id": "sql-live"}),
|
|
47
|
+
_event(trace_id, span_id, source_id, 1, 4, logs=["疑似盗刷"]),
|
|
48
|
+
_event(trace_id, span_id, source_id, 2, 2, status=1),
|
|
49
|
+
]
|
|
50
|
+
assert db.ingest(events[1:]) == {"ingested": 2}
|
|
51
|
+
assert db.ingest(events) == {"ingested": 1}
|
|
52
|
+
assert db.ingest(events) == {"ingested": 0}
|
|
53
|
+
span = db.span(trace_id, span_id)
|
|
54
|
+
assert span is not None and span["event_count"] == 3
|
|
55
|
+
assert span["span_name"] == "review" and span["logs"] == ["疑似盗刷"]
|
|
56
|
+
assert db.trace(trace_id) == [span]
|
|
57
|
+
assert len(db.search(text="盗刷", filter={"project_id": "sql-live", "status": 1})) == 1
|
|
58
|
+
assert db.list_trace_ids(filters={"externalSessionId": 42})["items"] == [trace_id]
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
db.ingest([{**events[0], "span_id": "wrong-span"}])
|
|
62
|
+
except ValueError:
|
|
63
|
+
pass
|
|
64
|
+
else:
|
|
65
|
+
raise AssertionError("conflicting event identity was accepted")
|
|
66
|
+
assert db.span(trace_id, "wrong-span") is None
|
|
67
|
+
assert db.span(trace_id, span_id)["event_count"] == 3
|
|
68
|
+
|
|
69
|
+
with connect(postgresql_dsn=dsn, tenant_id=2, table_prefix=prefix) as other_tenant:
|
|
70
|
+
assert other_tenant.trace(trace_id) == []
|
|
71
|
+
assert other_tenant.search(text="盗刷") == []
|
|
72
|
+
|
|
73
|
+
concurrent_span = "span-2"
|
|
74
|
+
concurrent_source = "source-" + uuid.uuid4().hex
|
|
75
|
+
db.ingest([_event(trace_id, concurrent_span, concurrent_source, 0, 1,
|
|
76
|
+
span_name="parallel")])
|
|
77
|
+
barrier = threading.Barrier(2)
|
|
78
|
+
failures: list[Exception] = []
|
|
79
|
+
|
|
80
|
+
def write_one(event: dict[str, object]) -> None:
|
|
81
|
+
try:
|
|
82
|
+
with connect(postgresql_dsn=dsn, tenant_id=1, table_prefix=prefix) as writer:
|
|
83
|
+
barrier.wait(timeout=10)
|
|
84
|
+
writer.ingest([event])
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
failures.append(exc)
|
|
87
|
+
|
|
88
|
+
writers = [
|
|
89
|
+
threading.Thread(target=write_one, args=(
|
|
90
|
+
_event(trace_id, concurrent_span, concurrent_source, 1, 4,
|
|
91
|
+
logs=["parallel log"]),)),
|
|
92
|
+
threading.Thread(target=write_one, args=(
|
|
93
|
+
_event(trace_id, concurrent_span, concurrent_source, 2, 2,
|
|
94
|
+
status=0),)),
|
|
95
|
+
]
|
|
96
|
+
for writer in writers:
|
|
97
|
+
writer.start()
|
|
98
|
+
for writer in writers:
|
|
99
|
+
writer.join(timeout=15)
|
|
100
|
+
assert not any(writer.is_alive() for writer in writers), "parallel writers timed out"
|
|
101
|
+
assert not failures, failures
|
|
102
|
+
folded = db.span(trace_id, concurrent_span)
|
|
103
|
+
assert folded is not None and folded["event_count"] == 3
|
|
104
|
+
assert folded["logs"] == ["parallel log"] and folded["has_end"]
|
|
105
|
+
|
|
106
|
+
tracer = Tracer(exporter=DbExporter(db, tenant_id=1), node_id=1)
|
|
107
|
+
with tracer.trace("smoke", session_id=42, tenant_id=1) as trace:
|
|
108
|
+
with trace.span("write") as span:
|
|
109
|
+
span.log("fuju sql live smoke")
|
|
110
|
+
tracer.close()
|
|
111
|
+
assert len(db.search(text="fuju sql live smoke")) == 1
|
|
112
|
+
db.close()
|
|
113
|
+
db = None
|
|
114
|
+
with connect(postgresql_dsn=dsn, tenant_id=1, table_prefix=prefix) as reopened:
|
|
115
|
+
assert reopened.ping()
|
|
116
|
+
assert reopened.span(trace_id, span_id)["event_count"] == 3
|
|
117
|
+
assert reopened.span(trace_id, concurrent_span)["event_count"] == 3
|
|
118
|
+
print("postgresql: real write, retry, rollback, tenant, concurrent writers, search, reopen OK")
|
|
119
|
+
finally:
|
|
120
|
+
if db is not None:
|
|
121
|
+
db.close()
|
|
122
|
+
# These names were generated here and validated by the store.
|
|
123
|
+
import psycopg2
|
|
124
|
+
with closing(psycopg2.connect(dsn)) as cleanup:
|
|
125
|
+
with cleanup:
|
|
126
|
+
with cleanup.cursor() as cur:
|
|
127
|
+
for suffix in ("attrs", "spans", "events", "config"):
|
|
128
|
+
cur.execute(f"DROP TABLE IF EXISTS {prefix}_{suffix}")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
repo_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
|
5
|
+
test_python=${FUJU_SQL_PYTHON:-python3}
|
|
6
|
+
|
|
7
|
+
for program in initdb pg_ctl createdb; do
|
|
8
|
+
if ! command -v "$program" >/dev/null 2>&1; then
|
|
9
|
+
printf 'missing PostgreSQL command: %s\n' "$program" >&2
|
|
10
|
+
exit 1
|
|
11
|
+
fi
|
|
12
|
+
done
|
|
13
|
+
"$test_python" -c 'import psycopg2' || {
|
|
14
|
+
printf 'install fuju-trace-sql[postgresql] for %s\n' "$test_python" >&2
|
|
15
|
+
exit 1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
pg_test_dir=$(mktemp -d /tmp/fuju-trace-pg.XXXXXX)
|
|
19
|
+
cleanup() {
|
|
20
|
+
pg_ctl -D "$pg_test_dir/data" -m immediate stop >/dev/null 2>&1 || true
|
|
21
|
+
rm -r -- "$pg_test_dir"
|
|
22
|
+
}
|
|
23
|
+
trap cleanup EXIT
|
|
24
|
+
|
|
25
|
+
initdb -D "$pg_test_dir/data" -A trust -U fuju_test --no-instructions > "$pg_test_dir/init.log"
|
|
26
|
+
pg_ctl -D "$pg_test_dir/data" -l "$pg_test_dir/server.log" \
|
|
27
|
+
-o "-c listen_addresses='' -c unix_socket_directories=$pg_test_dir -p 55432" start
|
|
28
|
+
createdb -h "$pg_test_dir" -p 55432 -U fuju_test fuju_test
|
|
29
|
+
|
|
30
|
+
POSTGRESQL_DSN="host=$pg_test_dir port=55432 dbname=fuju_test user=fuju_test" \
|
|
31
|
+
"$test_python" "$repo_dir/fuju-trace-sql/tests/live_smoke.py"
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import sqlite3
|
|
3
|
+
import tempfile
|
|
4
|
+
import threading
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
9
|
+
sys.path.insert(0, str(ROOT))
|
|
10
|
+
sys.path.insert(0, str(ROOT.parent / "fuju-trace-sdk" / "python"))
|
|
11
|
+
|
|
12
|
+
from fuju_trace import DbExporter, Tracer, connect
|
|
13
|
+
from fuju_trace_sql import (
|
|
14
|
+
DuckDBTraceStore, PostgreSQLTraceStore, SQLiteTraceStore,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _events():
|
|
19
|
+
return [
|
|
20
|
+
{"trace_id": "trace-1", "span_id": "span-1", "ext_span_id": "source-1",
|
|
21
|
+
"event_type": 1, "seq": 0, "ts": 10, "tenant_id": 1,
|
|
22
|
+
"span_name": "plan", "session_id": 42, "agent_name": "planner",
|
|
23
|
+
"attrs": {"project_id": "demo", "retry": True}},
|
|
24
|
+
{"trace_id": "trace-1", "span_id": "span-1", "ext_span_id": "source-1",
|
|
25
|
+
"event_type": 4, "seq": 1, "ts": 11, "tenant_id": 1,
|
|
26
|
+
"logs": ["疑似盗刷"], "attrs": {"step": 2}},
|
|
27
|
+
{"trace_id": "trace-1", "span_id": "span-1", "ext_span_id": "source-1",
|
|
28
|
+
"event_type": 2, "seq": 2, "ts": 12, "tenant_id": 1, "status": 1},
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SQLiteTests(unittest.TestCase):
|
|
33
|
+
def test_sdk_round_trip_retry_filters_and_reopen(self):
|
|
34
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
35
|
+
db_path = Path(temp) / "trace.sqlite"
|
|
36
|
+
with connect(sqlite_path=db_path, tenant_id=1, initialize=True) as db:
|
|
37
|
+
self.assertTrue(db.ping())
|
|
38
|
+
self.assertEqual(db.ingest(_events()[1:]), {"ingested": 2})
|
|
39
|
+
self.assertEqual(db.ingest(_events()), {"ingested": 1})
|
|
40
|
+
self.assertEqual(db.ingest(_events()), {"ingested": 0})
|
|
41
|
+
span = db.span("trace-1", "span-1")
|
|
42
|
+
self.assertEqual(span["span_name"], "plan")
|
|
43
|
+
self.assertEqual(span["logs"], ["疑似盗刷"])
|
|
44
|
+
self.assertEqual(span["event_count"], 3)
|
|
45
|
+
self.assertEqual(db.trace("trace-1"), [span])
|
|
46
|
+
query = {"text": "盗刷", "filter": {
|
|
47
|
+
"externalSessionId": 42, "agent_name": "planner",
|
|
48
|
+
"status": 1, "attrs": {"project_id": "demo", "retry": True},
|
|
49
|
+
}}
|
|
50
|
+
self.assertEqual(len(db.search(query)), 1)
|
|
51
|
+
self.assertEqual(db.search(text="盗%", k=10), [])
|
|
52
|
+
self.assertEqual(db.list_spans(filters={"externalSessionId": 42})["total"], 1)
|
|
53
|
+
self.assertEqual(db.list_trace_ids(filters={"externalSessionId": 42})["items"], ["trace-1"])
|
|
54
|
+
with self.assertRaises(ValueError):
|
|
55
|
+
db.search(text="盗刷", filter={"tenant_id": 2})
|
|
56
|
+
with self.assertRaises(ValueError):
|
|
57
|
+
db.span("trace-1", "span-1", tenant_id=2)
|
|
58
|
+
with self.assertRaises(NotImplementedError):
|
|
59
|
+
db.search(vector=[1.0, 0.0])
|
|
60
|
+
self.assertEqual(db.capabilities()["vector"], None)
|
|
61
|
+
with SQLiteTraceStore.open(db_path, tenant_id=1, initialize=True) as reopened:
|
|
62
|
+
self.assertEqual(reopened.span("trace-1", "span-1")["event_count"], 3)
|
|
63
|
+
with SQLiteTraceStore.open(db_path, tenant_id=2) as other:
|
|
64
|
+
self.assertEqual(other.trace("trace-1"), [])
|
|
65
|
+
|
|
66
|
+
def test_transaction_rollback_and_conflicting_retry(self):
|
|
67
|
+
with SQLiteTraceStore.open(":memory:", tenant_id=1, initialize=True) as db:
|
|
68
|
+
with self.assertRaises(ValueError):
|
|
69
|
+
db.ingest([_events()[0], {**_events()[1], "tenant_id": 2}])
|
|
70
|
+
self.assertEqual(db.list_spans()["total"], 0)
|
|
71
|
+
with self.assertRaises(TypeError):
|
|
72
|
+
db.ingest([{**_events()[0], "logs": [["invalid log"]]}])
|
|
73
|
+
self.assertEqual(db.list_spans()["total"], 0)
|
|
74
|
+
db.ingest([_events()[0]])
|
|
75
|
+
with self.assertRaises(ValueError):
|
|
76
|
+
db.ingest([{**_events()[0], "span_id": "another"}])
|
|
77
|
+
self.assertEqual(db.list_spans()["total"], 1)
|
|
78
|
+
self.assertIsNone(db.span("trace-1", "another"))
|
|
79
|
+
|
|
80
|
+
def test_sdk_exporter_and_concurrent_sessions(self):
|
|
81
|
+
with SQLiteTraceStore.open(":memory:", tenant_id=1, initialize=True) as db:
|
|
82
|
+
failures = []
|
|
83
|
+
def write(node):
|
|
84
|
+
try:
|
|
85
|
+
tracer = Tracer(exporter=DbExporter(db, tenant_id=1), node_id=node)
|
|
86
|
+
with tracer.trace(f"request-{node}", session_id=node, tenant_id=1) as trace:
|
|
87
|
+
with trace.span("work") as span:
|
|
88
|
+
span.log(f"log-{node}")
|
|
89
|
+
tracer.close()
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
failures.append(exc)
|
|
92
|
+
threads = [threading.Thread(target=write, args=(i,)) for i in range(4)]
|
|
93
|
+
for thread in threads:
|
|
94
|
+
thread.start()
|
|
95
|
+
for thread in threads:
|
|
96
|
+
thread.join()
|
|
97
|
+
self.assertFalse(failures)
|
|
98
|
+
self.assertEqual(db.list_spans()["total"], 4)
|
|
99
|
+
|
|
100
|
+
def test_validation_and_no_silent_vector_claim(self):
|
|
101
|
+
with SQLiteTraceStore.open(":memory:", tenant_id=1, initialize=True) as db:
|
|
102
|
+
event = _events()[0]
|
|
103
|
+
with self.assertRaises(ValueError):
|
|
104
|
+
db.ingest([{**event, "event_id": 42}])
|
|
105
|
+
with self.assertRaises(ValueError):
|
|
106
|
+
db.ingest([{**event, "ext_span_id": None}])
|
|
107
|
+
self.assertEqual(db.list_spans()["total"], 0)
|
|
108
|
+
with self.assertRaises(ValueError):
|
|
109
|
+
connect(sqlite_path=":memory:", duckdb_path=":memory:", tenant_id=1)
|
|
110
|
+
with self.assertRaises(ValueError):
|
|
111
|
+
connect(sqlite_path=":memory:", tenant_id=None)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class DriverRoutingTests(unittest.TestCase):
|
|
115
|
+
def test_postgresql_dml_through_dbapi_compatibility_layer(self):
|
|
116
|
+
# SQLite executes equivalent SQL here; real server smoke is separate.
|
|
117
|
+
class Cursor:
|
|
118
|
+
def __init__(self, inner):
|
|
119
|
+
self.inner = inner
|
|
120
|
+
def execute(self, sql, params=()):
|
|
121
|
+
sql = sql.replace("%s", "?").replace(" FOR UPDATE", "")
|
|
122
|
+
return self.inner.execute(sql, params)
|
|
123
|
+
def fetchone(self):
|
|
124
|
+
return self.inner.fetchone()
|
|
125
|
+
def fetchall(self):
|
|
126
|
+
return self.inner.fetchall()
|
|
127
|
+
def close(self):
|
|
128
|
+
self.inner.close()
|
|
129
|
+
class Connection:
|
|
130
|
+
def __init__(self, inner):
|
|
131
|
+
self.inner = inner
|
|
132
|
+
def cursor(self):
|
|
133
|
+
return Cursor(self.inner.cursor())
|
|
134
|
+
def commit(self):
|
|
135
|
+
self.inner.commit()
|
|
136
|
+
def rollback(self):
|
|
137
|
+
self.inner.rollback()
|
|
138
|
+
def close(self):
|
|
139
|
+
self.inner.close()
|
|
140
|
+
raw = sqlite3.connect(":memory:")
|
|
141
|
+
SQLiteTraceStore(raw, tenant_id=1).initialize()
|
|
142
|
+
wrapped = Connection(raw)
|
|
143
|
+
with PostgreSQLTraceStore.open(
|
|
144
|
+
"dbname=unused", tenant_id=1,
|
|
145
|
+
connection_factory=lambda _: wrapped,
|
|
146
|
+
) as db:
|
|
147
|
+
self.assertEqual(db.ingest(_events()), {"ingested": 3})
|
|
148
|
+
self.assertEqual(db.ingest(_events()), {"ingested": 0})
|
|
149
|
+
self.assertEqual(db.list_trace_ids()["items"], ["trace-1"])
|
|
150
|
+
self.assertEqual(len(db.search(text="盗刷", filter={"project_id": "demo"})), 1)
|
|
151
|
+
|
|
152
|
+
def test_postgresql_driver_routing_without_connecting(self):
|
|
153
|
+
class Connection:
|
|
154
|
+
def __init__(self):
|
|
155
|
+
self.closed = False
|
|
156
|
+
def close(self):
|
|
157
|
+
self.closed = True
|
|
158
|
+
pg = Connection()
|
|
159
|
+
captured = []
|
|
160
|
+
with PostgreSQLTraceStore.open("dbname=trace", tenant_id=1,
|
|
161
|
+
connection_factory=lambda dsn: (captured.append(dsn), pg)[1]) as db:
|
|
162
|
+
self.assertEqual(db.capabilities()["backend"], "postgresql")
|
|
163
|
+
self.assertTrue(pg.closed)
|
|
164
|
+
self.assertEqual(captured[0], "dbname=trace")
|
|
165
|
+
|
|
166
|
+
def test_duckdb_round_trip_if_installed(self):
|
|
167
|
+
try:
|
|
168
|
+
import duckdb # noqa: F401
|
|
169
|
+
except ImportError:
|
|
170
|
+
self.skipTest("duckdb optional driver is not installed")
|
|
171
|
+
with tempfile.TemporaryDirectory() as temp:
|
|
172
|
+
path = Path(temp) / "trace.duckdb"
|
|
173
|
+
with connect(duckdb_path=path, tenant_id=1, initialize=True) as db:
|
|
174
|
+
self.assertEqual(db.ingest(_events()[1:]), {"ingested": 2})
|
|
175
|
+
self.assertEqual(db.ingest(_events()), {"ingested": 1})
|
|
176
|
+
self.assertEqual(db.ingest(_events()), {"ingested": 0})
|
|
177
|
+
self.assertEqual(len(db.search(text="盗刷", filter={"externalSessionId": 42})), 1)
|
|
178
|
+
self.assertEqual(db.span("trace-1", "span-1")["event_count"], 3)
|
|
179
|
+
with self.assertRaises(ValueError):
|
|
180
|
+
db.ingest([{**_events()[0], "span_id": "wrong-span"}])
|
|
181
|
+
self.assertIsNone(db.span("trace-1", "wrong-span"))
|
|
182
|
+
with DuckDBTraceStore.open(path, tenant_id=1) as db:
|
|
183
|
+
self.assertEqual(db.list_trace_ids()["items"], ["trace-1"])
|
|
184
|
+
self.assertEqual(db.span("trace-1", "span-1")["logs"], ["疑似盗刷"])
|
|
185
|
+
with DuckDBTraceStore.open(path, tenant_id=2) as other:
|
|
186
|
+
self.assertEqual(other.trace("trace-1"), [])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
unittest.main()
|