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 ADDED
@@ -0,0 +1,6 @@
1
+ from .core.tracer import Tracer
2
+ from .core.cost_calculator import calculate_cost
3
+ from .models.trace import Trace
4
+ from .models.span import Span, SpanType
5
+
6
+ __all__ = ["Tracer", "calculate_cost", "Trace", "Span", "SpanType"]
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)
@@ -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}
@@ -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