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.
Files changed (71) hide show
  1. prela/__init__.py +394 -0
  2. prela/_version.py +3 -0
  3. prela/contrib/CLI.md +431 -0
  4. prela/contrib/README.md +118 -0
  5. prela/contrib/__init__.py +5 -0
  6. prela/contrib/cli.py +1063 -0
  7. prela/contrib/explorer.py +571 -0
  8. prela/core/__init__.py +64 -0
  9. prela/core/clock.py +98 -0
  10. prela/core/context.py +228 -0
  11. prela/core/replay.py +403 -0
  12. prela/core/sampler.py +178 -0
  13. prela/core/span.py +295 -0
  14. prela/core/tracer.py +498 -0
  15. prela/evals/__init__.py +94 -0
  16. prela/evals/assertions/README.md +484 -0
  17. prela/evals/assertions/__init__.py +78 -0
  18. prela/evals/assertions/base.py +90 -0
  19. prela/evals/assertions/multi_agent.py +625 -0
  20. prela/evals/assertions/semantic.py +223 -0
  21. prela/evals/assertions/structural.py +443 -0
  22. prela/evals/assertions/tool.py +380 -0
  23. prela/evals/case.py +370 -0
  24. prela/evals/n8n/__init__.py +69 -0
  25. prela/evals/n8n/assertions.py +450 -0
  26. prela/evals/n8n/runner.py +497 -0
  27. prela/evals/reporters/README.md +184 -0
  28. prela/evals/reporters/__init__.py +32 -0
  29. prela/evals/reporters/console.py +251 -0
  30. prela/evals/reporters/json.py +176 -0
  31. prela/evals/reporters/junit.py +278 -0
  32. prela/evals/runner.py +525 -0
  33. prela/evals/suite.py +316 -0
  34. prela/exporters/__init__.py +27 -0
  35. prela/exporters/base.py +189 -0
  36. prela/exporters/console.py +443 -0
  37. prela/exporters/file.py +322 -0
  38. prela/exporters/http.py +394 -0
  39. prela/exporters/multi.py +154 -0
  40. prela/exporters/otlp.py +388 -0
  41. prela/instrumentation/ANTHROPIC.md +297 -0
  42. prela/instrumentation/LANGCHAIN.md +480 -0
  43. prela/instrumentation/OPENAI.md +59 -0
  44. prela/instrumentation/__init__.py +49 -0
  45. prela/instrumentation/anthropic.py +1436 -0
  46. prela/instrumentation/auto.py +129 -0
  47. prela/instrumentation/base.py +436 -0
  48. prela/instrumentation/langchain.py +959 -0
  49. prela/instrumentation/llamaindex.py +719 -0
  50. prela/instrumentation/multi_agent/__init__.py +48 -0
  51. prela/instrumentation/multi_agent/autogen.py +357 -0
  52. prela/instrumentation/multi_agent/crewai.py +404 -0
  53. prela/instrumentation/multi_agent/langgraph.py +299 -0
  54. prela/instrumentation/multi_agent/models.py +203 -0
  55. prela/instrumentation/multi_agent/swarm.py +231 -0
  56. prela/instrumentation/n8n/__init__.py +68 -0
  57. prela/instrumentation/n8n/code_node.py +534 -0
  58. prela/instrumentation/n8n/models.py +336 -0
  59. prela/instrumentation/n8n/webhook.py +489 -0
  60. prela/instrumentation/openai.py +1198 -0
  61. prela/license.py +245 -0
  62. prela/replay/__init__.py +31 -0
  63. prela/replay/comparison.py +390 -0
  64. prela/replay/engine.py +1227 -0
  65. prela/replay/loader.py +231 -0
  66. prela/replay/result.py +196 -0
  67. prela-0.1.0.dist-info/METADATA +399 -0
  68. prela-0.1.0.dist-info/RECORD +71 -0
  69. prela-0.1.0.dist-info/WHEEL +4 -0
  70. prela-0.1.0.dist-info/entry_points.txt +2 -0
  71. prela-0.1.0.dist-info/licenses/LICENSE +190 -0
@@ -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)