prela 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.
- prela/__init__.py +394 -0
- prela/_version.py +3 -0
- prela/contrib/CLI.md +431 -0
- prela/contrib/README.md +118 -0
- prela/contrib/__init__.py +5 -0
- prela/contrib/cli.py +1063 -0
- prela/contrib/explorer.py +571 -0
- prela/core/__init__.py +64 -0
- prela/core/clock.py +98 -0
- prela/core/context.py +228 -0
- prela/core/replay.py +403 -0
- prela/core/sampler.py +178 -0
- prela/core/span.py +295 -0
- prela/core/tracer.py +498 -0
- prela/evals/__init__.py +94 -0
- prela/evals/assertions/README.md +484 -0
- prela/evals/assertions/__init__.py +78 -0
- prela/evals/assertions/base.py +90 -0
- prela/evals/assertions/multi_agent.py +625 -0
- prela/evals/assertions/semantic.py +223 -0
- prela/evals/assertions/structural.py +443 -0
- prela/evals/assertions/tool.py +380 -0
- prela/evals/case.py +370 -0
- prela/evals/n8n/__init__.py +69 -0
- prela/evals/n8n/assertions.py +450 -0
- prela/evals/n8n/runner.py +497 -0
- prela/evals/reporters/README.md +184 -0
- prela/evals/reporters/__init__.py +32 -0
- prela/evals/reporters/console.py +251 -0
- prela/evals/reporters/json.py +176 -0
- prela/evals/reporters/junit.py +278 -0
- prela/evals/runner.py +525 -0
- prela/evals/suite.py +316 -0
- prela/exporters/__init__.py +27 -0
- prela/exporters/base.py +189 -0
- prela/exporters/console.py +443 -0
- prela/exporters/file.py +322 -0
- prela/exporters/http.py +394 -0
- prela/exporters/multi.py +154 -0
- prela/exporters/otlp.py +388 -0
- prela/instrumentation/ANTHROPIC.md +297 -0
- prela/instrumentation/LANGCHAIN.md +480 -0
- prela/instrumentation/OPENAI.md +59 -0
- prela/instrumentation/__init__.py +49 -0
- prela/instrumentation/anthropic.py +1436 -0
- prela/instrumentation/auto.py +129 -0
- prela/instrumentation/base.py +436 -0
- prela/instrumentation/langchain.py +959 -0
- prela/instrumentation/llamaindex.py +719 -0
- prela/instrumentation/multi_agent/__init__.py +48 -0
- prela/instrumentation/multi_agent/autogen.py +357 -0
- prela/instrumentation/multi_agent/crewai.py +404 -0
- prela/instrumentation/multi_agent/langgraph.py +299 -0
- prela/instrumentation/multi_agent/models.py +203 -0
- prela/instrumentation/multi_agent/swarm.py +231 -0
- prela/instrumentation/n8n/__init__.py +68 -0
- prela/instrumentation/n8n/code_node.py +534 -0
- prela/instrumentation/n8n/models.py +336 -0
- prela/instrumentation/n8n/webhook.py +489 -0
- prela/instrumentation/openai.py +1198 -0
- prela/license.py +245 -0
- prela/replay/__init__.py +31 -0
- prela/replay/comparison.py +390 -0
- prela/replay/engine.py +1227 -0
- prela/replay/loader.py +231 -0
- prela/replay/result.py +196 -0
- prela-0.1.0.dist-info/METADATA +399 -0
- prela-0.1.0.dist-info/RECORD +71 -0
- prela-0.1.0.dist-info/WHEEL +4 -0
- prela-0.1.0.dist-info/entry_points.txt +2 -0
- prela-0.1.0.dist-info/licenses/LICENSE +190 -0
prela/exporters/otlp.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OTLP exporter for sending traces to OpenTelemetry Protocol endpoints.
|
|
3
|
+
|
|
4
|
+
This exporter sends spans to any OTLP-compatible backend (e.g., Jaeger,
|
|
5
|
+
Tempo, Honeycomb, New Relic, etc.) using the OpenTelemetry Protocol.
|
|
6
|
+
|
|
7
|
+
The exporter uses HTTP/protobuf by default but can be configured for gRPC.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
|
|
18
|
+
from prela.core.span import Span
|
|
19
|
+
from prela.exporters.base import BaseExporter, ExportResult
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
REQUESTS_AVAILABLE = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
REQUESTS_AVAILABLE = False
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OTLPExporter(BaseExporter):
|
|
32
|
+
"""
|
|
33
|
+
Exporter for sending traces to OTLP-compatible backends.
|
|
34
|
+
|
|
35
|
+
Supports:
|
|
36
|
+
- Jaeger (v1.35+)
|
|
37
|
+
- Grafana Tempo
|
|
38
|
+
- Honeycomb
|
|
39
|
+
- New Relic
|
|
40
|
+
- Any OpenTelemetry Collector
|
|
41
|
+
- Any OTLP-compatible endpoint
|
|
42
|
+
|
|
43
|
+
The exporter uses HTTP/JSON by default for maximum compatibility.
|
|
44
|
+
Protobuf encoding is available but requires additional dependencies.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
```python
|
|
48
|
+
from prela import init
|
|
49
|
+
from prela.exporters.otlp import OTLPExporter
|
|
50
|
+
|
|
51
|
+
# Simple usage with defaults (localhost collector)
|
|
52
|
+
init(
|
|
53
|
+
service_name="my-app",
|
|
54
|
+
exporter=OTLPExporter()
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Jaeger
|
|
58
|
+
init(
|
|
59
|
+
service_name="my-app",
|
|
60
|
+
exporter=OTLPExporter(endpoint="http://localhost:4318")
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Tempo
|
|
64
|
+
init(
|
|
65
|
+
service_name="my-app",
|
|
66
|
+
exporter=OTLPExporter(
|
|
67
|
+
endpoint="http://tempo:4318",
|
|
68
|
+
headers={"X-Scope-OrgID": "tenant1"}
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Honeycomb
|
|
73
|
+
init(
|
|
74
|
+
service_name="my-app",
|
|
75
|
+
exporter=OTLPExporter(
|
|
76
|
+
endpoint="https://api.honeycomb.io:443",
|
|
77
|
+
headers={
|
|
78
|
+
"x-honeycomb-team": "YOUR_API_KEY",
|
|
79
|
+
"x-honeycomb-dataset": "my-dataset"
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
endpoint: str = "http://localhost:4318/v1/traces",
|
|
89
|
+
headers: dict[str, str] | None = None,
|
|
90
|
+
timeout: int = 10,
|
|
91
|
+
insecure: bool = False,
|
|
92
|
+
compression: str | None = None,
|
|
93
|
+
):
|
|
94
|
+
"""
|
|
95
|
+
Initialize OTLP exporter.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
endpoint: OTLP endpoint URL (default: http://localhost:4318/v1/traces)
|
|
99
|
+
headers: Additional HTTP headers (e.g., authentication)
|
|
100
|
+
timeout: Request timeout in seconds (default: 10)
|
|
101
|
+
insecure: Allow insecure HTTPS connections (default: False)
|
|
102
|
+
compression: Compression algorithm ("gzip" or None)
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ImportError: If requests library is not installed
|
|
106
|
+
"""
|
|
107
|
+
if not REQUESTS_AVAILABLE:
|
|
108
|
+
raise ImportError(
|
|
109
|
+
"requests library is required for OTLP exporter. "
|
|
110
|
+
"Install with: pip install 'prela[otlp]' or 'pip install requests'"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
self.endpoint = endpoint
|
|
114
|
+
self.headers = headers or {}
|
|
115
|
+
self.timeout = timeout
|
|
116
|
+
self.insecure = insecure
|
|
117
|
+
self.compression = compression
|
|
118
|
+
|
|
119
|
+
# Set default headers
|
|
120
|
+
if "Content-Type" not in self.headers:
|
|
121
|
+
self.headers["Content-Type"] = "application/json"
|
|
122
|
+
|
|
123
|
+
# Validate endpoint
|
|
124
|
+
parsed = urlparse(endpoint)
|
|
125
|
+
if not parsed.scheme or not parsed.netloc:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Invalid endpoint URL: {endpoint}. "
|
|
128
|
+
f"Must include scheme and host (e.g., http://localhost:4318)"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
logger.debug(f"OTLPExporter initialized: {endpoint}")
|
|
132
|
+
|
|
133
|
+
def export(self, spans: list[Span]) -> ExportResult:
|
|
134
|
+
"""
|
|
135
|
+
Export spans to OTLP endpoint.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
spans: List of spans to export
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
ExportResult indicating success, failure, or retry
|
|
142
|
+
"""
|
|
143
|
+
if not spans:
|
|
144
|
+
return ExportResult.SUCCESS
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
# Convert spans to OTLP format
|
|
148
|
+
otlp_data = self._spans_to_otlp(spans)
|
|
149
|
+
|
|
150
|
+
# Send to endpoint
|
|
151
|
+
response = requests.post(
|
|
152
|
+
self.endpoint,
|
|
153
|
+
json=otlp_data,
|
|
154
|
+
headers=self.headers,
|
|
155
|
+
timeout=self.timeout,
|
|
156
|
+
verify=not self.insecure,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Check response
|
|
160
|
+
if response.status_code == 200:
|
|
161
|
+
logger.debug(f"Exported {len(spans)} spans to OTLP endpoint")
|
|
162
|
+
return ExportResult.SUCCESS
|
|
163
|
+
elif response.status_code in (429, 503):
|
|
164
|
+
# Rate limit or service unavailable - retry
|
|
165
|
+
logger.warning(
|
|
166
|
+
f"OTLP endpoint returned {response.status_code}, will retry"
|
|
167
|
+
)
|
|
168
|
+
return ExportResult.RETRY
|
|
169
|
+
else:
|
|
170
|
+
# Other errors - failure
|
|
171
|
+
logger.error(
|
|
172
|
+
f"OTLP export failed: {response.status_code} {response.text}"
|
|
173
|
+
)
|
|
174
|
+
return ExportResult.FAILURE
|
|
175
|
+
|
|
176
|
+
except requests.exceptions.Timeout:
|
|
177
|
+
logger.warning(f"OTLP export timeout after {self.timeout}s, will retry")
|
|
178
|
+
return ExportResult.RETRY
|
|
179
|
+
except requests.exceptions.ConnectionError as e:
|
|
180
|
+
logger.warning(f"OTLP connection error: {e}, will retry")
|
|
181
|
+
return ExportResult.RETRY
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.error(f"OTLP export error: {e}", exc_info=True)
|
|
184
|
+
return ExportResult.FAILURE
|
|
185
|
+
|
|
186
|
+
def _spans_to_otlp(self, spans: list[Span]) -> dict[str, Any]:
|
|
187
|
+
"""
|
|
188
|
+
Convert Prela spans to OTLP JSON format.
|
|
189
|
+
|
|
190
|
+
OTLP format spec:
|
|
191
|
+
https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/trace/v1/trace.proto
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
spans: List of Prela spans
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
OTLP-formatted dictionary
|
|
198
|
+
"""
|
|
199
|
+
# Group spans by trace_id
|
|
200
|
+
traces_by_id: dict[str, list[Span]] = {}
|
|
201
|
+
for span in spans:
|
|
202
|
+
if span.trace_id not in traces_by_id:
|
|
203
|
+
traces_by_id[span.trace_id] = []
|
|
204
|
+
traces_by_id[span.trace_id].append(span)
|
|
205
|
+
|
|
206
|
+
# Build OTLP structure
|
|
207
|
+
resource_spans = []
|
|
208
|
+
|
|
209
|
+
for trace_id, trace_spans in traces_by_id.items():
|
|
210
|
+
# Extract service name from first span
|
|
211
|
+
service_name = "unknown"
|
|
212
|
+
for span in trace_spans:
|
|
213
|
+
if "service.name" in span.attributes:
|
|
214
|
+
service_name = span.attributes["service.name"]
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
# Build scope spans (one scope for all spans)
|
|
218
|
+
otlp_spans = []
|
|
219
|
+
for span in trace_spans:
|
|
220
|
+
otlp_span = self._span_to_otlp(span)
|
|
221
|
+
otlp_spans.append(otlp_span)
|
|
222
|
+
|
|
223
|
+
resource_span = {
|
|
224
|
+
"resource": {
|
|
225
|
+
"attributes": [
|
|
226
|
+
{"key": "service.name", "value": {"stringValue": service_name}}
|
|
227
|
+
]
|
|
228
|
+
},
|
|
229
|
+
"scopeSpans": [{"scope": {"name": "prela"}, "spans": otlp_spans}],
|
|
230
|
+
}
|
|
231
|
+
resource_spans.append(resource_span)
|
|
232
|
+
|
|
233
|
+
return {"resourceSpans": resource_spans}
|
|
234
|
+
|
|
235
|
+
def _span_to_otlp(self, span: Span) -> dict[str, Any]:
|
|
236
|
+
"""
|
|
237
|
+
Convert a single Prela span to OTLP format.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
span: Prela span
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
OTLP span dictionary
|
|
244
|
+
"""
|
|
245
|
+
# Convert trace_id and span_id to hex (OTLP uses hex strings)
|
|
246
|
+
trace_id_hex = span.trace_id.replace("-", "")[:32].ljust(32, "0")
|
|
247
|
+
span_id_hex = span.span_id.replace("-", "")[:16].ljust(16, "0")
|
|
248
|
+
parent_span_id_hex = ""
|
|
249
|
+
if span.parent_span_id:
|
|
250
|
+
parent_span_id_hex = span.parent_span_id.replace("-", "")[:16].ljust(
|
|
251
|
+
16, "0"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Convert timestamps to nanoseconds
|
|
255
|
+
start_time_ns = int(span.started_at.timestamp() * 1_000_000_000)
|
|
256
|
+
end_time_ns = (
|
|
257
|
+
int(span.ended_at.timestamp() * 1_000_000_000) if span.ended_at else start_time_ns
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Convert span type to OTLP span kind
|
|
261
|
+
span_kind = self._span_type_to_kind(span.span_type.value)
|
|
262
|
+
|
|
263
|
+
# Convert attributes
|
|
264
|
+
attributes = []
|
|
265
|
+
for key, value in span.attributes.items():
|
|
266
|
+
if key == "service.name":
|
|
267
|
+
continue # Already in resource attributes
|
|
268
|
+
attr = self._attribute_to_otlp(key, value)
|
|
269
|
+
if attr:
|
|
270
|
+
attributes.append(attr)
|
|
271
|
+
|
|
272
|
+
# Convert events
|
|
273
|
+
events = []
|
|
274
|
+
for event in span.events:
|
|
275
|
+
event_time_ns = int(event.timestamp.timestamp() * 1_000_000_000)
|
|
276
|
+
event_attrs = []
|
|
277
|
+
for key, value in event.attributes.items():
|
|
278
|
+
attr = self._attribute_to_otlp(key, value)
|
|
279
|
+
if attr:
|
|
280
|
+
event_attrs.append(attr)
|
|
281
|
+
|
|
282
|
+
events.append(
|
|
283
|
+
{
|
|
284
|
+
"timeUnixNano": str(event_time_ns),
|
|
285
|
+
"name": event.name,
|
|
286
|
+
"attributes": event_attrs,
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Build status
|
|
291
|
+
status = {"code": 0} # UNSET
|
|
292
|
+
if span.status:
|
|
293
|
+
if span.status.value == "success":
|
|
294
|
+
status["code"] = 1 # OK
|
|
295
|
+
elif span.status.value == "error":
|
|
296
|
+
status["code"] = 2 # ERROR
|
|
297
|
+
if span.status_message:
|
|
298
|
+
status["message"] = span.status_message
|
|
299
|
+
|
|
300
|
+
otlp_span = {
|
|
301
|
+
"traceId": trace_id_hex,
|
|
302
|
+
"spanId": span_id_hex,
|
|
303
|
+
"name": span.name,
|
|
304
|
+
"kind": span_kind,
|
|
305
|
+
"startTimeUnixNano": str(start_time_ns),
|
|
306
|
+
"endTimeUnixNano": str(end_time_ns),
|
|
307
|
+
"attributes": attributes,
|
|
308
|
+
"events": events,
|
|
309
|
+
"status": status,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if parent_span_id_hex:
|
|
313
|
+
otlp_span["parentSpanId"] = parent_span_id_hex
|
|
314
|
+
|
|
315
|
+
return otlp_span
|
|
316
|
+
|
|
317
|
+
def _span_type_to_kind(self, span_type: str) -> int:
|
|
318
|
+
"""
|
|
319
|
+
Convert Prela span type to OTLP span kind.
|
|
320
|
+
|
|
321
|
+
OTLP span kinds:
|
|
322
|
+
- 0: UNSPECIFIED
|
|
323
|
+
- 1: INTERNAL
|
|
324
|
+
- 2: SERVER
|
|
325
|
+
- 3: CLIENT
|
|
326
|
+
- 4: PRODUCER
|
|
327
|
+
- 5: CONSUMER
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
span_type: Prela span type
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
OTLP span kind integer
|
|
334
|
+
"""
|
|
335
|
+
# Map Prela types to OTLP kinds
|
|
336
|
+
type_map = {
|
|
337
|
+
"agent": 1, # INTERNAL
|
|
338
|
+
"llm": 3, # CLIENT (calling external LLM service)
|
|
339
|
+
"tool": 1, # INTERNAL
|
|
340
|
+
"retrieval": 3, # CLIENT (calling external retrieval service)
|
|
341
|
+
"embedding": 3, # CLIENT (calling external embedding service)
|
|
342
|
+
"custom": 1, # INTERNAL
|
|
343
|
+
}
|
|
344
|
+
return type_map.get(span_type.lower(), 0) # Default: UNSPECIFIED
|
|
345
|
+
|
|
346
|
+
def _attribute_to_otlp(self, key: str, value: Any) -> dict[str, Any] | None:
|
|
347
|
+
"""
|
|
348
|
+
Convert a Prela attribute to OTLP format.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
key: Attribute key
|
|
352
|
+
value: Attribute value
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
OTLP attribute dictionary, or None if value type unsupported
|
|
356
|
+
"""
|
|
357
|
+
# Determine value type and convert
|
|
358
|
+
if isinstance(value, bool):
|
|
359
|
+
return {"key": key, "value": {"boolValue": value}}
|
|
360
|
+
elif isinstance(value, int):
|
|
361
|
+
return {"key": key, "value": {"intValue": str(value)}}
|
|
362
|
+
elif isinstance(value, float):
|
|
363
|
+
return {"key": key, "value": {"doubleValue": value}}
|
|
364
|
+
elif isinstance(value, str):
|
|
365
|
+
return {"key": key, "value": {"stringValue": value}}
|
|
366
|
+
elif isinstance(value, (list, tuple)):
|
|
367
|
+
# OTLP supports array values
|
|
368
|
+
array_values = []
|
|
369
|
+
for item in value:
|
|
370
|
+
if isinstance(item, str):
|
|
371
|
+
array_values.append({"stringValue": item})
|
|
372
|
+
elif isinstance(item, int):
|
|
373
|
+
array_values.append({"intValue": str(item)})
|
|
374
|
+
elif isinstance(item, float):
|
|
375
|
+
array_values.append({"doubleValue": item})
|
|
376
|
+
elif isinstance(item, bool):
|
|
377
|
+
array_values.append({"boolValue": item})
|
|
378
|
+
if array_values:
|
|
379
|
+
return {"key": key, "value": {"arrayValue": {"values": array_values}}}
|
|
380
|
+
else:
|
|
381
|
+
# Convert other types to string
|
|
382
|
+
return {"key": key, "value": {"stringValue": str(value)}}
|
|
383
|
+
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
def shutdown(self) -> None:
|
|
387
|
+
"""Shutdown the exporter."""
|
|
388
|
+
logger.debug("OTLPExporter shutdown")
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# Anthropic SDK Instrumentation
|
|
2
|
+
|
|
3
|
+
This document describes the Anthropic SDK instrumentation for automatic tracing of Claude API calls.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The `AnthropicInstrumentor` provides automatic tracing for all Anthropic API calls by monkey-patching the `anthropic` Python SDK (version 0.40.0+). Once instrumented, all API calls create spans that capture detailed request/response information, token usage, latency, tool use, and errors.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
### Supported Methods
|
|
12
|
+
|
|
13
|
+
- ✅ `anthropic.Anthropic.messages.create` (sync)
|
|
14
|
+
- ✅ `anthropic.AsyncAnthropic.messages.create` (async)
|
|
15
|
+
- ✅ `anthropic.Anthropic.messages.stream` (sync streaming)
|
|
16
|
+
- ✅ `anthropic.AsyncAnthropic.messages.stream` (async streaming)
|
|
17
|
+
|
|
18
|
+
### Captured Data
|
|
19
|
+
|
|
20
|
+
#### Request Attributes
|
|
21
|
+
- `llm.vendor`: Always `"anthropic"`
|
|
22
|
+
- `llm.model`: Model identifier (e.g., `"claude-sonnet-4-20250514"`)
|
|
23
|
+
- `llm.request.model`: Requested model name
|
|
24
|
+
- `llm.system`: System prompt (if provided)
|
|
25
|
+
- `llm.temperature`: Sampling temperature
|
|
26
|
+
- `llm.max_tokens`: Maximum output tokens
|
|
27
|
+
- `llm.stream`: Whether streaming is enabled (for streaming calls)
|
|
28
|
+
|
|
29
|
+
#### Response Attributes
|
|
30
|
+
- `llm.response.model`: Actual model used (may differ from request)
|
|
31
|
+
- `llm.response.id`: Response message ID
|
|
32
|
+
- `llm.input_tokens`: Input token count from usage
|
|
33
|
+
- `llm.output_tokens`: Output token count from usage
|
|
34
|
+
- `llm.stop_reason`: Stop reason (`end_turn`, `max_tokens`, `stop_sequence`, `tool_use`)
|
|
35
|
+
- `llm.latency_ms`: Total request latency in milliseconds
|
|
36
|
+
- `llm.time_to_first_token_ms`: Time to first token (streaming only)
|
|
37
|
+
|
|
38
|
+
#### Events
|
|
39
|
+
- `llm.request`: Captures request messages and system prompt
|
|
40
|
+
- `llm.response`: Captures response content blocks
|
|
41
|
+
- `llm.tool_use`: Captures tool calls when `stop_reason == "tool_use"`
|
|
42
|
+
- `llm.thinking`: Captures extended thinking blocks (if enabled)
|
|
43
|
+
- `error`: Captures error details on failure
|
|
44
|
+
|
|
45
|
+
#### Error Attributes (on failure)
|
|
46
|
+
- `error.type`: Exception class name
|
|
47
|
+
- `error.message`: Error message
|
|
48
|
+
- `error.status_code`: HTTP status code (if applicable)
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
### Basic Example
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from prela.core.tracer import Tracer
|
|
56
|
+
from prela.instrumentation.anthropic import AnthropicInstrumentor
|
|
57
|
+
import anthropic
|
|
58
|
+
|
|
59
|
+
# Initialize tracer
|
|
60
|
+
tracer = Tracer()
|
|
61
|
+
|
|
62
|
+
# Instrument Anthropic SDK
|
|
63
|
+
instrumentor = AnthropicInstrumentor()
|
|
64
|
+
instrumentor.instrument(tracer)
|
|
65
|
+
|
|
66
|
+
# Now all Anthropic calls are automatically traced
|
|
67
|
+
client = anthropic.Anthropic(api_key="your-api-key")
|
|
68
|
+
|
|
69
|
+
response = client.messages.create(
|
|
70
|
+
model="claude-sonnet-4-20250514",
|
|
71
|
+
max_tokens=1024,
|
|
72
|
+
messages=[{"role": "user", "content": "Hello!"}]
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Streaming Example
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
with client.messages.stream(
|
|
80
|
+
model="claude-sonnet-4-20250514",
|
|
81
|
+
max_tokens=1024,
|
|
82
|
+
messages=[{"role": "user", "content": "Write a haiku"}]
|
|
83
|
+
) as stream:
|
|
84
|
+
for text in stream.text_stream:
|
|
85
|
+
print(text, end="", flush=True)
|
|
86
|
+
|
|
87
|
+
# Streaming automatically captures:
|
|
88
|
+
# - Time to first token
|
|
89
|
+
# - Aggregated response text
|
|
90
|
+
# - Final token usage
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Async Example
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import asyncio
|
|
97
|
+
|
|
98
|
+
async def main():
|
|
99
|
+
client = anthropic.AsyncAnthropic(api_key="your-api-key")
|
|
100
|
+
|
|
101
|
+
# Async calls are automatically traced
|
|
102
|
+
response = await client.messages.create(
|
|
103
|
+
model="claude-sonnet-4-20250514",
|
|
104
|
+
max_tokens=1024,
|
|
105
|
+
messages=[{"role": "user", "content": "Hello!"}]
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Async streaming also works
|
|
109
|
+
async with client.messages.stream(
|
|
110
|
+
model="claude-sonnet-4-20250514",
|
|
111
|
+
max_tokens=1024,
|
|
112
|
+
messages=[{"role": "user", "content": "Count to 5"}]
|
|
113
|
+
) as stream:
|
|
114
|
+
async for text in stream.text_stream:
|
|
115
|
+
print(text, end="", flush=True)
|
|
116
|
+
|
|
117
|
+
asyncio.run(main())
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Tool Use Example
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
tools = [{
|
|
124
|
+
"name": "get_weather",
|
|
125
|
+
"description": "Get weather for a location",
|
|
126
|
+
"input_schema": {
|
|
127
|
+
"type": "object",
|
|
128
|
+
"properties": {
|
|
129
|
+
"location": {"type": "string"}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}]
|
|
133
|
+
|
|
134
|
+
response = client.messages.create(
|
|
135
|
+
model="claude-sonnet-4-20250514",
|
|
136
|
+
max_tokens=1024,
|
|
137
|
+
tools=tools,
|
|
138
|
+
messages=[{"role": "user", "content": "What's the weather in SF?"}]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Tool calls are automatically captured in span events
|
|
142
|
+
if response.stop_reason == "tool_use":
|
|
143
|
+
for block in response.content:
|
|
144
|
+
if block.type == "tool_use":
|
|
145
|
+
# Tool execution can be traced separately
|
|
146
|
+
result = execute_tool(block.name, block.input)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Uninstrumenting
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
# Disable instrumentation and restore original functions
|
|
153
|
+
instrumentor.uninstrument()
|
|
154
|
+
|
|
155
|
+
# Future calls will not be traced
|
|
156
|
+
response = client.messages.create(...)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Advanced Features
|
|
160
|
+
|
|
161
|
+
### Extended Thinking
|
|
162
|
+
|
|
163
|
+
When extended thinking is enabled in the API, thinking blocks are automatically captured:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
response = client.messages.create(
|
|
167
|
+
model="claude-sonnet-4-20250514",
|
|
168
|
+
max_tokens=2048,
|
|
169
|
+
thinking={
|
|
170
|
+
"type": "enabled",
|
|
171
|
+
"budget_tokens": 1000
|
|
172
|
+
},
|
|
173
|
+
messages=[{"role": "user", "content": "Solve this problem..."}]
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Thinking blocks are captured in the llm.thinking event
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Error Handling
|
|
180
|
+
|
|
181
|
+
All errors are automatically captured and re-raised:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
try:
|
|
185
|
+
response = client.messages.create(
|
|
186
|
+
model="invalid-model",
|
|
187
|
+
max_tokens=1024,
|
|
188
|
+
messages=[{"role": "user", "content": "Hello"}]
|
|
189
|
+
)
|
|
190
|
+
except anthropic.APIError as e:
|
|
191
|
+
# Error is captured in span with:
|
|
192
|
+
# - error.type: "APIError"
|
|
193
|
+
# - error.message: Error description
|
|
194
|
+
# - error.status_code: HTTP status code
|
|
195
|
+
# Span status set to ERROR
|
|
196
|
+
pass
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Defensive Programming
|
|
200
|
+
|
|
201
|
+
The instrumentation is designed to never crash user code:
|
|
202
|
+
|
|
203
|
+
- All attribute extraction is wrapped in try/except
|
|
204
|
+
- Malformed responses don't break instrumentation
|
|
205
|
+
- Missing attributes are silently skipped
|
|
206
|
+
- Logging is used for debugging issues
|
|
207
|
+
|
|
208
|
+
## Implementation Details
|
|
209
|
+
|
|
210
|
+
### Monkey Patching
|
|
211
|
+
|
|
212
|
+
The instrumentor wraps the following methods using `wrap_function`:
|
|
213
|
+
|
|
214
|
+
1. `Messages.create` (sync and async)
|
|
215
|
+
2. `Messages.stream` (sync and async)
|
|
216
|
+
|
|
217
|
+
The original functions are stored in `module.__prela_originals__` and can be restored via `uninstrument()`.
|
|
218
|
+
|
|
219
|
+
### Stream Wrapping
|
|
220
|
+
|
|
221
|
+
Streaming calls return wrapped stream objects:
|
|
222
|
+
|
|
223
|
+
- `TracedMessageStream` (sync)
|
|
224
|
+
- `TracedAsyncMessageStream` (async)
|
|
225
|
+
|
|
226
|
+
These wrappers:
|
|
227
|
+
- Implement context manager protocol (`__enter__`/`__exit__` or async equivalents)
|
|
228
|
+
- Implement iterator protocol (`__iter__`/`__next__` or async equivalents)
|
|
229
|
+
- Process streaming events to extract metadata
|
|
230
|
+
- Aggregate text content for final span
|
|
231
|
+
- Calculate time-to-first-token
|
|
232
|
+
|
|
233
|
+
### Thread Safety
|
|
234
|
+
|
|
235
|
+
The instrumentation is thread-safe:
|
|
236
|
+
- Wrapping/unwrapping uses module-level locks (handled by Python's import system)
|
|
237
|
+
- Span creation is handled by the tracer (which manages context per-thread)
|
|
238
|
+
- No shared mutable state in the instrumentor
|
|
239
|
+
|
|
240
|
+
## Testing
|
|
241
|
+
|
|
242
|
+
The instrumentation has comprehensive test coverage:
|
|
243
|
+
|
|
244
|
+
- **33 tests** covering all functionality
|
|
245
|
+
- **94% code coverage** (remaining 6% is defensive error logging)
|
|
246
|
+
- Tests use mocked Anthropic SDK (no API calls required)
|
|
247
|
+
- Includes sync, async, streaming, tool use, thinking, and error cases
|
|
248
|
+
|
|
249
|
+
Run tests:
|
|
250
|
+
```bash
|
|
251
|
+
pytest tests/test_instrumentation/test_anthropic.py -v
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Check coverage:
|
|
255
|
+
```bash
|
|
256
|
+
pytest tests/test_instrumentation/test_anthropic.py --cov=prela.instrumentation.anthropic --cov-report=term-missing
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Requirements
|
|
260
|
+
|
|
261
|
+
- Python 3.9+
|
|
262
|
+
- `anthropic>=0.40.0` (optional, only needed if using Anthropic)
|
|
263
|
+
- `prela` SDK
|
|
264
|
+
|
|
265
|
+
Install:
|
|
266
|
+
```bash
|
|
267
|
+
pip install prela[anthropic]
|
|
268
|
+
# or
|
|
269
|
+
pip install anthropic>=0.40.0
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Limitations
|
|
273
|
+
|
|
274
|
+
1. **SDK Version**: Requires `anthropic>=0.40.0`. Older versions may have different APIs.
|
|
275
|
+
|
|
276
|
+
2. **Completion API**: This instrumentor only supports the Messages API. The legacy Completions API is not supported.
|
|
277
|
+
|
|
278
|
+
3. **Prompt Caching**: Prompt caching metadata is not yet captured (can be added if needed).
|
|
279
|
+
|
|
280
|
+
4. **Batching**: The SDK doesn't support batching, so this isn't applicable.
|
|
281
|
+
|
|
282
|
+
## Future Enhancements
|
|
283
|
+
|
|
284
|
+
Potential improvements:
|
|
285
|
+
|
|
286
|
+
- [ ] Capture prompt caching hits/misses
|
|
287
|
+
- [ ] Capture image inputs (multimodal)
|
|
288
|
+
- [ ] Support for future API features
|
|
289
|
+
- [ ] Integration with LangChain's Anthropic wrapper
|
|
290
|
+
- [ ] Cost calculation based on token usage
|
|
291
|
+
|
|
292
|
+
## See Also
|
|
293
|
+
|
|
294
|
+
- [Anthropic API Documentation](https://docs.anthropic.com/)
|
|
295
|
+
- [Prela SDK Documentation](../../docs/)
|
|
296
|
+
- [Base Instrumentor](base.py)
|
|
297
|
+
- [Example Usage](../../examples/anthropic_instrumentation.py)
|