traccia 0.1.2__py3-none-any.whl → 0.1.6__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 (57) hide show
  1. traccia/__init__.py +73 -0
  2. traccia/auto.py +748 -0
  3. traccia/auto_instrumentation.py +74 -0
  4. traccia/cli.py +349 -0
  5. traccia/config.py +699 -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 +26 -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 +358 -0
  22. traccia/instrumentation/requests.py +68 -0
  23. traccia/integrations/__init__.py +39 -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/integrations/openai_agents/__init__.py +73 -0
  28. traccia/integrations/openai_agents/processor.py +262 -0
  29. traccia/pricing_config.py +58 -0
  30. traccia/processors/__init__.py +35 -0
  31. traccia/processors/agent_enricher.py +159 -0
  32. traccia/processors/batch_processor.py +140 -0
  33. traccia/processors/cost_engine.py +71 -0
  34. traccia/processors/cost_processor.py +70 -0
  35. traccia/processors/drop_policy.py +44 -0
  36. traccia/processors/logging_processor.py +31 -0
  37. traccia/processors/rate_limiter.py +223 -0
  38. traccia/processors/sampler.py +22 -0
  39. traccia/processors/token_counter.py +216 -0
  40. traccia/runtime_config.py +127 -0
  41. traccia/tracer/__init__.py +15 -0
  42. traccia/tracer/otel_adapter.py +577 -0
  43. traccia/tracer/otel_utils.py +24 -0
  44. traccia/tracer/provider.py +155 -0
  45. traccia/tracer/span.py +286 -0
  46. traccia/tracer/span_context.py +16 -0
  47. traccia/tracer/tracer.py +243 -0
  48. traccia/utils/__init__.py +19 -0
  49. traccia/utils/helpers.py +95 -0
  50. {traccia-0.1.2.dist-info → traccia-0.1.6.dist-info}/METADATA +72 -15
  51. traccia-0.1.6.dist-info/RECORD +55 -0
  52. traccia-0.1.6.dist-info/top_level.txt +1 -0
  53. traccia-0.1.2.dist-info/RECORD +0 -6
  54. traccia-0.1.2.dist-info/top_level.txt +0 -1
  55. {traccia-0.1.2.dist-info → traccia-0.1.6.dist-info}/WHEEL +0 -0
  56. {traccia-0.1.2.dist-info → traccia-0.1.6.dist-info}/entry_points.txt +0 -0
  57. {traccia-0.1.2.dist-info → traccia-0.1.6.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,214 @@
1
+ """HTTP exporter with retry, backoff, and graceful shutdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import random
7
+ import time
8
+ from typing import Any, Callable, Dict, Iterable, List, Optional
9
+
10
+ from traccia.tracer.span import Span
11
+ from traccia.tracer.span import SpanStatus
12
+ from traccia import runtime_config
13
+
14
+ TransientStatus = {429, 503, 504}
15
+ DEFAULT_ENDPOINT = "https://api.dashboard.com/api/v1/traces"
16
+
17
+
18
+ class HttpExporter:
19
+ """
20
+ Push spans to a backend over HTTP with retry/backoff.
21
+
22
+ A transport callable can be injected for testing. It should accept (payload_bytes, headers)
23
+ and return an HTTP status code integer.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ endpoint: str = DEFAULT_ENDPOINT,
29
+ api_key: Optional[str] = None,
30
+ timeout: float = 10.0,
31
+ max_retries: int = 5,
32
+ backoff_base: float = 1.0,
33
+ backoff_jitter: float = 0.5,
34
+ transport: Optional[Callable[[bytes, dict], int]] = None,
35
+ ) -> None:
36
+ self.endpoint = endpoint
37
+ self.api_key = api_key
38
+ self.timeout = timeout
39
+ self.max_retries = max_retries
40
+ self.backoff_base = backoff_base
41
+ self.backoff_jitter = backoff_jitter
42
+ self._transport = transport or self._http_post
43
+
44
+ def export(self, spans: Iterable[Span]) -> bool:
45
+ spans_list = list(spans)
46
+ if not spans_list:
47
+ return True
48
+
49
+ payload = self._serialize(spans_list)
50
+ headers = self._headers()
51
+
52
+ for attempt in range(self.max_retries):
53
+ status = self._safe_send(payload, headers)
54
+ if status is None:
55
+ status = 503 # treat transport errors as transient
56
+
57
+ if 200 <= status < 300:
58
+ return True
59
+
60
+ if status not in TransientStatus:
61
+ return False
62
+
63
+ sleep_for = self._compute_backoff(attempt)
64
+ time.sleep(sleep_for)
65
+
66
+ return False
67
+
68
+ def shutdown(self) -> None:
69
+ # No persistent connections when using urllib; if using requests Session it
70
+ # could be closed here. Kept for API symmetry.
71
+ pass
72
+
73
+ # Internal helpers
74
+ def _safe_send(self, payload: bytes, headers: dict) -> Optional[int]:
75
+ try:
76
+ return self._transport(payload, headers)
77
+ except Exception:
78
+ return None
79
+
80
+ def _compute_backoff(self, attempt: int) -> float:
81
+ base = self.backoff_base * (2 ** attempt)
82
+ jitter = random.uniform(0, self.backoff_jitter)
83
+ return base + jitter
84
+
85
+ def _headers(self) -> dict:
86
+ headers = {
87
+ "Content-Type": "application/json",
88
+ "User-Agent": "agent-dashboard-sdk/0.1.0",
89
+ "X-SDK-Version": "0.1.0",
90
+ }
91
+ if self.api_key:
92
+ headers["Authorization"] = f"Bearer {self.api_key}"
93
+ return headers
94
+
95
+ def _serialize(self, spans: List[Span]) -> bytes:
96
+ trunc = runtime_config.get_attr_truncation_limit()
97
+
98
+ def _truncate_str(s: str) -> str:
99
+ if trunc is None or trunc <= 0:
100
+ return s
101
+ if len(s) <= trunc:
102
+ return s
103
+ return s[: max(0, trunc - 1)] + "…"
104
+
105
+ def _sanitize(value: Any, depth: int = 0) -> Any:
106
+ # Keep payload JSON-safe and bounded.
107
+ if value is None or isinstance(value, (bool, int, float)):
108
+ return value
109
+ if isinstance(value, str):
110
+ return _truncate_str(value)
111
+ if depth >= 6:
112
+ return _truncate_str(repr(value))
113
+ if isinstance(value, dict):
114
+ out: Dict[str, Any] = {}
115
+ for k, v in value.items():
116
+ try:
117
+ key = str(k)
118
+ except Exception:
119
+ key = "<unstringifiable>"
120
+ out[_truncate_str(key)] = _sanitize(v, depth + 1)
121
+ return out
122
+ if isinstance(value, (list, tuple, set)):
123
+ return [_sanitize(v, depth + 1) for v in list(value)[:100]]
124
+ # Fallback for unknown objects
125
+ return _truncate_str(repr(value))
126
+
127
+ def _status_code(status: SpanStatus) -> int:
128
+ # OTLP-style: 0=UNSET, 1=OK, 2=ERROR
129
+ if status == SpanStatus.OK:
130
+ return 1
131
+ if status == SpanStatus.ERROR:
132
+ return 2
133
+ return 0
134
+
135
+ def to_event(ev):
136
+ return {
137
+ "name": ev.get("name"),
138
+ "attributes": _sanitize(ev.get("attributes", {})),
139
+ "timestamp_ns": ev.get("timestamp_ns"),
140
+ }
141
+
142
+ resource_attrs: Dict[str, Any] = {}
143
+ try:
144
+ if spans and getattr(spans[0], "tracer", None) is not None:
145
+ provider = getattr(spans[0].tracer, "_provider", None)
146
+ if provider is not None:
147
+ resource_attrs.update(getattr(provider, "resource", {}) or {})
148
+ except Exception:
149
+ resource_attrs = resource_attrs
150
+
151
+ # Add optional session/user identifiers to resource for easier querying.
152
+ if runtime_config.get_session_id():
153
+ resource_attrs.setdefault("session.id", runtime_config.get_session_id())
154
+ if runtime_config.get_user_id():
155
+ resource_attrs.setdefault("user.id", runtime_config.get_user_id())
156
+ if runtime_config.get_tenant_id():
157
+ resource_attrs.setdefault("tenant.id", runtime_config.get_tenant_id())
158
+ if runtime_config.get_project_id():
159
+ resource_attrs.setdefault("project.id", runtime_config.get_project_id())
160
+ if runtime_config.get_agent_id():
161
+ resource_attrs.setdefault("agent.id", runtime_config.get_agent_id())
162
+ if runtime_config.get_debug():
163
+ resource_attrs.setdefault("trace.debug", True)
164
+
165
+ payload = {
166
+ "resource": {"attributes": _sanitize(resource_attrs)},
167
+ "scopeSpans": [
168
+ {
169
+ "scope": {"name": "agent-tracing-sdk", "version": "0.1.0"},
170
+ "spans": [
171
+ {
172
+ "traceId": span.context.trace_id,
173
+ "spanId": span.context.span_id,
174
+ "parentSpanId": span.parent_span_id,
175
+ "name": span.name,
176
+ "startTimeUnixNano": span.start_time_ns,
177
+ "endTimeUnixNano": span.end_time_ns,
178
+ "attributes": _sanitize(span.attributes),
179
+ "events": [to_event(e) for e in span.events],
180
+ "status": {
181
+ "code": _status_code(span.status),
182
+ "message": span.status_description or "",
183
+ },
184
+ }
185
+ for span in spans
186
+ ],
187
+ }
188
+ ]
189
+ }
190
+ # Ensure we never raise due to serialization edge cases.
191
+ return json.dumps(payload, ensure_ascii=False).encode("utf-8")
192
+
193
+ def _http_post(self, payload: bytes, headers: dict) -> int:
194
+ # Lightweight stdlib HTTP client to avoid extra deps.
195
+ # Run in empty context to prevent exporter's own HTTP calls from being instrumented
196
+ import urllib.request
197
+ from opentelemetry import context as context_api
198
+
199
+ # Run HTTP call in an empty context (no active span) to prevent instrumentation
200
+ # This ensures the exporter's HTTP calls don't create spans that pollute business traces
201
+ empty_context = context_api.Context()
202
+ token = context_api.attach(empty_context)
203
+
204
+ try:
205
+ req = urllib.request.Request(
206
+ self.endpoint, data=payload, headers=headers, method="POST"
207
+ )
208
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
209
+ return resp.getcode()
210
+ except Exception:
211
+ return None
212
+ finally:
213
+ context_api.detach(token)
214
+
@@ -0,0 +1,190 @@
1
+ """OTLP exporter using OpenTelemetry OTLP HTTP exporter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable, Optional, Any
6
+
7
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as OTelOTLPSpanExporter
8
+ from opentelemetry.sdk.trace.export import SpanExporter as OTelSpanExporter, SpanExportResult
9
+
10
+
11
+ class OTLPExporter:
12
+ """
13
+ OTLP exporter wrapper that maintains Traccia exporter interface.
14
+
15
+ This wraps OpenTelemetry's OTLP HTTP exporter to work with Traccia's
16
+ BatchSpanProcessor which expects an `export()` method.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ endpoint: Optional[str] = None,
22
+ api_key: Optional[str] = None,
23
+ timeout: float = 10.0,
24
+ headers: Optional[dict] = None,
25
+ ) -> None:
26
+ """
27
+ Initialize OTLP exporter.
28
+
29
+ Args:
30
+ endpoint: OTLP endpoint URL (defaults to OTel default)
31
+ api_key: Optional API key for authentication
32
+ timeout: Request timeout in seconds
33
+ headers: Optional additional headers
34
+ """
35
+ # Build headers
36
+ export_headers = dict(headers) if headers else {}
37
+ if api_key:
38
+ export_headers["Authorization"] = f"Bearer {api_key}"
39
+
40
+ # Create OTel OTLP exporter
41
+ self._otel_exporter = OTelOTLPSpanExporter(
42
+ endpoint=endpoint,
43
+ timeout=timeout,
44
+ headers=export_headers if export_headers else None,
45
+ )
46
+
47
+ self.endpoint = endpoint
48
+ self.api_key = api_key
49
+ self.timeout = timeout
50
+
51
+ def export(self, spans: Iterable[Any]) -> bool:
52
+ """
53
+ Export spans using OTLP format.
54
+
55
+ Args:
56
+ spans: Iterable of Traccia-compatible spans
57
+
58
+ Returns:
59
+ True if export succeeded, False otherwise
60
+ """
61
+ spans_list = list(spans)
62
+ if not spans_list:
63
+ return True
64
+
65
+ # Convert Traccia spans to OTel ReadableSpan format
66
+ from opentelemetry.sdk.trace.export import ReadableSpan
67
+ from opentelemetry.trace import SpanContext, TraceFlags, TraceState
68
+ from opentelemetry.sdk.trace import Resource
69
+ from opentelemetry.trace import Status, StatusCode
70
+
71
+ from traccia.utils.helpers import parse_trace_id, parse_span_id
72
+
73
+ readable_spans = []
74
+
75
+ for span in spans_list:
76
+ # Get OTel span from Traccia wrapper
77
+ # Traccia Span wraps OTel Span
78
+ otel_span = None
79
+
80
+ if hasattr(span, '_otel_span'):
81
+ otel_span = span._otel_span
82
+ elif isinstance(span, ReadableSpan):
83
+ # Already a ReadableSpan (from OTel SDK directly)
84
+ readable_spans.append(span)
85
+ continue
86
+ else:
87
+ # Try to use span directly if it's OTel-compatible
88
+ otel_span = span
89
+
90
+ # Check if it's already a ReadableSpan (OTel SDK provides this when span ends)
91
+ if isinstance(otel_span, ReadableSpan):
92
+ readable_spans.append(otel_span)
93
+ continue
94
+
95
+ # Try to get ReadableSpan from OTel span if it's ended
96
+ # OTel SDK stores ReadableSpan in the span's internal state when it ends
97
+ if hasattr(otel_span, '_readable_span'):
98
+ readable_spans.append(otel_span._readable_span)
99
+ continue
100
+
101
+ # If OTel span is from SDK, try to get it from the span processor
102
+ # OTel SDK's BatchSpanProcessor receives ReadableSpan in on_end()
103
+ # But we're using our own processor, so we need to convert manually
104
+
105
+ # Fallback: convert Traccia span to ReadableSpan manually
106
+ # This handles Traccia Span that wraps an active OTel Span
107
+ try:
108
+ # Parse trace/span IDs
109
+ trace_id = parse_trace_id(span.context.trace_id)
110
+ span_id = parse_span_id(span.context.span_id)
111
+
112
+ # Create OTel SpanContext
113
+ trace_flags = TraceFlags(span.context.trace_flags)
114
+ trace_state = TraceState()
115
+ if span.context.trace_state:
116
+ from traccia.context.propagators import parse_tracestate
117
+ parsed = parse_tracestate(span.context.trace_state)
118
+ if parsed:
119
+ items = [(k, v) for k, v in parsed.items()]
120
+ trace_state = TraceState(items)
121
+
122
+ otel_context = SpanContext(
123
+ trace_id=trace_id,
124
+ span_id=span_id,
125
+ is_remote=False,
126
+ trace_flags=trace_flags,
127
+ trace_state=trace_state,
128
+ )
129
+
130
+ # Convert status
131
+ if span.status.value == 1: # OK
132
+ otel_status = Status(status_code=StatusCode.OK, description=span.status_description)
133
+ elif span.status.value == 2: # ERROR
134
+ otel_status = Status(status_code=StatusCode.ERROR, description=span.status_description)
135
+ else:
136
+ otel_status = Status(status_code=StatusCode.UNSET, description=span.status_description)
137
+
138
+ # Convert events
139
+ otel_events = []
140
+ if span.events:
141
+ from opentelemetry.sdk.trace import Event
142
+ for ev in span.events:
143
+ otel_events.append(Event(
144
+ name=ev.get("name", ""),
145
+ timestamp=ev.get("timestamp_ns", span.start_time_ns),
146
+ attributes=ev.get("attributes", {}),
147
+ ))
148
+
149
+ # Get resource
150
+ resource = Resource.create({})
151
+ if hasattr(span, 'tracer') and span.tracer:
152
+ provider = getattr(span.tracer, '_provider', None)
153
+ if provider:
154
+ resource = provider._otel_provider.resource
155
+
156
+ # Create ReadableSpan
157
+ readable_span = ReadableSpan(
158
+ name=span.name,
159
+ context=otel_context,
160
+ parent=parse_span_id(span.parent_span_id) if span.parent_span_id else None,
161
+ kind=None, # Not available in Traccia
162
+ start_time=span.start_time_ns,
163
+ end_time=span.end_time_ns,
164
+ status=otel_status,
165
+ attributes=span.attributes,
166
+ events=otel_events,
167
+ links=[],
168
+ resource=resource,
169
+ instrumentation_scope=None, # Will be set by OTel
170
+ )
171
+ readable_spans.append(readable_span)
172
+ except Exception:
173
+ # If conversion fails, skip this span
174
+ continue
175
+
176
+ if not readable_spans:
177
+ return True
178
+
179
+ # Export using OTel exporter
180
+ result = self._otel_exporter.export(readable_spans)
181
+ return result == SpanExportResult.SUCCESS
182
+
183
+ def shutdown(self) -> None:
184
+ """Shutdown the exporter."""
185
+ self._otel_exporter.shutdown()
186
+
187
+ def force_flush(self, timeout_millis: Optional[int] = None) -> None:
188
+ """Force flush any pending spans."""
189
+ timeout = timeout_millis / 1000.0 if timeout_millis else None
190
+ self._otel_exporter.force_flush(timeout_millis=int(timeout * 1000) if timeout else 30000)
@@ -0,0 +1,26 @@
1
+ """Infrastructure and vendor instrumentation.
2
+
3
+ This module exposes helpers for HTTP client/server tracing (including FastAPI),
4
+ vendor SDK patching (OpenAI, Anthropic, requests), and decorators used for
5
+ auto-instrumentation.
6
+ """
7
+
8
+ from traccia.instrumentation.decorator import observe
9
+ from traccia.instrumentation.openai import patch_openai, patch_openai_responses
10
+ from traccia.instrumentation.anthropic import patch_anthropic
11
+ from traccia.instrumentation.requests import patch_requests
12
+ from traccia.instrumentation.http_client import inject_headers as inject_http_headers
13
+ from traccia.instrumentation.http_server import extract_parent_context, start_server_span
14
+ from traccia.instrumentation.fastapi import install_http_middleware
15
+
16
+ __all__ = [
17
+ "observe",
18
+ "patch_openai",
19
+ "patch_openai_responses",
20
+ "patch_anthropic",
21
+ "patch_requests",
22
+ "inject_http_headers",
23
+ "extract_parent_context",
24
+ "start_server_span",
25
+ "install_http_middleware",
26
+ ]
@@ -0,0 +1,92 @@
1
+ """Anthropic monkey patching for messages.create."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+ from traccia.tracer.span import SpanStatus
7
+
8
+ _patched = False
9
+
10
+
11
+ def _safe_get(obj, path: str, default=None):
12
+ cur = obj
13
+ for part in path.split("."):
14
+ if cur is None:
15
+ return default
16
+ if isinstance(cur, dict):
17
+ cur = cur.get(part)
18
+ else:
19
+ cur = getattr(cur, part, None)
20
+ return cur if cur is not None else default
21
+
22
+
23
+ def patch_anthropic() -> bool:
24
+ """Patch Anthropic messages.create; returns True if patched, False otherwise."""
25
+ global _patched
26
+ if _patched:
27
+ return True
28
+ try:
29
+ import anthropic
30
+ except Exception:
31
+ return False
32
+
33
+ client_cls = getattr(anthropic, "Anthropic", None)
34
+ if client_cls is None:
35
+ return False
36
+
37
+ original = getattr(client_cls, "messages", None)
38
+ if original is None:
39
+ return False
40
+
41
+ create_fn = getattr(original, "create", None)
42
+ if create_fn is None:
43
+ return False
44
+ if getattr(create_fn, "_agent_trace_patched", False):
45
+ _patched = True
46
+ return True
47
+
48
+ def wrapped_create(self, *args, **kwargs):
49
+ tracer = _get_tracer("anthropic")
50
+ model = kwargs.get("model") or _safe_get(args, "0.model", None)
51
+ attributes: Dict[str, Any] = {"llm.vendor": "anthropic"}
52
+ if model:
53
+ attributes["llm.model"] = model
54
+ with tracer.start_as_current_span("llm.anthropic.messages", attributes=attributes) as span:
55
+ try:
56
+ resp = create_fn(self, *args, **kwargs)
57
+ usage = getattr(resp, "usage", None) or resp.get("usage") if isinstance(resp, dict) else None
58
+ if usage:
59
+ span.set_attribute("llm.usage.source", "provider_usage")
60
+ for k in ("input_tokens", "output_tokens"):
61
+ if k in usage:
62
+ span.set_attribute(f"llm.usage.{k}", usage[k])
63
+ # Provide OpenAI-style aliases so downstream processors (cost, etc.)
64
+ # can treat Anthropic uniformly.
65
+ if "input_tokens" in usage and "llm.usage.prompt_tokens" not in span.attributes:
66
+ span.set_attribute("llm.usage.prompt_tokens", usage["input_tokens"])
67
+ if "input_tokens" in usage:
68
+ span.set_attribute("llm.usage.prompt_source", "provider_usage")
69
+ if "output_tokens" in usage and "llm.usage.completion_tokens" not in span.attributes:
70
+ span.set_attribute("llm.usage.completion_tokens", usage["output_tokens"])
71
+ if "output_tokens" in usage:
72
+ span.set_attribute("llm.usage.completion_source", "provider_usage")
73
+ stop_reason = _safe_get(resp, "stop_reason") or _safe_get(resp, "stop_reason", None)
74
+ if stop_reason:
75
+ span.set_attribute("llm.stop_reason", stop_reason)
76
+ return resp
77
+ except Exception as exc:
78
+ span.record_exception(exc)
79
+ span.set_status(SpanStatus.ERROR, str(exc))
80
+ raise
81
+
82
+ wrapped_create._agent_trace_patched = True
83
+ setattr(original, "create", wrapped_create)
84
+ _patched = True
85
+ return True
86
+
87
+
88
+ def _get_tracer(name: str):
89
+ import traccia
90
+
91
+ return traccia.get_tracer(name)
92
+