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.
Files changed (55) hide show
  1. traccia/__init__.py +73 -0
  2. traccia/auto.py +736 -0
  3. traccia/auto_instrumentation.py +74 -0
  4. traccia/cli.py +349 -0
  5. traccia/config.py +693 -0
  6. traccia/context/__init__.py +33 -0
  7. traccia/context/context.py +67 -0
  8. traccia/context/propagators.py +283 -0
  9. traccia/errors.py +48 -0
  10. traccia/exporter/__init__.py +8 -0
  11. traccia/exporter/console_exporter.py +31 -0
  12. traccia/exporter/file_exporter.py +178 -0
  13. traccia/exporter/http_exporter.py +214 -0
  14. traccia/exporter/otlp_exporter.py +190 -0
  15. traccia/instrumentation/__init__.py +20 -0
  16. traccia/instrumentation/anthropic.py +92 -0
  17. traccia/instrumentation/decorator.py +263 -0
  18. traccia/instrumentation/fastapi.py +38 -0
  19. traccia/instrumentation/http_client.py +21 -0
  20. traccia/instrumentation/http_server.py +25 -0
  21. traccia/instrumentation/openai.py +178 -0
  22. traccia/instrumentation/requests.py +68 -0
  23. traccia/integrations/__init__.py +22 -0
  24. traccia/integrations/langchain/__init__.py +14 -0
  25. traccia/integrations/langchain/callback.py +418 -0
  26. traccia/integrations/langchain/utils.py +129 -0
  27. traccia/pricing_config.py +58 -0
  28. traccia/processors/__init__.py +35 -0
  29. traccia/processors/agent_enricher.py +159 -0
  30. traccia/processors/batch_processor.py +140 -0
  31. traccia/processors/cost_engine.py +71 -0
  32. traccia/processors/cost_processor.py +70 -0
  33. traccia/processors/drop_policy.py +44 -0
  34. traccia/processors/logging_processor.py +31 -0
  35. traccia/processors/rate_limiter.py +223 -0
  36. traccia/processors/sampler.py +22 -0
  37. traccia/processors/token_counter.py +216 -0
  38. traccia/runtime_config.py +106 -0
  39. traccia/tracer/__init__.py +15 -0
  40. traccia/tracer/otel_adapter.py +577 -0
  41. traccia/tracer/otel_utils.py +24 -0
  42. traccia/tracer/provider.py +155 -0
  43. traccia/tracer/span.py +286 -0
  44. traccia/tracer/span_context.py +16 -0
  45. traccia/tracer/tracer.py +243 -0
  46. traccia/utils/__init__.py +19 -0
  47. traccia/utils/helpers.py +95 -0
  48. {traccia-0.1.2.dist-info → traccia-0.1.5.dist-info}/METADATA +32 -15
  49. traccia-0.1.5.dist-info/RECORD +53 -0
  50. traccia-0.1.5.dist-info/top_level.txt +1 -0
  51. traccia-0.1.2.dist-info/RECORD +0 -6
  52. traccia-0.1.2.dist-info/top_level.txt +0 -1
  53. {traccia-0.1.2.dist-info → traccia-0.1.5.dist-info}/WHEEL +0 -0
  54. {traccia-0.1.2.dist-info → traccia-0.1.5.dist-info}/entry_points.txt +0 -0
  55. {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
+