tracecast 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tracecast/__init__.py +6 -0
- tracecast/core/__init__.py +0 -0
- tracecast/core/cost_calculator.py +55 -0
- tracecast/core/logger.py +103 -0
- tracecast/core/token_counter.py +39 -0
- tracecast/core/tracer.py +106 -0
- tracecast/exporters/__init__.py +15 -0
- tracecast/exporters/base.py +15 -0
- tracecast/exporters/dict_exporter.py +35 -0
- tracecast/exporters/json_file.py +37 -0
- tracecast/exporters/mongo.py +34 -0
- tracecast/exporters/postgres.py +164 -0
- tracecast/integrations/__init__.py +0 -0
- tracecast/integrations/langchain.py +162 -0
- tracecast/models/__init__.py +0 -0
- tracecast/models/span.py +51 -0
- tracecast/models/trace.py +63 -0
- tracecast-0.1.0.dist-info/METADATA +696 -0
- tracecast-0.1.0.dist-info/RECORD +22 -0
- tracecast-0.1.0.dist-info/WHEEL +5 -0
- tracecast-0.1.0.dist-info/licenses/LICENSE +21 -0
- tracecast-0.1.0.dist-info/top_level.txt +1 -0
tracecast/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from typing import Optional, Dict
|
|
2
|
+
|
|
3
|
+
PRICE_TABLE: Dict[str, Dict[str, float]] = {
|
|
4
|
+
"gpt-5": {"input": 0.00125, "output": 0.01000},
|
|
5
|
+
"gpt-5.4": {"input": 0.00250, "output": 0.01500},
|
|
6
|
+
"gpt-4o": {"input": 0.00250, "output": 0.01000},
|
|
7
|
+
"gpt-4o-mini": {"input": 0.00015, "output": 0.00060},
|
|
8
|
+
"gpt-4-turbo": {"input": 0.01000, "output": 0.03000},
|
|
9
|
+
"gpt-4.1": {"input": 0.00200, "output": 0.00800},
|
|
10
|
+
"gpt-4.1-mini": {"input": 0.00040, "output": 0.00160},
|
|
11
|
+
"o3": {"input": 0.00200, "output": 0.00800},
|
|
12
|
+
"o4-mini": {"input": 0.00110, "output": 0.00440},
|
|
13
|
+
|
|
14
|
+
"claude-opus-4-6": {"input": 0.00500, "output": 0.02500},
|
|
15
|
+
"claude-sonnet-4-6": {"input": 0.00300, "output": 0.01500},
|
|
16
|
+
"claude-haiku-4-5": {"input": 0.00100, "output": 0.00500},
|
|
17
|
+
"claude-opus-4-5": {"input": 0.00500, "output": 0.02500},
|
|
18
|
+
"claude-sonnet-4-5": {"input": 0.00300, "output": 0.01500},
|
|
19
|
+
"claude-opus-4": {"input": 0.01500, "output": 0.07500},
|
|
20
|
+
"claude-sonnet-4": {"input": 0.00300, "output": 0.01500},
|
|
21
|
+
"claude-haiku-3-5": {"input": 0.00080, "output": 0.00400},
|
|
22
|
+
|
|
23
|
+
"gemini-3.1-pro": {"input": 0.00200, "output": 0.01200},
|
|
24
|
+
"gemini-3-flash": {"input": 0.00050, "output": 0.00300},
|
|
25
|
+
"gemini-2.5-flash": {"input": 0.00030, "output": 0.00250},
|
|
26
|
+
"gemini-2.5-pro": {"input": 0.00125, "output": 0.01000},
|
|
27
|
+
"gemini-2.0-flash": {"input": 0.00010, "output": 0.00040},
|
|
28
|
+
|
|
29
|
+
"llama-3.3-70b-versatile": {"input": 0.00059, "output": 0.00079},
|
|
30
|
+
"meta-llama/llama-4-scout-17b-16e-instruct": {"input": 0.00011, "output": 0.00034},
|
|
31
|
+
"meta-llama/llama-4-maverick-17b-128e-instruct": {"input": 0.00020, "output": 0.00060},
|
|
32
|
+
"llama-3.3-70b": {"input": 0.00059, "output": 0.00079},
|
|
33
|
+
"llama-4-scout": {"input": 0.00011, "output": 0.00034},
|
|
34
|
+
"llama-4-maverick": {"input": 0.00020, "output": 0.00060},
|
|
35
|
+
|
|
36
|
+
"ollama/*": {"input": 0.0, "output": 0.0},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def calculate_cost(
|
|
41
|
+
model: str,
|
|
42
|
+
tokens_in: int,
|
|
43
|
+
tokens_out: int,
|
|
44
|
+
custom_prices: Optional[Dict] = None,
|
|
45
|
+
) -> float:
|
|
46
|
+
prices = {**PRICE_TABLE, **custom_prices} if custom_prices else PRICE_TABLE
|
|
47
|
+
table = prices.get(model) or _prefix_match(model, prices)
|
|
48
|
+
if not table:
|
|
49
|
+
return 0.0
|
|
50
|
+
return (tokens_in / 1000 * table["input"]) + (tokens_out / 1000 * table["output"])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _prefix_match(model: str, prices: Dict) -> Optional[Dict]:
|
|
54
|
+
provider = model.split("/")[0] + "/*"
|
|
55
|
+
return prices.get(provider)
|
tracecast/core/logger.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
_logger = logging.getLogger("tracecast")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _inline(text: str, max_chars: int = 50) -> str:
|
|
9
|
+
normalized = re.sub(r"[\r\n\t]+", " ", text)
|
|
10
|
+
normalized = re.sub(r" {2,}", " ", normalized).strip()
|
|
11
|
+
if len(normalized) > max_chars:
|
|
12
|
+
normalized = normalized[:max_chars] + "..."
|
|
13
|
+
return normalized
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TraceCastLogger:
|
|
17
|
+
def __init__(self, prefix: Optional[str] = None):
|
|
18
|
+
self._prefix = prefix
|
|
19
|
+
|
|
20
|
+
def _fmt(self, prefix: str, msg: str) -> str:
|
|
21
|
+
return f"[{prefix}] {msg}"
|
|
22
|
+
|
|
23
|
+
def trace_start(self, trace_name: str) -> None:
|
|
24
|
+
prefix = self._prefix or trace_name
|
|
25
|
+
_logger.info(self._fmt(prefix, "Trace started"))
|
|
26
|
+
|
|
27
|
+
def trace_end(
|
|
28
|
+
self,
|
|
29
|
+
trace_name: str,
|
|
30
|
+
*,
|
|
31
|
+
total_tokens: int,
|
|
32
|
+
cost_usd: float,
|
|
33
|
+
latency_ms: Optional[int],
|
|
34
|
+
tools_used: dict,
|
|
35
|
+
) -> None:
|
|
36
|
+
prefix = self._prefix or trace_name
|
|
37
|
+
latency_str = f"{latency_ms / 1000:.2f}s" if latency_ms is not None else "n/a"
|
|
38
|
+
tools_str = ""
|
|
39
|
+
if tools_used:
|
|
40
|
+
parts = [f"{k}×{v}" for k, v in tools_used.items()]
|
|
41
|
+
tools_str = " | tools: " + ", ".join(parts)
|
|
42
|
+
_logger.info(
|
|
43
|
+
self._fmt(
|
|
44
|
+
prefix,
|
|
45
|
+
f"Trace finished → total: {total_tokens} tokens"
|
|
46
|
+
f" | ${cost_usd:.4f}"
|
|
47
|
+
f" | {latency_str}"
|
|
48
|
+
f"{tools_str}",
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def llm_start(self, trace_name: str, *, model: str) -> None:
|
|
53
|
+
prefix = self._prefix or trace_name
|
|
54
|
+
_logger.info(self._fmt(prefix, f"LLM started → {model}"))
|
|
55
|
+
|
|
56
|
+
def llm_end(
|
|
57
|
+
self,
|
|
58
|
+
trace_name: str,
|
|
59
|
+
*,
|
|
60
|
+
model: str,
|
|
61
|
+
tokens_in: int,
|
|
62
|
+
tokens_out: int,
|
|
63
|
+
cost_usd: float,
|
|
64
|
+
latency_ms: Optional[float],
|
|
65
|
+
) -> None:
|
|
66
|
+
prefix = self._prefix or trace_name
|
|
67
|
+
latency_str = f"{latency_ms / 1000:.2f}s" if latency_ms is not None else "n/a"
|
|
68
|
+
_logger.info(
|
|
69
|
+
self._fmt(
|
|
70
|
+
prefix,
|
|
71
|
+
f"LLM end → {model}"
|
|
72
|
+
f" | tokens: {tokens_in} in / {tokens_out} out"
|
|
73
|
+
f" | ${cost_usd:.4f}"
|
|
74
|
+
f" | {latency_str}",
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def llm_error(self, trace_name: str, *, model: str, error: str) -> None:
|
|
79
|
+
prefix = self._prefix or trace_name
|
|
80
|
+
_logger.warning(self._fmt(prefix, f"LLM error → {model} | ⚠ {_inline(error)}"))
|
|
81
|
+
|
|
82
|
+
def tool_start(self, trace_name: str, *, name: str, input_str: str) -> None:
|
|
83
|
+
prefix = self._prefix or trace_name
|
|
84
|
+
_logger.info(self._fmt(prefix, f"Tool call → {name} | {_inline(input_str, 80)}"))
|
|
85
|
+
|
|
86
|
+
def tool_end(
|
|
87
|
+
self, trace_name: str, *, name: str, latency_ms: Optional[float]
|
|
88
|
+
) -> None:
|
|
89
|
+
prefix = self._prefix or trace_name
|
|
90
|
+
latency_str = f"{latency_ms / 1000:.2f}s" if latency_ms is not None else "n/a"
|
|
91
|
+
_logger.info(self._fmt(prefix, f"Tool end → {name} | {latency_str}"))
|
|
92
|
+
|
|
93
|
+
def tool_error(self, trace_name: str, *, name: str, error: str) -> None:
|
|
94
|
+
prefix = self._prefix or trace_name
|
|
95
|
+
_logger.warning(self._fmt(prefix, f"Tool error → {name} | ⚠ {_inline(error)}"))
|
|
96
|
+
|
|
97
|
+
def chain_start(self, trace_name: str, *, name: str) -> None:
|
|
98
|
+
prefix = self._prefix or trace_name
|
|
99
|
+
_logger.info(self._fmt(prefix, f"Chain → {name}"))
|
|
100
|
+
|
|
101
|
+
def chain_error(self, trace_name: str, *, name: str, error: str) -> None:
|
|
102
|
+
prefix = self._prefix or trace_name
|
|
103
|
+
_logger.warning(self._fmt(prefix, f"Chain error → {name} | ⚠ {_inline(error)}"))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def extract_tokens(response: Any, provider: str) -> dict:
|
|
5
|
+
extractors = {
|
|
6
|
+
"openai": _from_openai,
|
|
7
|
+
"anthropic": _from_anthropic,
|
|
8
|
+
"langchain": _from_langchain_response,
|
|
9
|
+
}
|
|
10
|
+
fn = extractors.get(provider, _fallback)
|
|
11
|
+
return fn(response)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _from_openai(r) -> dict:
|
|
15
|
+
usage = getattr(r, "usage", None) or {}
|
|
16
|
+
return {
|
|
17
|
+
"input": getattr(usage, "prompt_tokens", 0),
|
|
18
|
+
"output": getattr(usage, "completion_tokens", 0),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _from_anthropic(r) -> dict:
|
|
23
|
+
usage = getattr(r, "usage", None) or {}
|
|
24
|
+
return {
|
|
25
|
+
"input": getattr(usage, "input_tokens", 0),
|
|
26
|
+
"output": getattr(usage, "output_tokens", 0),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _from_langchain_response(r) -> dict:
|
|
31
|
+
usage = r.get("token_usage") or r.get("usage", {})
|
|
32
|
+
return {
|
|
33
|
+
"input": usage.get("prompt_tokens") or usage.get("input_tokens", 0),
|
|
34
|
+
"output": usage.get("completion_tokens") or usage.get("output_tokens", 0),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fallback(r) -> dict:
|
|
39
|
+
return {"input": 0, "output": 0}
|
tracecast/core/tracer.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from contextvars import ContextVar
|
|
3
|
+
from contextlib import contextmanager, asynccontextmanager
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
|
|
7
|
+
from ..models.trace import Trace
|
|
8
|
+
from ..exporters.base import BaseExporter
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_current_trace: ContextVar[Optional[Trace]] = ContextVar("_current_trace", default=None)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Tracer:
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
exporters: Optional[List[BaseExporter]] = None,
|
|
19
|
+
logging: bool = False,
|
|
20
|
+
log_prefix: Optional[str] = None,
|
|
21
|
+
):
|
|
22
|
+
self.exporters = exporters or []
|
|
23
|
+
self._tc_logger = None
|
|
24
|
+
if logging:
|
|
25
|
+
from .logger import TraceCastLogger
|
|
26
|
+
self._tc_logger = TraceCastLogger(prefix=log_prefix)
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def trace(self, name: str, session_id=None, user_id=None, project_id=None, metadata=None):
|
|
30
|
+
t = Trace(
|
|
31
|
+
trace_id=str(uuid.uuid4()),
|
|
32
|
+
name=name,
|
|
33
|
+
session_id=session_id,
|
|
34
|
+
user_id=user_id,
|
|
35
|
+
project_id=project_id,
|
|
36
|
+
started_at=datetime.now(timezone.utc),
|
|
37
|
+
metadata=metadata or {},
|
|
38
|
+
)
|
|
39
|
+
if self._tc_logger:
|
|
40
|
+
self._tc_logger.trace_start(name)
|
|
41
|
+
token = _current_trace.set(t)
|
|
42
|
+
try:
|
|
43
|
+
yield t
|
|
44
|
+
finally:
|
|
45
|
+
t.finished_at = datetime.now(timezone.utc)
|
|
46
|
+
t._finalize()
|
|
47
|
+
_current_trace.reset(token)
|
|
48
|
+
if self._tc_logger:
|
|
49
|
+
self._tc_logger.trace_end(
|
|
50
|
+
name,
|
|
51
|
+
total_tokens=t.total_tokens,
|
|
52
|
+
cost_usd=t.cost_usd,
|
|
53
|
+
latency_ms=t.latency_ms,
|
|
54
|
+
tools_used=t.tools_used,
|
|
55
|
+
)
|
|
56
|
+
self._export(t)
|
|
57
|
+
|
|
58
|
+
@asynccontextmanager
|
|
59
|
+
async def atrace(self, name: str, session_id=None, user_id=None, project_id=None, metadata=None):
|
|
60
|
+
t = Trace(
|
|
61
|
+
trace_id=str(uuid.uuid4()),
|
|
62
|
+
name=name,
|
|
63
|
+
session_id=session_id,
|
|
64
|
+
user_id=user_id,
|
|
65
|
+
project_id=project_id,
|
|
66
|
+
started_at=datetime.now(timezone.utc),
|
|
67
|
+
metadata=metadata or {},
|
|
68
|
+
)
|
|
69
|
+
if self._tc_logger:
|
|
70
|
+
self._tc_logger.trace_start(name)
|
|
71
|
+
token = _current_trace.set(t)
|
|
72
|
+
try:
|
|
73
|
+
yield t
|
|
74
|
+
finally:
|
|
75
|
+
t.finished_at = datetime.now(timezone.utc)
|
|
76
|
+
t._finalize()
|
|
77
|
+
_current_trace.reset(token)
|
|
78
|
+
if self._tc_logger:
|
|
79
|
+
self._tc_logger.trace_end(
|
|
80
|
+
name,
|
|
81
|
+
total_tokens=t.total_tokens,
|
|
82
|
+
cost_usd=t.cost_usd,
|
|
83
|
+
latency_ms=t.latency_ms,
|
|
84
|
+
tools_used=t.tools_used,
|
|
85
|
+
)
|
|
86
|
+
await self._aexport(t)
|
|
87
|
+
|
|
88
|
+
def _export(self, trace: Trace) -> None:
|
|
89
|
+
for exporter in self.exporters:
|
|
90
|
+
try:
|
|
91
|
+
exporter.export(trace)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
import warnings
|
|
94
|
+
warnings.warn(f"TraceCast: exporter {type(exporter).__name__} failed: {exc}", stacklevel=2)
|
|
95
|
+
|
|
96
|
+
async def _aexport(self, trace: Trace) -> None:
|
|
97
|
+
for exporter in self.exporters:
|
|
98
|
+
try:
|
|
99
|
+
await exporter.aexport(trace)
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
import warnings
|
|
102
|
+
warnings.warn(f"TraceCast: exporter {type(exporter).__name__} failed: {exc}", stacklevel=2)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def current() -> Optional[Trace]:
|
|
106
|
+
return _current_trace.get()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .base import BaseExporter
|
|
2
|
+
from .json_file import JsonFileExporter
|
|
3
|
+
from .dict_exporter import DictExporter
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from .mongo import MongoExporter
|
|
7
|
+
except ImportError:
|
|
8
|
+
MongoExporter = None
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from .postgres import PostgresExporter
|
|
12
|
+
except ImportError:
|
|
13
|
+
PostgresExporter = None
|
|
14
|
+
|
|
15
|
+
__all__ = ["BaseExporter", "JsonFileExporter", "DictExporter", "MongoExporter", "PostgresExporter"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from ..models.trace import Trace
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BaseExporter(ABC):
|
|
6
|
+
@abstractmethod
|
|
7
|
+
def export(self, trace: Trace) -> None:
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
def export_batch(self, traces: list) -> None:
|
|
11
|
+
for trace in traces:
|
|
12
|
+
self.export(trace)
|
|
13
|
+
|
|
14
|
+
async def aexport(self, trace: Trace) -> None:
|
|
15
|
+
self.export(trace)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Set
|
|
2
|
+
from .base import BaseExporter
|
|
3
|
+
from ..models.trace import Trace
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _filter_dict(doc: dict, include: Optional[Set[str]], exclude: Optional[Set[str]]) -> dict:
|
|
7
|
+
if include is not None:
|
|
8
|
+
return {k: v for k, v in doc.items() if k in include}
|
|
9
|
+
if exclude is not None:
|
|
10
|
+
return {k: v for k, v in doc.items() if k not in exclude}
|
|
11
|
+
return doc
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DictExporter(BaseExporter):
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
on_trace: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
19
|
+
include_fields: Optional[Iterable[str]] = None,
|
|
20
|
+
exclude_fields: Optional[Iterable[str]] = None,
|
|
21
|
+
):
|
|
22
|
+
self._on_trace = on_trace
|
|
23
|
+
self._include: Optional[Set[str]] = set(include_fields) if include_fields is not None else None
|
|
24
|
+
self._exclude: Optional[Set[str]] = set(exclude_fields) if exclude_fields is not None else None
|
|
25
|
+
self.traces: List[Dict[str, Any]] = []
|
|
26
|
+
|
|
27
|
+
def export(self, trace: Trace) -> None:
|
|
28
|
+
doc = _filter_dict(trace.to_dict(), self._include, self._exclude)
|
|
29
|
+
if self._on_trace is not None:
|
|
30
|
+
self._on_trace(doc)
|
|
31
|
+
else:
|
|
32
|
+
self.traces.append(doc)
|
|
33
|
+
|
|
34
|
+
def clear(self) -> None:
|
|
35
|
+
self.traces.clear()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import asyncio
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterable, Optional, Set
|
|
5
|
+
from .base import BaseExporter
|
|
6
|
+
from ..models.trace import Trace
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _filter_dict(doc: dict, include: Optional[Set[str]], exclude: Optional[Set[str]]) -> dict:
|
|
10
|
+
if include is not None:
|
|
11
|
+
return {k: v for k, v in doc.items() if k in include}
|
|
12
|
+
if exclude is not None:
|
|
13
|
+
return {k: v for k, v in doc.items() if k not in exclude}
|
|
14
|
+
return doc
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JsonFileExporter(BaseExporter):
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
path: str = "./traces.jsonl",
|
|
22
|
+
include_fields: Optional[Iterable[str]] = None,
|
|
23
|
+
exclude_fields: Optional[Iterable[str]] = None,
|
|
24
|
+
):
|
|
25
|
+
self.path = Path(path)
|
|
26
|
+
if self.path.parent != Path("."):
|
|
27
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
self._include: Optional[Set[str]] = set(include_fields) if include_fields is not None else None
|
|
29
|
+
self._exclude: Optional[Set[str]] = set(exclude_fields) if exclude_fields is not None else None
|
|
30
|
+
|
|
31
|
+
def export(self, trace: Trace) -> None:
|
|
32
|
+
doc = _filter_dict(trace.to_dict(), self._include, self._exclude)
|
|
33
|
+
with self.path.open("a", encoding="utf-8") as f:
|
|
34
|
+
f.write(json.dumps(doc, default=str) + "\n")
|
|
35
|
+
|
|
36
|
+
async def aexport(self, trace: Trace) -> None:
|
|
37
|
+
await asyncio.to_thread(self.export, trace)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Iterable, Optional, Set
|
|
3
|
+
from pymongo import MongoClient
|
|
4
|
+
from .base import BaseExporter
|
|
5
|
+
from ..models.trace import Trace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _filter_doc(doc: dict, include: Optional[Set[str]], exclude: Optional[Set[str]]) -> dict:
|
|
9
|
+
if include is not None:
|
|
10
|
+
return {k: v for k, v in doc.items() if k in include}
|
|
11
|
+
if exclude is not None:
|
|
12
|
+
return {k: v for k, v in doc.items() if k not in exclude}
|
|
13
|
+
return doc
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MongoExporter(BaseExporter):
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
uri: str,
|
|
21
|
+
db: str = "tracecast",
|
|
22
|
+
collection: str = "traces",
|
|
23
|
+
include_fields: Optional[Iterable[str]] = None,
|
|
24
|
+
exclude_fields: Optional[Iterable[str]] = None,
|
|
25
|
+
):
|
|
26
|
+
self.col = MongoClient(uri)[db][collection]
|
|
27
|
+
self._include: Optional[Set[str]] = set(include_fields) if include_fields is not None else None
|
|
28
|
+
self._exclude: Optional[Set[str]] = set(exclude_fields) if exclude_fields is not None else None
|
|
29
|
+
|
|
30
|
+
def export(self, trace: Trace) -> None:
|
|
31
|
+
doc = trace.to_dict()
|
|
32
|
+
doc["exported_at"] = datetime.now(timezone.utc)
|
|
33
|
+
doc = _filter_doc(doc, self._include, self._exclude)
|
|
34
|
+
self.col.insert_one(doc)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Any, Dict, Iterable, List, Optional, Set
|
|
4
|
+
from .base import BaseExporter
|
|
5
|
+
from ..models.trace import Trace
|
|
6
|
+
|
|
7
|
+
_ALL_COLUMNS: List[str] = [
|
|
8
|
+
"trace_id",
|
|
9
|
+
"name",
|
|
10
|
+
"session_id",
|
|
11
|
+
"user_id",
|
|
12
|
+
"project_id",
|
|
13
|
+
"model",
|
|
14
|
+
"total_tokens_in",
|
|
15
|
+
"total_tokens_out",
|
|
16
|
+
"total_tokens",
|
|
17
|
+
"cost_usd",
|
|
18
|
+
"latency_ms",
|
|
19
|
+
"tools_used",
|
|
20
|
+
"spans",
|
|
21
|
+
"metadata",
|
|
22
|
+
"started_at",
|
|
23
|
+
"finished_at",
|
|
24
|
+
"exported_at",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
_COLUMN_DEFS: Dict[str, str] = {
|
|
28
|
+
"trace_id": "TEXT NOT NULL",
|
|
29
|
+
"name": "TEXT NOT NULL",
|
|
30
|
+
"session_id": "TEXT",
|
|
31
|
+
"user_id": "TEXT",
|
|
32
|
+
"project_id": "TEXT",
|
|
33
|
+
"model": "TEXT",
|
|
34
|
+
"total_tokens_in": "INTEGER DEFAULT 0",
|
|
35
|
+
"total_tokens_out": "INTEGER DEFAULT 0",
|
|
36
|
+
"total_tokens": "INTEGER DEFAULT 0",
|
|
37
|
+
"cost_usd": "DOUBLE PRECISION DEFAULT 0",
|
|
38
|
+
"latency_ms": "INTEGER",
|
|
39
|
+
"tools_used": "JSONB DEFAULT '{}'",
|
|
40
|
+
"spans": "JSONB DEFAULT '[]'",
|
|
41
|
+
"metadata": "JSONB DEFAULT '{}'",
|
|
42
|
+
"started_at": "TIMESTAMPTZ NOT NULL",
|
|
43
|
+
"finished_at": "TIMESTAMPTZ",
|
|
44
|
+
"exported_at": "TIMESTAMPTZ NOT NULL",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_REQUIRED_COLUMNS: Set[str] = {"trace_id", "started_at", "exported_at"}
|
|
48
|
+
|
|
49
|
+
_JSONB_COLUMNS: Set[str] = {"tools_used", "spans", "metadata"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _build_create_sql(table: str, columns: List[str]) -> str:
|
|
53
|
+
col_parts = ["id BIGSERIAL PRIMARY KEY"]
|
|
54
|
+
for col in columns:
|
|
55
|
+
col_parts.append(f" {col} {_COLUMN_DEFS[col]}")
|
|
56
|
+
if "trace_id" in columns:
|
|
57
|
+
col_parts.append(f" CONSTRAINT {table}_trace_id_unique UNIQUE (trace_id)")
|
|
58
|
+
return f"CREATE TABLE IF NOT EXISTS {table} (\n" + ",\n".join(col_parts) + "\n);"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _build_insert_sql(table: str, columns: List[str]) -> str:
|
|
62
|
+
update_cols = [c for c in columns if c not in ("trace_id", "started_at")]
|
|
63
|
+
placeholders = ", ".join(f"%({c})s" for c in columns)
|
|
64
|
+
col_list = ", ".join(columns)
|
|
65
|
+
set_clause = ",\n ".join(f"{c} = EXCLUDED.{c}" for c in update_cols)
|
|
66
|
+
return (
|
|
67
|
+
f"INSERT INTO {table} ({col_list})\n"
|
|
68
|
+
f"VALUES ({placeholders})\n"
|
|
69
|
+
f"ON CONFLICT (trace_id) DO UPDATE SET\n {set_clause};"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PostgresExporter(BaseExporter):
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
dsn: str,
|
|
78
|
+
table: str = "traces",
|
|
79
|
+
autocommit: bool = True,
|
|
80
|
+
include_fields: Optional[Iterable[str]] = None,
|
|
81
|
+
exclude_fields: Optional[Iterable[str]] = None,
|
|
82
|
+
):
|
|
83
|
+
try:
|
|
84
|
+
import psycopg2
|
|
85
|
+
import psycopg2.extras
|
|
86
|
+
except ImportError as exc:
|
|
87
|
+
raise ImportError(
|
|
88
|
+
"psycopg2 is required for PostgresExporter. "
|
|
89
|
+
"Install it with: pip install psycopg2-binary"
|
|
90
|
+
) from exc
|
|
91
|
+
|
|
92
|
+
self._psycopg2 = psycopg2
|
|
93
|
+
self._extras = psycopg2.extras
|
|
94
|
+
self._dsn = dsn
|
|
95
|
+
self._table = table
|
|
96
|
+
self._autocommit = autocommit
|
|
97
|
+
self._conn = None
|
|
98
|
+
|
|
99
|
+
if include_fields is not None:
|
|
100
|
+
chosen = set(include_fields) | _REQUIRED_COLUMNS
|
|
101
|
+
self._columns = [c for c in _ALL_COLUMNS if c in chosen]
|
|
102
|
+
elif exclude_fields is not None:
|
|
103
|
+
excluded = set(exclude_fields) - _REQUIRED_COLUMNS
|
|
104
|
+
self._columns = [c for c in _ALL_COLUMNS if c not in excluded]
|
|
105
|
+
else:
|
|
106
|
+
self._columns = list(_ALL_COLUMNS)
|
|
107
|
+
|
|
108
|
+
self._create_sql = _build_create_sql(self._table, self._columns)
|
|
109
|
+
self._insert_sql = _build_insert_sql(self._table, self._columns)
|
|
110
|
+
self._ensure_table()
|
|
111
|
+
|
|
112
|
+
def _get_conn(self):
|
|
113
|
+
if self._conn is None or self._conn.closed:
|
|
114
|
+
self._conn = self._psycopg2.connect(self._dsn)
|
|
115
|
+
self._conn.autocommit = self._autocommit
|
|
116
|
+
return self._conn
|
|
117
|
+
|
|
118
|
+
def _ensure_table(self) -> None:
|
|
119
|
+
conn = self._get_conn()
|
|
120
|
+
with conn.cursor() as cur:
|
|
121
|
+
cur.execute(self._create_sql)
|
|
122
|
+
if not self._autocommit:
|
|
123
|
+
conn.commit()
|
|
124
|
+
|
|
125
|
+
def _build_row(self, doc: dict) -> dict:
|
|
126
|
+
full_row: Dict[str, Any] = {
|
|
127
|
+
"trace_id": doc["trace_id"],
|
|
128
|
+
"name": doc["name"],
|
|
129
|
+
"session_id": doc.get("session_id"),
|
|
130
|
+
"user_id": doc.get("user_id"),
|
|
131
|
+
"project_id": doc.get("project_id"),
|
|
132
|
+
"model": doc.get("model"),
|
|
133
|
+
"total_tokens_in": doc["total_tokens_in"],
|
|
134
|
+
"total_tokens_out": doc["total_tokens_out"],
|
|
135
|
+
"total_tokens": doc["total_tokens"],
|
|
136
|
+
"cost_usd": doc["cost_usd"],
|
|
137
|
+
"latency_ms": doc.get("latency_ms"),
|
|
138
|
+
"tools_used": self._extras.Json(doc["tools_used"]),
|
|
139
|
+
"spans": self._extras.Json(doc["spans"]),
|
|
140
|
+
"metadata": self._extras.Json(doc["metadata"]),
|
|
141
|
+
"started_at": doc["started_at"],
|
|
142
|
+
"finished_at": doc.get("finished_at"),
|
|
143
|
+
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
144
|
+
}
|
|
145
|
+
return {k: v for k, v in full_row.items() if k in self._columns}
|
|
146
|
+
|
|
147
|
+
def export(self, trace: Trace) -> None:
|
|
148
|
+
doc = trace.to_dict()
|
|
149
|
+
row = self._build_row(doc)
|
|
150
|
+
conn = self._get_conn()
|
|
151
|
+
with conn.cursor() as cur:
|
|
152
|
+
cur.execute(self._insert_sql, row)
|
|
153
|
+
if not self._autocommit:
|
|
154
|
+
conn.commit()
|
|
155
|
+
|
|
156
|
+
def close(self) -> None:
|
|
157
|
+
if self._conn and not self._conn.closed:
|
|
158
|
+
self._conn.close()
|
|
159
|
+
|
|
160
|
+
def __enter__(self):
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
def __exit__(self, *_):
|
|
164
|
+
self.close()
|
|
File without changes
|