traccia 0.1.2__py3-none-any.whl → 0.1.5__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.
- traccia/__init__.py +73 -0
- traccia/auto.py +736 -0
- traccia/auto_instrumentation.py +74 -0
- traccia/cli.py +349 -0
- traccia/config.py +693 -0
- traccia/context/__init__.py +33 -0
- traccia/context/context.py +67 -0
- traccia/context/propagators.py +283 -0
- traccia/errors.py +48 -0
- traccia/exporter/__init__.py +8 -0
- traccia/exporter/console_exporter.py +31 -0
- traccia/exporter/file_exporter.py +178 -0
- traccia/exporter/http_exporter.py +214 -0
- traccia/exporter/otlp_exporter.py +190 -0
- traccia/instrumentation/__init__.py +20 -0
- traccia/instrumentation/anthropic.py +92 -0
- traccia/instrumentation/decorator.py +263 -0
- traccia/instrumentation/fastapi.py +38 -0
- traccia/instrumentation/http_client.py +21 -0
- traccia/instrumentation/http_server.py +25 -0
- traccia/instrumentation/openai.py +178 -0
- traccia/instrumentation/requests.py +68 -0
- traccia/integrations/__init__.py +22 -0
- traccia/integrations/langchain/__init__.py +14 -0
- traccia/integrations/langchain/callback.py +418 -0
- traccia/integrations/langchain/utils.py +129 -0
- traccia/pricing_config.py +58 -0
- traccia/processors/__init__.py +35 -0
- traccia/processors/agent_enricher.py +159 -0
- traccia/processors/batch_processor.py +140 -0
- traccia/processors/cost_engine.py +71 -0
- traccia/processors/cost_processor.py +70 -0
- traccia/processors/drop_policy.py +44 -0
- traccia/processors/logging_processor.py +31 -0
- traccia/processors/rate_limiter.py +223 -0
- traccia/processors/sampler.py +22 -0
- traccia/processors/token_counter.py +216 -0
- traccia/runtime_config.py +106 -0
- traccia/tracer/__init__.py +15 -0
- traccia/tracer/otel_adapter.py +577 -0
- traccia/tracer/otel_utils.py +24 -0
- traccia/tracer/provider.py +155 -0
- traccia/tracer/span.py +286 -0
- traccia/tracer/span_context.py +16 -0
- traccia/tracer/tracer.py +243 -0
- traccia/utils/__init__.py +19 -0
- traccia/utils/helpers.py +95 -0
- {traccia-0.1.2.dist-info → traccia-0.1.5.dist-info}/METADATA +32 -15
- traccia-0.1.5.dist-info/RECORD +53 -0
- traccia-0.1.5.dist-info/top_level.txt +1 -0
- traccia-0.1.2.dist-info/RECORD +0 -6
- traccia-0.1.2.dist-info/top_level.txt +0 -1
- {traccia-0.1.2.dist-info → traccia-0.1.5.dist-info}/WHEEL +0 -0
- {traccia-0.1.2.dist-info → traccia-0.1.5.dist-info}/entry_points.txt +0 -0
- {traccia-0.1.2.dist-info → traccia-0.1.5.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Span processor that enriches spans with agent metadata and cost."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from traccia.processors.cost_engine import compute_cost
|
|
10
|
+
from traccia.tracer.provider import SpanProcessor
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_agent_catalog(path: Optional[str]) -> Dict[str, Dict[str, Any]]:
|
|
14
|
+
"""
|
|
15
|
+
Load agent metadata from a JSON file.
|
|
16
|
+
Supports:
|
|
17
|
+
{ "agents": [ { "id": "...", "name": "...", ... } ] }
|
|
18
|
+
or { "agent-id": { "name": "...", ... }, ... }
|
|
19
|
+
"""
|
|
20
|
+
if not path:
|
|
21
|
+
return {}
|
|
22
|
+
if not os.path.exists(path):
|
|
23
|
+
return {}
|
|
24
|
+
try:
|
|
25
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
26
|
+
data = json.load(f)
|
|
27
|
+
except Exception:
|
|
28
|
+
return {}
|
|
29
|
+
if isinstance(data, dict) and "agents" in data and isinstance(data["agents"], list):
|
|
30
|
+
out = {}
|
|
31
|
+
for agent in data["agents"]:
|
|
32
|
+
if not isinstance(agent, dict):
|
|
33
|
+
continue
|
|
34
|
+
aid = agent.get("id")
|
|
35
|
+
if aid:
|
|
36
|
+
out[str(aid)] = agent
|
|
37
|
+
return out
|
|
38
|
+
if isinstance(data, dict):
|
|
39
|
+
return {str(k): v for k, v in data.items() if isinstance(v, dict)}
|
|
40
|
+
return {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AgentEnrichmentProcessor(SpanProcessor):
|
|
44
|
+
"""
|
|
45
|
+
Enrich spans with agent metadata (id/name/env/owner/team/org) and compute llm.cost.usd if missing.
|
|
46
|
+
Static metadata can come from:
|
|
47
|
+
- span attributes (preferred)
|
|
48
|
+
- environment variables (AGENT_DASHBOARD_AGENT_ID/NAME/ENV/OWNER/TEAM/ORG_ID/SUB_ORG_ID/DESCRIPTION)
|
|
49
|
+
- JSON config file pointed by AGENT_DASHBOARD_AGENT_CONFIG
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
agent_config_path: Optional[str] = None,
|
|
56
|
+
default_agent_id: Optional[str] = None,
|
|
57
|
+
default_env: str = "production",
|
|
58
|
+
) -> None:
|
|
59
|
+
self.default_agent_id = default_agent_id or os.getenv("AGENT_DASHBOARD_AGENT_ID")
|
|
60
|
+
self.default_env = os.getenv("AGENT_DASHBOARD_ENV") or default_env
|
|
61
|
+
self.default_name = os.getenv("AGENT_DASHBOARD_AGENT_NAME")
|
|
62
|
+
self.default_type = os.getenv("AGENT_DASHBOARD_AGENT_TYPE")
|
|
63
|
+
self.default_owner = os.getenv("AGENT_DASHBOARD_AGENT_OWNER")
|
|
64
|
+
self.default_team = os.getenv("AGENT_DASHBOARD_AGENT_TEAM")
|
|
65
|
+
self.default_org = os.getenv("AGENT_DASHBOARD_ORG_ID")
|
|
66
|
+
self.default_sub_org = os.getenv("AGENT_DASHBOARD_SUB_ORG_ID")
|
|
67
|
+
self.default_description = os.getenv("AGENT_DASHBOARD_AGENT_DESCRIPTION")
|
|
68
|
+
cfg_path = (
|
|
69
|
+
agent_config_path
|
|
70
|
+
or os.getenv("AGENT_DASHBOARD_AGENT_CONFIG")
|
|
71
|
+
or "agent_config.json"
|
|
72
|
+
)
|
|
73
|
+
self.catalog = _load_agent_catalog(cfg_path)
|
|
74
|
+
# If only one agent is declared, remember it for convenient fallback.
|
|
75
|
+
self.single_agent_id: Optional[str] = None
|
|
76
|
+
if len(self.catalog) == 1:
|
|
77
|
+
self.single_agent_id = next(iter(self.catalog.keys()))
|
|
78
|
+
|
|
79
|
+
def on_end(self, span) -> None:
|
|
80
|
+
attrs = span.attributes
|
|
81
|
+
# Resolve agent id
|
|
82
|
+
agent_id = (
|
|
83
|
+
attrs.get("agent.id")
|
|
84
|
+
or attrs.get("agent")
|
|
85
|
+
or self.default_agent_id
|
|
86
|
+
)
|
|
87
|
+
# Try using tracer instrumentation scope as a fallback id
|
|
88
|
+
if not agent_id and getattr(span, "tracer", None) is not None:
|
|
89
|
+
agent_id = getattr(span.tracer, "instrumentation_scope", None)
|
|
90
|
+
# If not found in attributes/env/scope, and only one agent exists in catalog, use it
|
|
91
|
+
if not agent_id and self.single_agent_id:
|
|
92
|
+
agent_id = self.single_agent_id
|
|
93
|
+
# If still missing, skip enrichment
|
|
94
|
+
if not agent_id:
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
# Look up static metadata
|
|
98
|
+
meta = self.catalog.get(agent_id, {})
|
|
99
|
+
# If the resolved id is not in catalog but we have a single agent defined, use that entry
|
|
100
|
+
if not meta and self.single_agent_id:
|
|
101
|
+
agent_id = self.single_agent_id
|
|
102
|
+
meta = self.catalog.get(agent_id, {})
|
|
103
|
+
|
|
104
|
+
def set_if_missing(key: str, value: Any) -> None:
|
|
105
|
+
if value is None:
|
|
106
|
+
return
|
|
107
|
+
if key not in attrs or attrs.get(key) in (None, ""):
|
|
108
|
+
attrs[key] = value
|
|
109
|
+
|
|
110
|
+
attrs["agent.id"] = agent_id
|
|
111
|
+
set_if_missing("agent.name", meta.get("name") or self.default_name or agent_id)
|
|
112
|
+
set_if_missing("agent.type", meta.get("type") or self.default_type or "workflow")
|
|
113
|
+
set_if_missing("agent.description", meta.get("description") or self.default_description or "")
|
|
114
|
+
set_if_missing("owner", meta.get("owner") or self.default_owner)
|
|
115
|
+
set_if_missing("team", meta.get("team") or self.default_team)
|
|
116
|
+
set_if_missing("org.id", meta.get("org_id") or self.default_org)
|
|
117
|
+
set_if_missing("sub_org.id", meta.get("sub_org_id") or self.default_sub_org)
|
|
118
|
+
|
|
119
|
+
# Environment
|
|
120
|
+
set_if_missing("env", meta.get("env") or self.default_env)
|
|
121
|
+
set_if_missing("environment", meta.get("env") or self.default_env)
|
|
122
|
+
|
|
123
|
+
# Consumers (store as list)
|
|
124
|
+
consumers = meta.get("consuming_teams")
|
|
125
|
+
if consumers and "agent.consuming_teams" not in attrs:
|
|
126
|
+
attrs["agent.consuming_teams"] = consumers
|
|
127
|
+
|
|
128
|
+
# Cost: fill llm.cost.usd if we have tokens + model
|
|
129
|
+
if "llm.cost.usd" not in attrs:
|
|
130
|
+
model = attrs.get("llm.model")
|
|
131
|
+
prompt_tokens = attrs.get("llm.usage.prompt_tokens") or 0
|
|
132
|
+
completion_tokens = attrs.get("llm.usage.completion_tokens") or 0
|
|
133
|
+
if model and (prompt_tokens or completion_tokens):
|
|
134
|
+
try:
|
|
135
|
+
cost = compute_cost(
|
|
136
|
+
model=model,
|
|
137
|
+
prompt_tokens=int(prompt_tokens or 0),
|
|
138
|
+
completion_tokens=int(completion_tokens or 0),
|
|
139
|
+
)
|
|
140
|
+
if cost is not None:
|
|
141
|
+
attrs["llm.cost.usd"] = cost
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
# Span type inference if missing
|
|
146
|
+
if "span.type" not in attrs and "type" not in attrs:
|
|
147
|
+
span_type = None
|
|
148
|
+
if attrs.get("llm.model"):
|
|
149
|
+
span_type = "LLM"
|
|
150
|
+
elif attrs.get("tool.name") or attrs.get("tool") or attrs.get("http.url"):
|
|
151
|
+
span_type = "TOOL"
|
|
152
|
+
if span_type:
|
|
153
|
+
attrs["span.type"] = span_type
|
|
154
|
+
|
|
155
|
+
def shutdown(self) -> None:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
def force_flush(self, timeout: Optional[float] = None) -> None:
|
|
159
|
+
return
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Batching span processor with bounded queue and background flush."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import Deque, Iterable, List, Optional
|
|
9
|
+
|
|
10
|
+
from traccia.processors.drop_policy import DEFAULT_DROP_POLICY, DropPolicy
|
|
11
|
+
from traccia.processors.sampler import Sampler
|
|
12
|
+
from traccia.tracer.provider import SpanProcessor
|
|
13
|
+
from traccia.tracer.span import Span
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BatchSpanProcessor(SpanProcessor):
|
|
17
|
+
"""
|
|
18
|
+
Batch span processor that queues spans for export.
|
|
19
|
+
|
|
20
|
+
Note: This runs as an enrichment processor (before span.end()),
|
|
21
|
+
but it queues spans and exports them after they end.
|
|
22
|
+
When exporting, it extracts ReadableSpan from the OTel span.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
exporter=None,
|
|
28
|
+
*,
|
|
29
|
+
max_queue_size: int = 5000,
|
|
30
|
+
max_export_batch_size: int = 512,
|
|
31
|
+
schedule_delay_millis: int = 5000,
|
|
32
|
+
drop_policy: Optional[DropPolicy] = None,
|
|
33
|
+
sampler: Optional[Sampler] = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.exporter = exporter
|
|
36
|
+
self.max_queue_size = max_queue_size
|
|
37
|
+
self.max_export_batch_size = max_export_batch_size
|
|
38
|
+
self.schedule_delay = schedule_delay_millis / 1000.0
|
|
39
|
+
self.drop_policy = drop_policy or DEFAULT_DROP_POLICY
|
|
40
|
+
self.sampler = sampler
|
|
41
|
+
|
|
42
|
+
self._queue: Deque[Span] = deque()
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._event = threading.Event()
|
|
45
|
+
self._shutdown = False
|
|
46
|
+
self._worker = threading.Thread(target=self._worker_loop, daemon=True)
|
|
47
|
+
self._worker.start()
|
|
48
|
+
|
|
49
|
+
def on_end(self, span: Span) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Called when a span ends (BEFORE span.end() is called).
|
|
52
|
+
|
|
53
|
+
We queue the span here, but it hasn't ended yet.
|
|
54
|
+
The span will end after enrichment processors run.
|
|
55
|
+
|
|
56
|
+
Note: We mark the span as queued to prevent double-queuing.
|
|
57
|
+
"""
|
|
58
|
+
if self._shutdown:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Head-based sampling is recorded on SpanContext.trace_flags.
|
|
62
|
+
# If a sampler is configured, traces marked as not-sampled (0) are dropped.
|
|
63
|
+
if self.sampler and getattr(span.context, "trace_flags", 1) == 0:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# Prevent double-queuing (span might be queued multiple times if on_end is called multiple times)
|
|
67
|
+
if hasattr(span, '_batch_queued') and span._batch_queued:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
with self._lock:
|
|
71
|
+
enqueued = self.drop_policy.handle(self._queue, span, self.max_queue_size)
|
|
72
|
+
if enqueued:
|
|
73
|
+
span._batch_queued = True # Mark as queued
|
|
74
|
+
self._event.set()
|
|
75
|
+
|
|
76
|
+
def force_flush(self, timeout: Optional[float] = None) -> None:
|
|
77
|
+
"""Force flush any pending spans."""
|
|
78
|
+
deadline = time.time() + timeout if timeout else None
|
|
79
|
+
while True:
|
|
80
|
+
flushed_any = self._flush_once()
|
|
81
|
+
if not flushed_any:
|
|
82
|
+
return
|
|
83
|
+
if deadline and time.time() >= deadline:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
def shutdown(self) -> None:
|
|
87
|
+
"""Shutdown the processor."""
|
|
88
|
+
self._shutdown = True
|
|
89
|
+
self._event.set()
|
|
90
|
+
self._worker.join(timeout=self.schedule_delay * 2)
|
|
91
|
+
self.force_flush()
|
|
92
|
+
|
|
93
|
+
# Internal
|
|
94
|
+
def _worker_loop(self) -> None:
|
|
95
|
+
"""Background worker that periodically flushes spans."""
|
|
96
|
+
while not self._shutdown:
|
|
97
|
+
self._event.wait(timeout=self.schedule_delay)
|
|
98
|
+
self._event.clear()
|
|
99
|
+
self._flush_once()
|
|
100
|
+
|
|
101
|
+
def _flush_once(self) -> bool:
|
|
102
|
+
"""Flush one batch of spans."""
|
|
103
|
+
spans = self._drain_queue(self.max_export_batch_size)
|
|
104
|
+
if not spans:
|
|
105
|
+
return False
|
|
106
|
+
self._export(spans)
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def _drain_queue(self, limit: int) -> List[Span]:
|
|
110
|
+
"""Drain spans from queue up to limit."""
|
|
111
|
+
items: List[Span] = []
|
|
112
|
+
with self._lock:
|
|
113
|
+
while self._queue and len(items) < limit:
|
|
114
|
+
items.append(self._queue.popleft())
|
|
115
|
+
return items
|
|
116
|
+
|
|
117
|
+
def _export(self, spans: Iterable[Span]) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Export spans to exporter.
|
|
120
|
+
|
|
121
|
+
Spans should be ended by the time they're flushed from the queue.
|
|
122
|
+
We filter out any spans that aren't ended yet.
|
|
123
|
+
"""
|
|
124
|
+
if self.exporter is None:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
# Filter to only ended spans
|
|
129
|
+
ended_spans = [span for span in spans if span._ended]
|
|
130
|
+
|
|
131
|
+
if not ended_spans:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# Export spans - exporter will handle conversion if needed
|
|
135
|
+
self.exporter.export(ended_spans)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
# Export errors are swallowed; resilience over strictness.
|
|
138
|
+
import traceback
|
|
139
|
+
traceback.print_exc()
|
|
140
|
+
return
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Cost calculation based on model pricing and token usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
DEFAULT_PRICING: Dict[str, Dict[str, float]] = {
|
|
8
|
+
# prices per 1k tokens
|
|
9
|
+
"gpt-4": {"prompt": 0.03, "completion": 0.06},
|
|
10
|
+
"gpt-4o": {"prompt": 0.005, "completion": 0.015},
|
|
11
|
+
"gpt-3.5-turbo": {"prompt": 0.0015, "completion": 0.002},
|
|
12
|
+
"claude-3-opus": {"prompt": 0.015, "completion": 0.075},
|
|
13
|
+
"claude-3-sonnet": {"prompt": 0.003, "completion": 0.015},
|
|
14
|
+
# Add other models via pricing overrides:
|
|
15
|
+
# - env: AGENT_DASHBOARD_PRICING_JSON='{"model": {"prompt": x, "completion": y}}'
|
|
16
|
+
# - start_tracing(pricing_override={...})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
def _lookup_price(model: str, table: Dict[str, Dict[str, float]]) -> Optional[Tuple[str, Dict[str, float]]]:
|
|
20
|
+
"""
|
|
21
|
+
Return (matched_key, price_dict) for a given model name.
|
|
22
|
+
|
|
23
|
+
Supports exact matches and prefix matches for version-suffixed model names:
|
|
24
|
+
e.g. "claude-3-opus-20240229" -> "claude-3-opus"
|
|
25
|
+
"gpt-4o-2024-08-06" -> "gpt-4o"
|
|
26
|
+
"""
|
|
27
|
+
if not model:
|
|
28
|
+
return None
|
|
29
|
+
m = str(model).strip()
|
|
30
|
+
if not m:
|
|
31
|
+
return None
|
|
32
|
+
# exact (case sensitive + lower)
|
|
33
|
+
if m in table:
|
|
34
|
+
return m, table[m]
|
|
35
|
+
ml = m.lower()
|
|
36
|
+
if ml in table:
|
|
37
|
+
return ml, table[ml]
|
|
38
|
+
# prefix match (longest key wins)
|
|
39
|
+
for key in sorted(table.keys(), key=len, reverse=True):
|
|
40
|
+
if ml.startswith(key.lower()):
|
|
41
|
+
return key, table[key]
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def match_pricing_model_key(
|
|
46
|
+
model: str, pricing_table: Optional[Dict[str, Dict[str, float]]] = None
|
|
47
|
+
) -> Optional[str]:
|
|
48
|
+
"""Return the pricing table key that would be used for `model`, if any."""
|
|
49
|
+
table = pricing_table or DEFAULT_PRICING
|
|
50
|
+
matched = _lookup_price(model, table)
|
|
51
|
+
if not matched:
|
|
52
|
+
return None
|
|
53
|
+
key, _ = matched
|
|
54
|
+
return key
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def compute_cost(
|
|
58
|
+
model: str,
|
|
59
|
+
prompt_tokens: int,
|
|
60
|
+
completion_tokens: int,
|
|
61
|
+
pricing_table: Optional[Dict[str, Dict[str, float]]] = None,
|
|
62
|
+
) -> Optional[float]:
|
|
63
|
+
table = pricing_table or DEFAULT_PRICING
|
|
64
|
+
matched = _lookup_price(model, table)
|
|
65
|
+
if not matched:
|
|
66
|
+
return None
|
|
67
|
+
_, price = matched
|
|
68
|
+
prompt_cost = (prompt_tokens / 1000.0) * price.get("prompt", 0.0)
|
|
69
|
+
completion_cost = (completion_tokens / 1000.0) * price.get("completion", 0.0)
|
|
70
|
+
return round(prompt_cost + completion_cost, 6)
|
|
71
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Span processor that annotates spans with cost based on token usage and pricing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
|
|
7
|
+
from traccia.processors.cost_engine import compute_cost, match_pricing_model_key
|
|
8
|
+
from traccia.tracer.provider import SpanProcessor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CostAnnotatingProcessor(SpanProcessor):
|
|
12
|
+
"""
|
|
13
|
+
Adds `llm.cost.usd` to spans when token usage and model info are available.
|
|
14
|
+
|
|
15
|
+
Expects spans to carry:
|
|
16
|
+
- llm.model (model name)
|
|
17
|
+
- llm.usage.prompt_tokens
|
|
18
|
+
- llm.usage.completion_tokens
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
pricing_table: Optional[Dict[str, Dict[str, float]]] = None,
|
|
24
|
+
*,
|
|
25
|
+
pricing_source: str = "default",
|
|
26
|
+
) -> None:
|
|
27
|
+
self.pricing_table = pricing_table or {}
|
|
28
|
+
self.pricing_source = pricing_source
|
|
29
|
+
|
|
30
|
+
def on_end(self, span) -> None:
|
|
31
|
+
if "llm.cost.usd" in (span.attributes or {}):
|
|
32
|
+
return
|
|
33
|
+
model = span.attributes.get("llm.model")
|
|
34
|
+
prompt = span.attributes.get("llm.usage.prompt_tokens")
|
|
35
|
+
completion = span.attributes.get("llm.usage.completion_tokens")
|
|
36
|
+
# Anthropic-style names (also supported)
|
|
37
|
+
if prompt is None:
|
|
38
|
+
prompt = span.attributes.get("llm.usage.input_tokens")
|
|
39
|
+
if completion is None:
|
|
40
|
+
completion = span.attributes.get("llm.usage.output_tokens")
|
|
41
|
+
if not model or prompt is None or completion is None:
|
|
42
|
+
return
|
|
43
|
+
cost = compute_cost(
|
|
44
|
+
model,
|
|
45
|
+
int(prompt),
|
|
46
|
+
int(completion),
|
|
47
|
+
pricing_table=self.pricing_table,
|
|
48
|
+
)
|
|
49
|
+
if cost is not None:
|
|
50
|
+
span.set_attribute("llm.cost.usd", cost)
|
|
51
|
+
# Provenance for downstream analysis.
|
|
52
|
+
span.set_attribute("llm.cost.source", span.attributes.get("llm.usage.source", "unknown"))
|
|
53
|
+
span.set_attribute("llm.pricing.source", self.pricing_source)
|
|
54
|
+
key = match_pricing_model_key(model, self.pricing_table)
|
|
55
|
+
if key:
|
|
56
|
+
span.set_attribute("llm.pricing.model_key", key)
|
|
57
|
+
|
|
58
|
+
def shutdown(self) -> None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def force_flush(self, timeout: Optional[float] = None) -> None:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def update_pricing_table(
|
|
65
|
+
self, pricing_table: Dict[str, Dict[str, float]], pricing_source: Optional[str] = None
|
|
66
|
+
) -> None:
|
|
67
|
+
self.pricing_table = pricing_table
|
|
68
|
+
if pricing_source:
|
|
69
|
+
self.pricing_source = pricing_source
|
|
70
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Queue overflow handling strategies for span buffering."""
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from typing import Deque
|
|
5
|
+
|
|
6
|
+
from traccia.tracer.span import Span
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DropPolicy:
|
|
10
|
+
"""Base policy deciding how to handle span queue overflow."""
|
|
11
|
+
|
|
12
|
+
def handle(self, queue: Deque[Span], span: Span, max_size: int) -> bool:
|
|
13
|
+
"""
|
|
14
|
+
Apply the drop policy.
|
|
15
|
+
|
|
16
|
+
Returns True if the span was enqueued, False if it was dropped.
|
|
17
|
+
"""
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DropOldestPolicy(DropPolicy):
|
|
22
|
+
"""Drop the oldest span to make room for a new one."""
|
|
23
|
+
|
|
24
|
+
def handle(self, queue: Deque[Span], span: Span, max_size: int) -> bool:
|
|
25
|
+
if len(queue) >= max_size and queue:
|
|
26
|
+
queue.popleft()
|
|
27
|
+
if len(queue) < max_size:
|
|
28
|
+
queue.append(span)
|
|
29
|
+
return True
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DropNewestPolicy(DropPolicy):
|
|
34
|
+
"""Drop the incoming span if the queue is full."""
|
|
35
|
+
|
|
36
|
+
def handle(self, queue: Deque[Span], span: Span, max_size: int) -> bool:
|
|
37
|
+
if len(queue) < max_size:
|
|
38
|
+
queue.append(span)
|
|
39
|
+
return True
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
DEFAULT_DROP_POLICY = DropOldestPolicy()
|
|
44
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Span processor that logs spans when they end."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from traccia.tracer.provider import SpanProcessor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LoggingSpanProcessor(SpanProcessor):
|
|
12
|
+
"""Logs span summary on end using the standard logging module."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, logger: Optional[logging.Logger] = None) -> None:
|
|
15
|
+
self.logger = logger or logging.getLogger("traccia.traces")
|
|
16
|
+
|
|
17
|
+
def on_end(self, span) -> None:
|
|
18
|
+
attrs = span.attributes or {}
|
|
19
|
+
msg = (
|
|
20
|
+
f"[trace] name={span.name} trace_id={span.context.trace_id} "
|
|
21
|
+
f"span_id={span.context.span_id} status={span.status.name} "
|
|
22
|
+
f"duration_ns={span.duration_ns} attrs={attrs}"
|
|
23
|
+
)
|
|
24
|
+
self.logger.info(msg)
|
|
25
|
+
|
|
26
|
+
def shutdown(self) -> None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
def force_flush(self, timeout: Optional[float] = None) -> None:
|
|
30
|
+
return None
|
|
31
|
+
|