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,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,20 @@
|
|
|
1
|
+
"""Instrumentation helpers and monkey patching."""
|
|
2
|
+
|
|
3
|
+
from traccia.instrumentation.decorator import observe
|
|
4
|
+
from traccia.instrumentation.openai import patch_openai
|
|
5
|
+
from traccia.instrumentation.anthropic import patch_anthropic
|
|
6
|
+
from traccia.instrumentation.requests import patch_requests
|
|
7
|
+
from traccia.instrumentation.http_client import inject_headers as inject_http_headers
|
|
8
|
+
from traccia.instrumentation.http_server import extract_parent_context, start_server_span
|
|
9
|
+
from traccia.instrumentation.fastapi import install_http_middleware
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"observe",
|
|
13
|
+
"patch_openai",
|
|
14
|
+
"patch_anthropic",
|
|
15
|
+
"patch_requests",
|
|
16
|
+
"inject_http_headers",
|
|
17
|
+
"extract_parent_context",
|
|
18
|
+
"start_server_span",
|
|
19
|
+
"install_http_middleware",
|
|
20
|
+
]
|
|
@@ -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
|
+
|