struct-sdk 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.
@@ -0,0 +1,938 @@
1
+ """Anthropic SDK auto-instrumentation — OTel GenAI Semantic Conventions v1.37+.
2
+
3
+ Patches anthropic.Messages.create() and messages.stream() at the CLASS level.
4
+ Supports multiple content capture modes:
5
+ - EVENT_ONLY (default): per-message log events linked to the chat span
6
+ - SPAN_ONLY: content as JSON string span attributes (legacy)
7
+ - SPAN_AND_EVENT: both log events and span attributes
8
+
9
+ Follows the OTel GenAI spec:
10
+ - Span name: "chat {model}"
11
+ - gen_ai.operation.name = "chat"
12
+ - gen_ai.provider.name = "anthropic"
13
+ - Structured message format with parts (text, tool_call, tool_call_response)
14
+ - Per-message log events: gen_ai.user.message, gen_ai.assistant.message, etc.
15
+ - Choice log event: gen_ai.choice
16
+
17
+ Auto-applied by struct.init() when the anthropic package is installed.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import functools
23
+ import json
24
+ import logging
25
+ import time
26
+ from typing import TYPE_CHECKING, Any, Generator, Optional
27
+
28
+ from opentelemetry import trace
29
+ from opentelemetry.trace import StatusCode
30
+
31
+ if TYPE_CHECKING:
32
+ from struct_sdk.core import StructSDK
33
+
34
+ logger = logging.getLogger("struct_sdk.anthropic")
35
+
36
+ # Max size for content attributes (JSON strings on spans)
37
+ _MAX_CONTENT_SIZE = 128 * 1024 # 128KB — generous limit for full message capture
38
+ _TRUNCATION_MARKER = "… [truncated]"
39
+ # Per-field cap: individual text/content fields longer than this get truncated
40
+ _MAX_FIELD_SIZE = 16384 # 16KB per field
41
+
42
+
43
+ def patch(sdk: StructSDK) -> None:
44
+ """Patch anthropic SDK classes. Raises ImportError if not installed."""
45
+ import anthropic
46
+ from anthropic.resources import Messages, AsyncMessages
47
+
48
+ if getattr(anthropic, "__struct_patched", False):
49
+ return
50
+
51
+ tracer = sdk.get_tracer("struct-sdk-anthropic")
52
+ otel_logger = sdk.get_logger("struct-sdk-anthropic") if sdk.emit_events else None
53
+
54
+ # Collect all Messages classes to patch: regular, beta, and Bedrock beta.
55
+ sync_classes = [Messages]
56
+ async_classes = [AsyncMessages]
57
+
58
+ for import_path in [
59
+ ("anthropic.resources.beta.messages.messages", "Messages", "AsyncMessages"),
60
+ ("anthropic.lib.bedrock._beta_messages", "Messages", "AsyncMessages"),
61
+ ("anthropic.lib.vertex._beta_messages", "Messages", "AsyncMessages"),
62
+ ]:
63
+ try:
64
+ mod = __import__(import_path[0], fromlist=[import_path[1], import_path[2]])
65
+ sync_classes.append(getattr(mod, import_path[1]))
66
+ async_classes.append(getattr(mod, import_path[2]))
67
+ except (ImportError, AttributeError):
68
+ pass
69
+
70
+ for sync_cls in sync_classes:
71
+ if not getattr(sync_cls.create, "__struct_wrapped__", False):
72
+ sync_cls.create = _wrap_create(sync_cls.create, tracer, sdk, otel_logger, is_async=False) # type: ignore[method-assign]
73
+ if hasattr(sync_cls, "stream") and not getattr(sync_cls.stream, "__struct_wrapped__", False):
74
+ sync_cls.stream = _wrap_stream(sync_cls.stream, tracer, sdk, otel_logger, is_async=False) # type: ignore[method-assign]
75
+
76
+ for async_cls in async_classes:
77
+ if not getattr(async_cls.create, "__struct_wrapped__", False):
78
+ async_cls.create = _wrap_create(async_cls.create, tracer, sdk, otel_logger, is_async=True) # type: ignore[method-assign]
79
+ if hasattr(async_cls, "stream") and not getattr(async_cls.stream, "__struct_wrapped__", False):
80
+ async_cls.stream = _wrap_stream(async_cls.stream, tracer, sdk, otel_logger, is_async=True) # type: ignore[method-assign]
81
+
82
+ anthropic.__struct_patched = True # type: ignore[attr-defined]
83
+
84
+
85
+ def unpatch() -> None:
86
+ """Remove patches. Restores original methods."""
87
+ try:
88
+ import anthropic
89
+ except ImportError:
90
+ return
91
+
92
+ if not getattr(anthropic, "__struct_patched", False):
93
+ return
94
+
95
+ from anthropic.resources import Messages, AsyncMessages
96
+ for cls in (Messages, AsyncMessages):
97
+ for method_name in ("create", "stream"):
98
+ method = getattr(cls, method_name, None)
99
+ if method and getattr(method, "__struct_wrapped__", False):
100
+ setattr(cls, method_name, method.__struct_original__)
101
+
102
+ anthropic.__struct_patched = False # type: ignore[attr-defined]
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # messages.create — sync/async via generator
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def _create_common(
110
+ f: Any, args: tuple, kwargs: dict, tracer: trace.Tracer, sdk: StructSDK,
111
+ otel_logger: Any = None,
112
+ ) -> Generator:
113
+ """Shared pre/post logic for messages.create. Generator pattern.
114
+
115
+ Telemetry side-effects (pre-call attribute setup, post-call attribute
116
+ extraction, error-path attribute writes) are wrapped in ``_safe`` so a
117
+ failure inside instrumentation can never replace the user's response or
118
+ mask the user's API exception.
119
+ """
120
+ from struct_sdk.core import _safe
121
+
122
+ model = kwargs.get("model", "unknown")
123
+
124
+ with tracer.start_as_current_span(
125
+ f"chat {model}", kind=trace.SpanKind.CLIENT
126
+ ) as span:
127
+ def set_pre_call_attrs() -> None:
128
+ # Required attributes
129
+ span.set_attribute("gen_ai.operation.name", "chat")
130
+ span.set_attribute("gen_ai.provider.name", "anthropic")
131
+
132
+ # Conditionally required
133
+ span.set_attribute("gen_ai.request.model", model)
134
+ from struct_sdk.core import _current_session_id
135
+ session_id = _current_session_id.get(None)
136
+ if session_id:
137
+ span.set_attribute("gen_ai.conversation.id", session_id)
138
+
139
+ # Recommended request attributes
140
+ if kwargs.get("max_tokens"):
141
+ span.set_attribute("gen_ai.request.max_tokens", kwargs["max_tokens"])
142
+ if kwargs.get("temperature") is not None:
143
+ span.set_attribute("gen_ai.request.temperature", kwargs["temperature"])
144
+ if kwargs.get("top_p") is not None:
145
+ span.set_attribute("gen_ai.request.top_p", kwargs["top_p"])
146
+ if kwargs.get("top_k") is not None:
147
+ span.set_attribute("gen_ai.request.top_k", kwargs["top_k"])
148
+ if kwargs.get("stop_sequences"):
149
+ span.set_attribute("gen_ai.request.stop_sequences", kwargs["stop_sequences"])
150
+
151
+ # Always: message count and user prompt propagation
152
+ if "messages" in kwargs:
153
+ span.set_attribute("struct.input.message_count", len(kwargs["messages"]))
154
+ _propagate_user_prompt_to_parent(kwargs["messages"])
155
+
156
+ # Log events: per-message log records linked to this span
157
+ if sdk.emit_events and otel_logger and "messages" in kwargs:
158
+ _emit_message_events(otel_logger, kwargs["messages"], kwargs.get("system"), span)
159
+
160
+ # Span attributes: content on the span (legacy / SPAN_AND_EVENT)
161
+ if sdk.emit_span_content:
162
+ if "messages" in kwargs:
163
+ span.set_attribute("gen_ai.input.messages", _to_input_messages(kwargs["messages"]))
164
+ if kwargs.get("system"):
165
+ span.set_attribute("gen_ai.system_instructions", _to_system_instructions(kwargs["system"]))
166
+ if kwargs.get("tools"):
167
+ span.set_attribute("gen_ai.tool.definitions", _safe_json(kwargs["tools"]))
168
+
169
+ _safe(set_pre_call_attrs, site="anthropic.create.pre_call_attrs")
170
+
171
+ try:
172
+ result = yield f, args, kwargs
173
+ except Exception as e:
174
+ _safe(lambda: span.set_attribute("error.type", type(e).__name__),
175
+ site="anthropic.create.error_type")
176
+ _safe(lambda: span.set_status(StatusCode.ERROR, str(e)),
177
+ site="anthropic.create.error_status")
178
+ _safe(lambda: span.record_exception(e),
179
+ site="anthropic.create.record_exception")
180
+ raise
181
+ _safe(lambda: _set_response_attrs(span, sdk, model, result, otel_logger),
182
+ site="anthropic.create.set_response_attrs")
183
+ _safe(lambda: span.set_status(StatusCode.OK),
184
+ site="anthropic.create.set_ok_status")
185
+ return result # noqa: B901
186
+
187
+
188
+ def _wrap_create(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logger: Any, is_async: bool) -> Any:
189
+ if is_async:
190
+ @functools.wraps(original)
191
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
192
+ from struct_sdk.core import _safe
193
+
194
+ gen: Optional[Generator] = None
195
+ yielded: Any = None
196
+
197
+ def _enter() -> None:
198
+ nonlocal gen, yielded
199
+ gen = _create_common(original, args, kwargs, tracer, sdk, otel_logger)
200
+ yielded = next(gen)
201
+
202
+ _safe(_enter, site="anthropic.create.start_span_async")
203
+ if gen is None or yielded is None:
204
+ # Telemetry setup raised before the user's call. Bypass
205
+ # instrumentation entirely so the user's call always runs.
206
+ return await original(*args, **kwargs)
207
+
208
+ f, call_args, call_kwargs = yielded
209
+ try:
210
+ result = await f(*call_args, **call_kwargs)
211
+ return gen.send(result)
212
+ except StopIteration as e:
213
+ return e.value
214
+ except Exception:
215
+ try:
216
+ gen.throw(*__import__("sys").exc_info())
217
+ except StopIteration as e:
218
+ return e.value
219
+ raise
220
+ else:
221
+ @functools.wraps(original)
222
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
223
+ from struct_sdk.core import _safe
224
+
225
+ gen: Optional[Generator] = None
226
+ yielded: Any = None
227
+
228
+ def _enter() -> None:
229
+ nonlocal gen, yielded
230
+ gen = _create_common(original, args, kwargs, tracer, sdk, otel_logger)
231
+ yielded = next(gen)
232
+
233
+ _safe(_enter, site="anthropic.create.start_span")
234
+ if gen is None or yielded is None:
235
+ # Telemetry setup raised before the user's call. Bypass
236
+ # instrumentation entirely so the user's call always runs.
237
+ return original(*args, **kwargs)
238
+
239
+ f, call_args, call_kwargs = yielded
240
+ try:
241
+ result = f(*call_args, **call_kwargs)
242
+ return gen.send(result)
243
+ except StopIteration as e:
244
+ return e.value
245
+ except Exception:
246
+ try:
247
+ gen.throw(*__import__("sys").exc_info())
248
+ except StopIteration as e:
249
+ return e.value
250
+ raise
251
+
252
+ wrapper.__struct_wrapped__ = True # type: ignore[attr-defined]
253
+ wrapper.__struct_original__ = original # type: ignore[attr-defined]
254
+ return wrapper
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # messages.stream — context manager wrapping
259
+ # ---------------------------------------------------------------------------
260
+
261
+ def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logger: Any, is_async: bool) -> Any:
262
+ """Wrap messages.stream() to trace the streaming context manager.
263
+
264
+ KNOWN GAP: this wrapper creates the chat span and stashes it on the
265
+ stream_manager for use by downstream code, but does NOT currently hook
266
+ the stream's finalization to call _set_response_attrs on the
267
+ accumulated final message. As a result, streaming chat calls do not
268
+ populate the _pending_tool_calls contextvar, and ``@struct.tool()``
269
+ callers downstream of a ``messages.stream(...)`` call must pass
270
+ ``tool_call_id=`` explicitly until this is fixed. Non-streaming
271
+ ``messages.create()`` is fully covered.
272
+ """
273
+ if is_async:
274
+ @functools.wraps(original)
275
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
276
+ from struct_sdk.core import _safe, _current_session_id
277
+ model = kwargs.get("model", "unknown")
278
+
279
+ span: Optional[trace.Span] = None
280
+
281
+ def start() -> None:
282
+ nonlocal span
283
+ span = tracer.start_span(f"chat {model}", kind=trace.SpanKind.CLIENT)
284
+
285
+ _safe(start, site="anthropic.stream.start_span_async")
286
+
287
+ if span is not None:
288
+ def set_pre_call_attrs() -> None:
289
+ assert span is not None
290
+ span.set_attribute("gen_ai.operation.name", "chat")
291
+ span.set_attribute("gen_ai.provider.name", "anthropic")
292
+ span.set_attribute("gen_ai.request.model", model)
293
+ session_id = _current_session_id.get(None)
294
+ if session_id:
295
+ span.set_attribute("gen_ai.conversation.id", session_id)
296
+ if "messages" in kwargs:
297
+ span.set_attribute("struct.input.message_count", len(kwargs["messages"]))
298
+ _propagate_user_prompt_to_parent(kwargs["messages"])
299
+ if sdk.emit_events and otel_logger and "messages" in kwargs:
300
+ _emit_message_events(otel_logger, kwargs["messages"], kwargs.get("system"), span)
301
+ if sdk.emit_span_content and "messages" in kwargs:
302
+ span.set_attribute("gen_ai.input.messages", _to_input_messages(kwargs["messages"]))
303
+
304
+ _safe(set_pre_call_attrs, site="anthropic.stream.pre_call_attrs")
305
+
306
+ stream_manager = await original(*args, **kwargs) if _is_coroutine(original) else original(*args, **kwargs)
307
+
308
+ if span is not None:
309
+ def stash_attrs() -> None:
310
+ stream_manager._struct_span = span # type: ignore[attr-defined]
311
+ stream_manager._struct_sdk = sdk # type: ignore[attr-defined]
312
+ stream_manager._struct_model = model # type: ignore[attr-defined]
313
+ stream_manager._struct_logger = otel_logger # type: ignore[attr-defined]
314
+
315
+ _safe(stash_attrs, site="anthropic.stream.stash_attrs")
316
+
317
+ return stream_manager
318
+ else:
319
+ @functools.wraps(original)
320
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
321
+ from struct_sdk.core import _safe, _current_session_id
322
+ model = kwargs.get("model", "unknown")
323
+
324
+ span: Optional[trace.Span] = None
325
+
326
+ def start() -> None:
327
+ nonlocal span
328
+ span = tracer.start_span(f"chat {model}", kind=trace.SpanKind.CLIENT)
329
+
330
+ _safe(start, site="anthropic.stream.start_span")
331
+
332
+ if span is not None:
333
+ def set_pre_call_attrs() -> None:
334
+ assert span is not None
335
+ span.set_attribute("gen_ai.operation.name", "chat")
336
+ span.set_attribute("gen_ai.provider.name", "anthropic")
337
+ span.set_attribute("gen_ai.request.model", model)
338
+ session_id = _current_session_id.get(None)
339
+ if session_id:
340
+ span.set_attribute("gen_ai.conversation.id", session_id)
341
+ if "messages" in kwargs:
342
+ span.set_attribute("struct.input.message_count", len(kwargs["messages"]))
343
+ _propagate_user_prompt_to_parent(kwargs["messages"])
344
+ if sdk.emit_events and otel_logger and "messages" in kwargs:
345
+ _emit_message_events(otel_logger, kwargs["messages"], kwargs.get("system"), span)
346
+ if sdk.emit_span_content and "messages" in kwargs:
347
+ span.set_attribute("gen_ai.input.messages", _to_input_messages(kwargs["messages"]))
348
+
349
+ _safe(set_pre_call_attrs, site="anthropic.stream.pre_call_attrs")
350
+
351
+ stream_manager = original(*args, **kwargs)
352
+
353
+ if span is not None:
354
+ def stash_attrs() -> None:
355
+ stream_manager._struct_span = span # type: ignore[attr-defined]
356
+ stream_manager._struct_sdk = sdk # type: ignore[attr-defined]
357
+ stream_manager._struct_model = model # type: ignore[attr-defined]
358
+ stream_manager._struct_logger = otel_logger # type: ignore[attr-defined]
359
+
360
+ _safe(stash_attrs, site="anthropic.stream.stash_attrs")
361
+
362
+ return stream_manager
363
+
364
+ wrapper.__struct_wrapped__ = True # type: ignore[attr-defined]
365
+ wrapper.__struct_original__ = original # type: ignore[attr-defined]
366
+ return wrapper
367
+
368
+
369
+ # ---------------------------------------------------------------------------
370
+ # Response attribute extraction
371
+ # ---------------------------------------------------------------------------
372
+
373
+ def _iter_tool_uses(content_blocks: Any) -> list[tuple[str, str]]:
374
+ """Walk response content blocks and yield (tool_name, tool_use_id) pairs.
375
+
376
+ Pure helper — no side effects. Used to (a) emit events and output messages,
377
+ and (b) populate the SDK-internal pending-tool-calls contextvar so that
378
+ ``@struct.tool()`` spans get auto-linked to the originating tool_use.
379
+ """
380
+ result: list[tuple[str, str]] = []
381
+ if not content_blocks:
382
+ return result
383
+ for block in content_blocks:
384
+ block_type = None
385
+ if hasattr(block, "type"):
386
+ block_type = getattr(block, "type", None)
387
+ elif isinstance(block, dict):
388
+ block_type = block.get("type")
389
+ if block_type != "tool_use":
390
+ continue
391
+ if hasattr(block, "name"):
392
+ name = getattr(block, "name", "") or ""
393
+ tool_id = getattr(block, "id", "") or ""
394
+ else:
395
+ name = (block.get("name") or "") if isinstance(block, dict) else ""
396
+ tool_id = (block.get("id") or "") if isinstance(block, dict) else ""
397
+ if name and tool_id:
398
+ result.append((name, tool_id))
399
+ return result
400
+
401
+
402
+ def _record_pending_tool_calls(content_blocks: Any) -> None:
403
+ """Push every tool_use (name, id) from the response into the SDK contextvar.
404
+
405
+ Always runs (not gated on content-capture mode) — the linkage between a
406
+ chat span and its child execute_tool spans is structural, not content.
407
+ """
408
+ from struct_sdk.core import _pending_tool_calls
409
+ pairs = _iter_tool_uses(content_blocks)
410
+ if not pairs:
411
+ return
412
+ pending = _pending_tool_calls.get()
413
+ if pending is None:
414
+ # No active agent scope — initialise a transient dict in the current
415
+ # context so a @struct.tool() call outside of @struct.agent() still
416
+ # gets the linkage. (Will not be reset automatically; that's fine.)
417
+ pending = {}
418
+ _pending_tool_calls.set(pending)
419
+ for name, tool_id in pairs:
420
+ pending.setdefault(name, []).append(tool_id)
421
+
422
+
423
+ def _set_response_attrs(span: trace.Span, sdk: StructSDK, model: str, response: Any, otel_logger: Any = None) -> None:
424
+ if not hasattr(response, "usage"):
425
+ return
426
+
427
+ usage = response.usage
428
+ input_tokens = getattr(usage, "input_tokens", 0) or 0
429
+ output_tokens = getattr(usage, "output_tokens", 0) or 0
430
+ cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
431
+ cache_creation = getattr(usage, "cache_creation_input_tokens", 0) or 0
432
+
433
+ # Anthropic's input_tokens excludes cached tokens — add them back for true total
434
+ total_input = input_tokens + cache_read + cache_creation
435
+
436
+ span.set_attribute("gen_ai.usage.input_tokens", total_input)
437
+ span.set_attribute("gen_ai.usage.output_tokens", output_tokens)
438
+ if cache_read:
439
+ span.set_attribute("gen_ai.usage.cache_read.input_tokens", cache_read)
440
+ if cache_creation:
441
+ span.set_attribute("gen_ai.usage.cache_creation.input_tokens", cache_creation)
442
+
443
+ if hasattr(response, "model"):
444
+ span.set_attribute("gen_ai.response.model", response.model)
445
+ if hasattr(response, "stop_reason") and response.stop_reason:
446
+ span.set_attribute("gen_ai.response.finish_reasons", [response.stop_reason])
447
+ if hasattr(response, "id"):
448
+ span.set_attribute("gen_ai.response.id", response.id)
449
+
450
+ if hasattr(response, "content"):
451
+ # Populate pending tool_use ids for @struct.tool() auto-linkage.
452
+ # Runs unconditionally — independent of content-capture mode.
453
+ _record_pending_tool_calls(response.content)
454
+
455
+ finish_reason = getattr(response, "stop_reason", None)
456
+
457
+ # Log event: gen_ai.choice
458
+ if sdk.emit_events and otel_logger:
459
+ _emit_choice_event(otel_logger, response.content, finish_reason, span)
460
+
461
+ # Span attribute (legacy / SPAN_AND_EVENT)
462
+ if sdk.emit_span_content:
463
+ span.set_attribute("gen_ai.output.messages", _to_output_messages(response.content, finish_reason))
464
+
465
+
466
+ # ---------------------------------------------------------------------------
467
+ # Log event emission — per-message LogRecords (OTel GenAI spec)
468
+ # ---------------------------------------------------------------------------
469
+
470
+ _EVENT_NAME_MAP = {
471
+ "user": "gen_ai.user.message",
472
+ "assistant": "gen_ai.assistant.message",
473
+ "system": "gen_ai.system.message",
474
+ }
475
+
476
+
477
+ def _emit_message_events(
478
+ otel_logger: Any,
479
+ messages: list,
480
+ system: Any = None,
481
+ span: Optional[trace.Span] = None,
482
+ ) -> None:
483
+ """Emit one LogRecord per message, linked to the current span via context.
484
+
485
+ Follows the OTel logs data model convention:
486
+ - ``body`` (log record body): the event tag string (``gen_ai.user.message``
487
+ etc.) — human-readable signal.
488
+ - ``attributes['body']`` (log record attribute): the JSON-serialised
489
+ structured payload ``{"role": ..., "parts": [...]}``.
490
+ - Other attributes: ``event.name``, ``gen_ai.system``,
491
+ ``gen_ai.message.index``, ``gen_ai.conversation.id``.
492
+
493
+ ``span`` — if provided, its span context is used for the LogRecord's
494
+ TraceId/SpanId fields. Fall back to ``trace.get_current_span()`` only for
495
+ backward compatibility. Always prefer passing the span explicitly — the
496
+ generator-based ``messages.create`` wrapper spans multiple async awaits
497
+ and contextvars can drop the active span on some Python + asyncio
498
+ interactions.
499
+ """
500
+ try:
501
+ from opentelemetry._logs import LogRecord, SeverityNumber
502
+
503
+ span_ctx = (span or trace.get_current_span()).get_span_context()
504
+ from struct_sdk.core import _current_session_id
505
+ session_id = _current_session_id.get(None)
506
+
507
+ msg_index = 0
508
+
509
+ # System prompt first (if present)
510
+ if system:
511
+ if isinstance(system, str):
512
+ parts = [{"type": "text", "content": system}]
513
+ elif isinstance(system, list):
514
+ parts = _content_to_parts(system)
515
+ else:
516
+ parts = [{"type": "text", "content": str(system)}]
517
+
518
+ payload = json.dumps({"role": "system", "parts": _truncate_parts(parts)}, default=str)
519
+ event_name = "gen_ai.system.message"
520
+ attrs: dict[str, Any] = {
521
+ "event.name": event_name,
522
+ "body": payload,
523
+ "gen_ai.system": "anthropic",
524
+ "gen_ai.message.index": msg_index,
525
+ }
526
+ if session_id:
527
+ attrs["gen_ai.conversation.id"] = session_id
528
+
529
+ otel_logger.emit(LogRecord(
530
+ timestamp=int(time.time_ns()),
531
+ trace_id=span_ctx.trace_id,
532
+ span_id=span_ctx.span_id,
533
+ trace_flags=span_ctx.trace_flags,
534
+ severity_number=SeverityNumber.INFO,
535
+ body=event_name,
536
+ attributes=attrs,
537
+ ))
538
+ msg_index += 1
539
+
540
+ # Input messages
541
+ for msg in messages:
542
+ if isinstance(msg, dict):
543
+ role = msg.get("role", "user")
544
+ content = msg.get("content")
545
+ else:
546
+ role = getattr(msg, "role", "user")
547
+ content = getattr(msg, "content", None)
548
+
549
+ parts = _content_to_parts(content)
550
+ event_name = _EVENT_NAME_MAP.get(role, f"gen_ai.{role}.message")
551
+ payload = json.dumps({"role": role, "parts": _truncate_parts(parts)}, default=str)
552
+
553
+ attrs = {
554
+ "event.name": event_name,
555
+ "body": payload,
556
+ "gen_ai.system": "anthropic",
557
+ "gen_ai.message.index": msg_index,
558
+ }
559
+ if session_id:
560
+ attrs["gen_ai.conversation.id"] = session_id
561
+
562
+ otel_logger.emit(LogRecord(
563
+ timestamp=int(time.time_ns()),
564
+ trace_id=span_ctx.trace_id,
565
+ span_id=span_ctx.span_id,
566
+ trace_flags=span_ctx.trace_flags,
567
+ severity_number=SeverityNumber.INFO,
568
+ body=event_name,
569
+ attributes=attrs,
570
+ ))
571
+ msg_index += 1
572
+
573
+ except Exception:
574
+ logger.debug("Failed to emit message events", exc_info=True)
575
+
576
+
577
+ def _emit_choice_event(
578
+ otel_logger: Any,
579
+ content_blocks: list,
580
+ finish_reason: str | None,
581
+ span: Optional[trace.Span] = None,
582
+ ) -> None:
583
+ """Emit a gen_ai.choice LogRecord for the assistant's response.
584
+
585
+ ``span`` — same rationale as ``_emit_message_events``: prefer the
586
+ explicit span over the ambient ``trace.get_current_span()`` lookup.
587
+ """
588
+ try:
589
+ from opentelemetry._logs import LogRecord, SeverityNumber
590
+
591
+ span_ctx = (span or trace.get_current_span()).get_span_context()
592
+ from struct_sdk.core import _current_session_id
593
+ session_id = _current_session_id.get(None)
594
+
595
+ parts = []
596
+ for block in content_blocks:
597
+ block_type = getattr(block, "type", None)
598
+ if block_type == "text":
599
+ parts.append({"type": "text", "content": getattr(block, "text", "")})
600
+ elif block_type == "tool_use":
601
+ part: dict[str, Any] = {"type": "tool_call", "name": getattr(block, "name", "")}
602
+ tool_id = getattr(block, "id", None)
603
+ if tool_id:
604
+ part["id"] = tool_id
605
+ tool_input = getattr(block, "input", None)
606
+ if tool_input is not None:
607
+ part["arguments"] = tool_input
608
+ parts.append(part)
609
+ elif block_type == "thinking":
610
+ parts.append({"type": "reasoning", "content": getattr(block, "thinking", "")})
611
+ else:
612
+ parts.append({"type": block_type or "unknown"})
613
+
614
+ # Map Anthropic stop reasons to spec finish reasons
615
+ reason_map = {
616
+ "end_turn": "stop",
617
+ "stop_sequence": "stop",
618
+ "max_tokens": "length",
619
+ "tool_use": "tool_call",
620
+ }
621
+ mapped_reason = reason_map.get(finish_reason, finish_reason) if finish_reason else None
622
+
623
+ choice_body = {
624
+ "index": 0,
625
+ "finish_reason": mapped_reason or "stop",
626
+ "message": {"role": "assistant", "parts": _truncate_parts(parts)},
627
+ }
628
+
629
+ payload = json.dumps(choice_body, default=str)
630
+ event_name = "gen_ai.choice"
631
+ attrs: dict[str, Any] = {
632
+ "event.name": event_name,
633
+ "body": payload,
634
+ "gen_ai.system": "anthropic",
635
+ }
636
+ if session_id:
637
+ attrs["gen_ai.conversation.id"] = session_id
638
+
639
+ otel_logger.emit(LogRecord(
640
+ timestamp=int(time.time_ns()),
641
+ trace_id=span_ctx.trace_id,
642
+ span_id=span_ctx.span_id,
643
+ trace_flags=span_ctx.trace_flags,
644
+ severity_number=SeverityNumber.INFO,
645
+ body=event_name,
646
+ attributes=attrs,
647
+ ))
648
+
649
+ except Exception:
650
+ logger.debug("Failed to emit choice event", exc_info=True)
651
+
652
+
653
+ # ---------------------------------------------------------------------------
654
+ # Message serialization — Anthropic format → OTel GenAI spec format
655
+ # ---------------------------------------------------------------------------
656
+
657
+ def _truncate_field(value: str | Any, max_len: int = _MAX_FIELD_SIZE) -> str | Any:
658
+ """Truncate a single string field if it exceeds max_len."""
659
+ if isinstance(value, str) and len(value) > max_len:
660
+ return value[:max_len] + _TRUNCATION_MARKER
661
+ return value
662
+
663
+
664
+ def _truncate_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
665
+ """Truncate content fields within message parts."""
666
+ result = []
667
+ for part in parts:
668
+ part = dict(part) # shallow copy
669
+ if "content" in part and isinstance(part["content"], str):
670
+ part["content"] = _truncate_field(part["content"])
671
+ if "arguments" in part:
672
+ arg = part["arguments"]
673
+ if isinstance(arg, str):
674
+ part["arguments"] = _truncate_field(arg)
675
+ elif isinstance(arg, dict):
676
+ serialized = json.dumps(arg, default=str)
677
+ if len(serialized) > _MAX_FIELD_SIZE:
678
+ part["arguments"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
679
+ if "response" in part:
680
+ resp = part["response"]
681
+ if isinstance(resp, str):
682
+ part["response"] = _truncate_field(resp)
683
+ elif isinstance(resp, (dict, list)):
684
+ serialized = json.dumps(resp, default=str)
685
+ if len(serialized) > _MAX_FIELD_SIZE:
686
+ part["response"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
687
+ result.append(part)
688
+ return result
689
+
690
+
691
+ def _truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
692
+ """Truncate individual fields in message structures, then serialize.
693
+
694
+ Unlike a blind string slice, this preserves valid JSON by truncating
695
+ content/arguments/response fields within parts before serialization.
696
+ If the result still exceeds max_size after field truncation, applies
697
+ progressively more aggressive truncation.
698
+ """
699
+ if isinstance(obj, list):
700
+ truncated = []
701
+ for item in obj:
702
+ if isinstance(item, dict):
703
+ item = dict(item) # shallow copy
704
+ if "parts" in item and isinstance(item["parts"], list):
705
+ item["parts"] = _truncate_parts(item["parts"])
706
+ elif "content" in item and isinstance(item["content"], str):
707
+ # Top-level content (e.g. system_instructions format)
708
+ item["content"] = _truncate_field(item["content"])
709
+ truncated.append(item)
710
+ result = json.dumps(truncated, default=str)
711
+ else:
712
+ result = json.dumps(obj, default=str)
713
+
714
+ # If still too large after field-level truncation, do a final hard cap
715
+ # but try to close the JSON array to keep it parseable
716
+ if len(result) > max_size:
717
+ # Find the last complete message boundary
718
+ cut = result[:max_size - 50] # leave room for closing
719
+ last_brace = cut.rfind("}")
720
+ if last_brace > 0:
721
+ # Try to close at a message boundary
722
+ result = cut[:last_brace + 1] + "]"
723
+ else:
724
+ result = "[]"
725
+
726
+ return result
727
+
728
+
729
+ def _to_input_messages(messages: list) -> str:
730
+ """Convert Anthropic messages list to GenAI spec input messages format.
731
+
732
+ Spec format: [{"role": "user", "parts": [{"type": "text", "content": "..."}]}, ...]
733
+ """
734
+ try:
735
+ result = []
736
+ for msg in messages:
737
+ if isinstance(msg, dict):
738
+ role = msg.get("role", "user")
739
+ content = msg.get("content")
740
+ else:
741
+ role = getattr(msg, "role", "user")
742
+ content = getattr(msg, "content", None)
743
+
744
+ parts = _content_to_parts(content)
745
+ result.append({"role": role, "parts": parts})
746
+
747
+ return _truncate_and_serialize(result)
748
+ except Exception:
749
+ return json.dumps([], default=str)
750
+
751
+
752
+ def _to_output_messages(content_blocks: list, finish_reason: str | None) -> str:
753
+ """Convert Anthropic response content blocks to GenAI spec output messages format.
754
+
755
+ Spec format: [{"role": "assistant", "parts": [...], "finish_reason": "stop"}]
756
+ """
757
+ try:
758
+ parts = []
759
+ for block in content_blocks:
760
+ block_type = getattr(block, "type", None)
761
+ if block_type == "text":
762
+ parts.append({"type": "text", "content": getattr(block, "text", "")})
763
+ elif block_type == "tool_use":
764
+ part: dict[str, Any] = {
765
+ "type": "tool_call",
766
+ "name": getattr(block, "name", ""),
767
+ }
768
+ tool_id = getattr(block, "id", None)
769
+ if tool_id:
770
+ part["id"] = tool_id
771
+ tool_input = getattr(block, "input", None)
772
+ if tool_input is not None:
773
+ part["arguments"] = tool_input
774
+ parts.append(part)
775
+ elif block_type == "thinking":
776
+ parts.append({"type": "reasoning", "content": getattr(block, "thinking", "")})
777
+ else:
778
+ parts.append({"type": block_type or "unknown"})
779
+
780
+ msg: dict[str, Any] = {"role": "assistant", "parts": parts}
781
+ if finish_reason:
782
+ # Map Anthropic stop reasons to spec finish reasons
783
+ reason_map = {
784
+ "end_turn": "stop",
785
+ "stop_sequence": "stop",
786
+ "max_tokens": "length",
787
+ "tool_use": "tool_call",
788
+ }
789
+ msg["finish_reason"] = reason_map.get(finish_reason, finish_reason)
790
+
791
+ return _truncate_and_serialize([msg])
792
+ except Exception:
793
+ return json.dumps([], default=str)
794
+
795
+
796
+ def _to_system_instructions(system: Any) -> str:
797
+ """Convert Anthropic system prompt to GenAI spec system_instructions format.
798
+
799
+ Spec format: [{"type": "text", "content": "..."}]
800
+ """
801
+ try:
802
+ if isinstance(system, str):
803
+ return _truncate_and_serialize([{"type": "text", "content": system}])
804
+ elif isinstance(system, list):
805
+ parts = _content_to_parts(system)
806
+ return _truncate_and_serialize(parts)
807
+ return json.dumps([], default=str)
808
+ except Exception:
809
+ return json.dumps([], default=str)
810
+
811
+
812
+ def _content_to_parts(content: Any) -> list[dict[str, Any]]:
813
+ """Convert Anthropic content (string, list of blocks, or tool_result) to spec parts."""
814
+ if content is None:
815
+ return []
816
+ if isinstance(content, str):
817
+ return [{"type": "text", "content": content}]
818
+ if isinstance(content, list):
819
+ parts = []
820
+ for item in content:
821
+ if isinstance(item, dict):
822
+ item_type = item.get("type")
823
+ if item_type == "text":
824
+ parts.append({"type": "text", "content": item.get("text", "")})
825
+ elif item_type == "tool_use":
826
+ part: dict[str, Any] = {"type": "tool_call", "name": item.get("name", "")}
827
+ if item.get("id"):
828
+ part["id"] = item["id"]
829
+ if item.get("input") is not None:
830
+ part["arguments"] = item["input"]
831
+ parts.append(part)
832
+ elif item_type == "tool_result":
833
+ part2: dict[str, Any] = {"type": "tool_call_response"}
834
+ if item.get("tool_use_id"):
835
+ part2["id"] = item["tool_use_id"]
836
+ part2["response"] = item.get("content", "")
837
+ parts.append(part2)
838
+ elif item_type == "image":
839
+ source = item.get("source", {})
840
+ if source.get("type") == "base64":
841
+ parts.append({
842
+ "type": "blob",
843
+ "modality": "image",
844
+ "content": source.get("data", "")[:256] + "...",
845
+ "mime_type": source.get("media_type"),
846
+ })
847
+ elif source.get("type") == "url":
848
+ parts.append({
849
+ "type": "uri",
850
+ "modality": "image",
851
+ "uri": source.get("url", ""),
852
+ })
853
+ else:
854
+ parts.append({"type": item_type or "unknown"})
855
+ elif hasattr(item, "type"):
856
+ # Anthropic SDK objects
857
+ item_type = item.type
858
+ if item_type == "text":
859
+ parts.append({"type": "text", "content": getattr(item, "text", "")})
860
+ elif item_type == "tool_use":
861
+ part3: dict[str, Any] = {"type": "tool_call", "name": getattr(item, "name", "")}
862
+ if getattr(item, "id", None):
863
+ part3["id"] = item.id
864
+ if getattr(item, "input", None) is not None:
865
+ part3["arguments"] = item.input
866
+ parts.append(part3)
867
+ elif item_type == "tool_result":
868
+ part4: dict[str, Any] = {"type": "tool_call_response"}
869
+ if getattr(item, "tool_use_id", None):
870
+ part4["id"] = item.tool_use_id
871
+ part4["response"] = getattr(item, "content", "")
872
+ parts.append(part4)
873
+ else:
874
+ parts.append({"type": item_type})
875
+ elif isinstance(item, str):
876
+ parts.append({"type": "text", "content": item})
877
+ return parts
878
+ return [{"type": "text", "content": str(content)}]
879
+
880
+
881
+ def _propagate_user_prompt_to_parent(messages: list) -> None:
882
+ """Set the last user message on the parent invoke_agent span if present.
883
+
884
+ This allows the waterfall UI to show what the user asked without
885
+ needing to drill into the child chat span. Only sets the attribute
886
+ once — the first chat call within an invoke_agent scope wins.
887
+ """
888
+ try:
889
+ # Walk up — the current span is the chat span we just created;
890
+ # its parent context holds the invoke_agent span. However OTel
891
+ # Python flattens context, so get_current_span() returns the
892
+ # innermost (our chat span). We need the parent, which is the
893
+ # span that was current *before* start_as_current_span.
894
+ # Since we're inside start_as_current_span, the parent is not
895
+ # directly accessible. Instead, we stash the attribute on any
896
+ # ancestor invoke_agent span via the context var approach.
897
+ from struct_sdk.core import _current_agent_span
898
+ agent_span = _current_agent_span.get(None)
899
+ if agent_span is None:
900
+ return
901
+ # Only set once — don't overwrite if already captured
902
+ # (ReadableSpan check — attributes is a dict on SDK spans)
903
+ existing = None
904
+ if hasattr(agent_span, "attributes"):
905
+ existing = agent_span.attributes.get("gen_ai.input.messages") # type: ignore[union-attr]
906
+ if existing:
907
+ return
908
+
909
+ # Extract just the last user message for a clean prompt preview
910
+ last_user_msg = None
911
+ for msg in reversed(messages):
912
+ role = msg.get("role") if isinstance(msg, dict) else getattr(msg, "role", None)
913
+ if role == "user":
914
+ content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None)
915
+ parts = _content_to_parts(content)
916
+ last_user_msg = [{"role": "user", "parts": parts}]
917
+ break
918
+
919
+ if last_user_msg:
920
+ agent_span.set_attribute(
921
+ "gen_ai.input.messages",
922
+ _truncate_and_serialize(last_user_msg),
923
+ )
924
+ except Exception:
925
+ pass # Never break the application for telemetry
926
+
927
+
928
+ def _safe_json(obj: Any) -> str:
929
+ """Safely serialize to JSON string with field-level truncation."""
930
+ try:
931
+ return _truncate_and_serialize(obj)
932
+ except Exception:
933
+ return "[]"
934
+
935
+
936
+ def _is_coroutine(fn: Any) -> bool:
937
+ import asyncio
938
+ return asyncio.iscoroutinefunction(fn)