struct-sdk 0.2.10__tar.gz → 0.2.12__tar.gz
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.
- {struct_sdk-0.2.10 → struct_sdk-0.2.12}/.gitignore +1 -4
- {struct_sdk-0.2.10 → struct_sdk-0.2.12}/PKG-INFO +9 -5
- {struct_sdk-0.2.10 → struct_sdk-0.2.12}/README.md +5 -3
- {struct_sdk-0.2.10 → struct_sdk-0.2.12}/pyproject.toml +11 -3
- struct_sdk-0.2.12/src/struct_sdk/_genai_content.py +265 -0
- {struct_sdk-0.2.10 → struct_sdk-0.2.12}/src/struct_sdk/anthropic.py +66 -212
- {struct_sdk-0.2.10 → struct_sdk-0.2.12}/src/struct_sdk/core.py +3 -1
- struct_sdk-0.2.12/src/struct_sdk/openai.py +764 -0
- {struct_sdk-0.2.10 → struct_sdk-0.2.12}/LICENSE +0 -0
- {struct_sdk-0.2.10 → struct_sdk-0.2.12}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.2.10 → struct_sdk-0.2.12}/src/struct_sdk/claude_agent.py +0 -0
- {struct_sdk-0.2.10 → struct_sdk-0.2.12}/src/struct_sdk/langchain.py +0 -0
|
@@ -87,12 +87,9 @@ apps/edgedive-functions/local.settings.json
|
|
|
87
87
|
# Claude
|
|
88
88
|
.claude/worktrees/
|
|
89
89
|
.claude/scheduled_tasks.lock
|
|
90
|
-
.superpowers/
|
|
91
|
-
docs/superpowers/
|
|
92
90
|
|
|
93
|
-
# Superpowers
|
|
91
|
+
# Superpowers working state (design specs and plans under docs/superpowers/ ARE committed)
|
|
94
92
|
.superpowers/
|
|
95
|
-
docs/superpowers/
|
|
96
93
|
|
|
97
94
|
.playwright-mcp/
|
|
98
95
|
struct-agent-key.json
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: struct-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.12
|
|
4
4
|
Summary: Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
|
|
5
5
|
Project-URL: Homepage, https://struct.ai
|
|
6
6
|
Project-URL: Documentation, https://struct.ai/docs
|
|
@@ -8,7 +8,7 @@ Project-URL: Issues, https://struct.ai/support
|
|
|
8
8
|
Author-email: Struct <support@struct.ai>
|
|
9
9
|
License: Apache-2.0
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Keywords: agent,ai,anthropic,langchain,observability,opentelemetry,struct,tracing
|
|
11
|
+
Keywords: agent,ai,anthropic,langchain,observability,openai,opentelemetry,struct,tracing
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
@@ -42,14 +42,16 @@ Requires-Dist: pytest>=9.0.3; extra == 'dev'
|
|
|
42
42
|
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
43
43
|
Provides-Extra: langchain
|
|
44
44
|
Requires-Dist: langchain-core>=1.3.3; extra == 'langchain'
|
|
45
|
+
Provides-Extra: openai
|
|
46
|
+
Requires-Dist: openai>=1.66.0; extra == 'openai'
|
|
45
47
|
Description-Content-Type: text/markdown
|
|
46
48
|
|
|
47
49
|
# struct-sdk
|
|
48
50
|
|
|
49
51
|
OpenTelemetry instrumentation for AI agents in Python. Captures spans,
|
|
50
|
-
token usage, and message events from the Anthropic SDK, the
|
|
51
|
-
SDK, and LangChain / LangGraph, and
|
|
52
|
-
[struct.ai](https://struct.ai) for observability.
|
|
52
|
+
token usage, and message events from the Anthropic SDK, the OpenAI SDK
|
|
53
|
+
(Responses API), the Claude Agent SDK, and LangChain / LangGraph, and
|
|
54
|
+
exports them to [struct.ai](https://struct.ai) for observability.
|
|
53
55
|
|
|
54
56
|
A TypeScript version is available as
|
|
55
57
|
[`@struct-ai/sdk`](https://www.npmjs.com/package/@struct-ai/sdk). Both
|
|
@@ -62,6 +64,7 @@ mix languages without a fragmented view.
|
|
|
62
64
|
pip install struct-sdk
|
|
63
65
|
# optional — the SDK auto-instruments these if present
|
|
64
66
|
pip install anthropic
|
|
67
|
+
pip install openai
|
|
65
68
|
pip install claude-agent-sdk
|
|
66
69
|
pip install langchain-core langgraph
|
|
67
70
|
```
|
|
@@ -106,6 +109,7 @@ async with struct.agent(name="checkout"):
|
|
|
106
109
|
| Library | Span type | Notes |
|
|
107
110
|
|---|---|---|
|
|
108
111
|
| `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
|
|
112
|
+
| `openai` (Responses API) | `chat {model}` | Non-streaming `responses.create` on sync and async clients (Azure clients included). Cached-token accounting from `usage.input_tokens_details`. Streaming and `chat.completions` calls pass through untraced. Message-content capture at parity with the Anthropic integration: per-message events (`gen_ai.system/user/assistant/tool.message`) and `gen_ai.choice`, plus `gen_ai.input.messages` / `gen_ai.output.messages` in span-content mode. Reasoning items are skipped (`encrypted_content` is never emitted). |
|
|
109
113
|
| `claude_agent_sdk` | `agent`, `chat`, `execute_tool` | Telemetry comes from Claude Code itself in the subprocess; ingest credentials are propagated to it via `ClaudeAgentOptions`. Subagents inherit the configuration. |
|
|
110
114
|
| `langchain_core` `BaseChatModel` | `chat {model}` | Skipped when an underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic` → a single span). |
|
|
111
115
|
| `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# struct-sdk
|
|
2
2
|
|
|
3
3
|
OpenTelemetry instrumentation for AI agents in Python. Captures spans,
|
|
4
|
-
token usage, and message events from the Anthropic SDK, the
|
|
5
|
-
SDK, and LangChain / LangGraph, and
|
|
6
|
-
[struct.ai](https://struct.ai) for observability.
|
|
4
|
+
token usage, and message events from the Anthropic SDK, the OpenAI SDK
|
|
5
|
+
(Responses API), the Claude Agent SDK, and LangChain / LangGraph, and
|
|
6
|
+
exports them to [struct.ai](https://struct.ai) for observability.
|
|
7
7
|
|
|
8
8
|
A TypeScript version is available as
|
|
9
9
|
[`@struct-ai/sdk`](https://www.npmjs.com/package/@struct-ai/sdk). Both
|
|
@@ -16,6 +16,7 @@ mix languages without a fragmented view.
|
|
|
16
16
|
pip install struct-sdk
|
|
17
17
|
# optional — the SDK auto-instruments these if present
|
|
18
18
|
pip install anthropic
|
|
19
|
+
pip install openai
|
|
19
20
|
pip install claude-agent-sdk
|
|
20
21
|
pip install langchain-core langgraph
|
|
21
22
|
```
|
|
@@ -60,6 +61,7 @@ async with struct.agent(name="checkout"):
|
|
|
60
61
|
| Library | Span type | Notes |
|
|
61
62
|
|---|---|---|
|
|
62
63
|
| `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
|
|
64
|
+
| `openai` (Responses API) | `chat {model}` | Non-streaming `responses.create` on sync and async clients (Azure clients included). Cached-token accounting from `usage.input_tokens_details`. Streaming and `chat.completions` calls pass through untraced. Message-content capture at parity with the Anthropic integration: per-message events (`gen_ai.system/user/assistant/tool.message`) and `gen_ai.choice`, plus `gen_ai.input.messages` / `gen_ai.output.messages` in span-content mode. Reasoning items are skipped (`encrypted_content` is never emitted). |
|
|
63
65
|
| `claude_agent_sdk` | `agent`, `chat`, `execute_tool` | Telemetry comes from Claude Code itself in the subprocess; ingest credentials are propagated to it via `ClaudeAgentOptions`. Subagents inherit the configuration. |
|
|
64
66
|
| `langchain_core` `BaseChatModel` | `chat {model}` | Skipped when an underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic` → a single span). |
|
|
65
67
|
| `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "struct-sdk"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.12"
|
|
8
8
|
description = "Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = { text = "Apache-2.0" }
|
|
12
12
|
authors = [{ name = "Struct", email = "support@struct.ai" }]
|
|
13
|
-
keywords = ["struct", "observability", "opentelemetry", "anthropic", "langchain", "agent", "ai", "tracing"]
|
|
13
|
+
keywords = ["struct", "observability", "opentelemetry", "anthropic", "openai", "langchain", "agent", "ai", "tracing"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 4 - Beta",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
@@ -39,6 +39,8 @@ Issues = "https://struct.ai/support"
|
|
|
39
39
|
anthropic = ["anthropic>=0.30.0"]
|
|
40
40
|
claude-agent-sdk = ["claude-agent-sdk>=0.1.59"]
|
|
41
41
|
langchain = ["langchain-core>=1.3.3"]
|
|
42
|
+
# Responses API (openai.resources.responses) exists from 1.66.0 onward.
|
|
43
|
+
openai = ["openai>=1.66.0"]
|
|
42
44
|
demo = [
|
|
43
45
|
"langchain>=1.3.9",
|
|
44
46
|
"langchain-core>=1.3.3",
|
|
@@ -60,6 +62,12 @@ dev = [
|
|
|
60
62
|
"pytest-asyncio>=1.3.0",
|
|
61
63
|
"mypy>=1.10",
|
|
62
64
|
"ruff>=0.5",
|
|
65
|
+
# The OpenAI conformance suite (tests/test_openai_conformance.py) exercises
|
|
66
|
+
# the REAL openai client with HTTP mocked at the httpx transport boundary,
|
|
67
|
+
# so both must be present in the default test environment (CI runs
|
|
68
|
+
# `uv run pytest` with no extras).
|
|
69
|
+
"openai>=1.66.0",
|
|
70
|
+
"httpx>=0.27.0",
|
|
63
71
|
]
|
|
64
72
|
|
|
65
73
|
[tool.uv]
|
|
@@ -81,7 +89,7 @@ markers = [
|
|
|
81
89
|
|
|
82
90
|
[tool.mypy]
|
|
83
91
|
[[tool.mypy.overrides]]
|
|
84
|
-
module = ["anthropic", "anthropic.*", "claude_agent_sdk", "claude_agent_sdk.*", "langchain_core", "langchain_core.*", "langchain", "langchain.*", "langgraph", "langgraph.*"]
|
|
92
|
+
module = ["anthropic", "anthropic.*", "claude_agent_sdk", "claude_agent_sdk.*", "langchain_core", "langchain_core.*", "langchain", "langchain.*", "langgraph", "langgraph.*", "openai", "openai.*"]
|
|
85
93
|
ignore_missing_imports = true
|
|
86
94
|
|
|
87
95
|
[tool.hatch.build.targets.wheel]
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Provider-agnostic GenAI content primitives — shared by the anthropic and
|
|
2
|
+
openai integrations.
|
|
3
|
+
|
|
4
|
+
This module is the single home for the pieces that are genuinely the same
|
|
5
|
+
across providers once a message/response has been mapped to the spec's
|
|
6
|
+
``{"role", "parts"}`` shape:
|
|
7
|
+
|
|
8
|
+
- Truncation constants + helpers (``truncate_field`` / ``truncate_parts`` /
|
|
9
|
+
``truncate_and_serialize`` / ``safe_json``).
|
|
10
|
+
- The low-level per-message and choice LogRecord emitters
|
|
11
|
+
(``emit_message_event`` / ``emit_choice_event``) — they take already-built
|
|
12
|
+
``parts`` and a ``provider`` string, so the OTel logs data-model wiring
|
|
13
|
+
(``body`` = event tag, ``attributes['body']`` = JSON payload, span-context
|
|
14
|
+
from the EXPLICIT span) lives in exactly one place.
|
|
15
|
+
- ``propagate_user_prompt_to_parent`` — write-once parent ``gen_ai.input.messages``
|
|
16
|
+
from the last user message's parts.
|
|
17
|
+
|
|
18
|
+
The per-provider MAPPER (Anthropic message blocks vs OpenAI Responses items ->
|
|
19
|
+
``parts``) is the only content code that stays provider-specific; it lives in
|
|
20
|
+
``anthropic.py`` / ``openai.py`` and calls into this module.
|
|
21
|
+
|
|
22
|
+
Both this bug (OpenAI emitting zero content) and the earlier reset bug came from
|
|
23
|
+
``openai.py`` diverging from ``anthropic.py``; sharing these primitives is the
|
|
24
|
+
structural fix. Everything here is written to NEVER fault the customer call —
|
|
25
|
+
each public emitter is wrapped in try/except -> ``logger.debug``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import time
|
|
33
|
+
from typing import Any, Optional
|
|
34
|
+
|
|
35
|
+
from opentelemetry import trace
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("struct_sdk._genai_content")
|
|
38
|
+
|
|
39
|
+
# Max size for content attributes (JSON strings on spans).
|
|
40
|
+
_MAX_CONTENT_SIZE = 128 * 1024 # 128KB — generous limit for full message capture
|
|
41
|
+
_TRUNCATION_MARKER = "… [truncated]"
|
|
42
|
+
# Per-field cap: individual text/content fields longer than this get truncated.
|
|
43
|
+
_MAX_FIELD_SIZE = 16384 # 16KB per field
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Truncation / serialization
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def truncate_field(value: "str | Any", max_len: int = _MAX_FIELD_SIZE) -> "str | Any":
|
|
51
|
+
"""Truncate a single string field if it exceeds ``max_len``."""
|
|
52
|
+
if isinstance(value, str) and len(value) > max_len:
|
|
53
|
+
return value[:max_len] + _TRUNCATION_MARKER
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def truncate_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
58
|
+
"""Truncate content fields within message parts (content/arguments/response)."""
|
|
59
|
+
result = []
|
|
60
|
+
for part in parts:
|
|
61
|
+
part = dict(part) # shallow copy
|
|
62
|
+
if "content" in part and isinstance(part["content"], str):
|
|
63
|
+
part["content"] = truncate_field(part["content"])
|
|
64
|
+
if "arguments" in part:
|
|
65
|
+
arg = part["arguments"]
|
|
66
|
+
if isinstance(arg, str):
|
|
67
|
+
part["arguments"] = truncate_field(arg)
|
|
68
|
+
elif isinstance(arg, dict):
|
|
69
|
+
serialized = json.dumps(arg, default=str)
|
|
70
|
+
if len(serialized) > _MAX_FIELD_SIZE:
|
|
71
|
+
part["arguments"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
|
|
72
|
+
if "response" in part:
|
|
73
|
+
resp = part["response"]
|
|
74
|
+
if isinstance(resp, str):
|
|
75
|
+
part["response"] = truncate_field(resp)
|
|
76
|
+
elif isinstance(resp, (dict, list)):
|
|
77
|
+
serialized = json.dumps(resp, default=str)
|
|
78
|
+
if len(serialized) > _MAX_FIELD_SIZE:
|
|
79
|
+
part["response"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
|
|
80
|
+
result.append(part)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
|
|
85
|
+
"""Truncate individual fields in message structures, then serialize.
|
|
86
|
+
|
|
87
|
+
Unlike a blind string slice, this preserves valid JSON by truncating
|
|
88
|
+
content/arguments/response fields within parts before serialization.
|
|
89
|
+
If the result still exceeds ``max_size`` after field truncation, applies
|
|
90
|
+
a final hard cap that tries to keep the JSON array parseable.
|
|
91
|
+
"""
|
|
92
|
+
if isinstance(obj, list):
|
|
93
|
+
truncated = []
|
|
94
|
+
for item in obj:
|
|
95
|
+
if isinstance(item, dict):
|
|
96
|
+
item = dict(item) # shallow copy
|
|
97
|
+
if "parts" in item and isinstance(item["parts"], list):
|
|
98
|
+
item["parts"] = truncate_parts(item["parts"])
|
|
99
|
+
elif "content" in item and isinstance(item["content"], str):
|
|
100
|
+
# Top-level content (e.g. system_instructions format)
|
|
101
|
+
item["content"] = truncate_field(item["content"])
|
|
102
|
+
truncated.append(item)
|
|
103
|
+
result = json.dumps(truncated, default=str)
|
|
104
|
+
else:
|
|
105
|
+
result = json.dumps(obj, default=str)
|
|
106
|
+
|
|
107
|
+
# If still too large after field-level truncation, do a final hard cap
|
|
108
|
+
# but try to close the JSON array to keep it parseable.
|
|
109
|
+
if len(result) > max_size:
|
|
110
|
+
cut = result[:max_size - 50] # leave room for closing
|
|
111
|
+
last_brace = cut.rfind("}")
|
|
112
|
+
if last_brace > 0:
|
|
113
|
+
result = cut[:last_brace + 1] + "]"
|
|
114
|
+
else:
|
|
115
|
+
result = "[]"
|
|
116
|
+
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def safe_json(obj: Any) -> str:
|
|
121
|
+
"""Safely serialize to JSON string with field-level truncation."""
|
|
122
|
+
try:
|
|
123
|
+
return truncate_and_serialize(obj)
|
|
124
|
+
except Exception:
|
|
125
|
+
return "[]"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# LogRecord emission — OTel logs data model
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def emit_message_event(
|
|
133
|
+
otel_logger: Any,
|
|
134
|
+
*,
|
|
135
|
+
role: str,
|
|
136
|
+
parts: list[dict[str, Any]],
|
|
137
|
+
event_name: str,
|
|
138
|
+
provider: str,
|
|
139
|
+
message_index: int,
|
|
140
|
+
span: Optional[trace.Span] = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Emit ONE per-message LogRecord linked to ``span``.
|
|
143
|
+
|
|
144
|
+
OTel logs data-model convention:
|
|
145
|
+
- ``body`` (log record body): the event tag (``gen_ai.user.message`` etc.).
|
|
146
|
+
- ``attributes['body']``: the JSON payload ``{"role": ..., "parts": [...]}``.
|
|
147
|
+
- Other attributes: ``event.name``, ``gen_ai.provider.name``,
|
|
148
|
+
``gen_ai.message.index``, and ``gen_ai.conversation.id`` when a session is
|
|
149
|
+
active.
|
|
150
|
+
|
|
151
|
+
Span-context is taken from the EXPLICIT ``span`` when given —
|
|
152
|
+
``trace.get_current_span()`` can drop the active span across the generator
|
|
153
|
+
wrapper's async awaits. All work is best-effort: a failure here NEVER
|
|
154
|
+
reaches the customer's call (it becomes a ``logger.debug``).
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
from opentelemetry._logs import LogRecord, SeverityNumber
|
|
158
|
+
|
|
159
|
+
span_ctx = (span or trace.get_current_span()).get_span_context()
|
|
160
|
+
from struct_sdk.core import _current_session_id
|
|
161
|
+
session_id = _current_session_id.get(None)
|
|
162
|
+
|
|
163
|
+
payload = json.dumps({"role": role, "parts": truncate_parts(parts)}, default=str)
|
|
164
|
+
attrs: dict[str, Any] = {
|
|
165
|
+
"event.name": event_name,
|
|
166
|
+
"body": payload,
|
|
167
|
+
"gen_ai.provider.name": provider,
|
|
168
|
+
"gen_ai.message.index": message_index,
|
|
169
|
+
}
|
|
170
|
+
if session_id:
|
|
171
|
+
attrs["gen_ai.conversation.id"] = session_id
|
|
172
|
+
|
|
173
|
+
otel_logger.emit(LogRecord(
|
|
174
|
+
timestamp=int(time.time_ns()),
|
|
175
|
+
trace_id=span_ctx.trace_id,
|
|
176
|
+
span_id=span_ctx.span_id,
|
|
177
|
+
trace_flags=span_ctx.trace_flags,
|
|
178
|
+
severity_number=SeverityNumber.INFO,
|
|
179
|
+
body=event_name,
|
|
180
|
+
attributes=attrs,
|
|
181
|
+
))
|
|
182
|
+
except Exception:
|
|
183
|
+
logger.debug("Failed to emit message event", exc_info=True)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def emit_choice_event(
|
|
187
|
+
otel_logger: Any,
|
|
188
|
+
*,
|
|
189
|
+
parts: list[dict[str, Any]],
|
|
190
|
+
finish_reason: str,
|
|
191
|
+
provider: str,
|
|
192
|
+
span: Optional[trace.Span] = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Emit a ``gen_ai.choice`` LogRecord for the assistant's response.
|
|
195
|
+
|
|
196
|
+
``parts`` are already spec-shaped assistant parts and ``finish_reason`` is
|
|
197
|
+
already mapped to the spec name by the caller (mapping is provider-specific).
|
|
198
|
+
The ``choice`` event omits ``gen_ai.message.index``.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
from opentelemetry._logs import LogRecord, SeverityNumber
|
|
202
|
+
|
|
203
|
+
span_ctx = (span or trace.get_current_span()).get_span_context()
|
|
204
|
+
from struct_sdk.core import _current_session_id
|
|
205
|
+
session_id = _current_session_id.get(None)
|
|
206
|
+
|
|
207
|
+
choice_body = {
|
|
208
|
+
"index": 0,
|
|
209
|
+
"finish_reason": finish_reason or "stop",
|
|
210
|
+
"message": {"role": "assistant", "parts": truncate_parts(parts)},
|
|
211
|
+
}
|
|
212
|
+
payload = json.dumps(choice_body, default=str)
|
|
213
|
+
event_name = "gen_ai.choice"
|
|
214
|
+
attrs: dict[str, Any] = {
|
|
215
|
+
"event.name": event_name,
|
|
216
|
+
"body": payload,
|
|
217
|
+
"gen_ai.provider.name": provider,
|
|
218
|
+
}
|
|
219
|
+
if session_id:
|
|
220
|
+
attrs["gen_ai.conversation.id"] = session_id
|
|
221
|
+
|
|
222
|
+
otel_logger.emit(LogRecord(
|
|
223
|
+
timestamp=int(time.time_ns()),
|
|
224
|
+
trace_id=span_ctx.trace_id,
|
|
225
|
+
span_id=span_ctx.span_id,
|
|
226
|
+
trace_flags=span_ctx.trace_flags,
|
|
227
|
+
severity_number=SeverityNumber.INFO,
|
|
228
|
+
body=event_name,
|
|
229
|
+
attributes=attrs,
|
|
230
|
+
))
|
|
231
|
+
except Exception:
|
|
232
|
+
logger.debug("Failed to emit choice event", exc_info=True)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Parent-span propagation
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def propagate_user_prompt_to_parent(last_user_parts: Optional[list[dict[str, Any]]]) -> None:
|
|
240
|
+
"""Set the last user message on the parent invoke_agent span (write-once).
|
|
241
|
+
|
|
242
|
+
This lets the waterfall UI show what the user asked without drilling into
|
|
243
|
+
the child chat span. Provider-agnostic: the caller extracts the last user
|
|
244
|
+
message's spec parts (Anthropic message blocks vs Responses items differ);
|
|
245
|
+
this just stamps them once on the ambient ``_current_agent_span``.
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
if not last_user_parts:
|
|
249
|
+
return
|
|
250
|
+
from struct_sdk.core import _current_agent_span
|
|
251
|
+
agent_span = _current_agent_span.get(None)
|
|
252
|
+
if agent_span is None:
|
|
253
|
+
return
|
|
254
|
+
# Only set once — the first chat call within an invoke_agent scope wins.
|
|
255
|
+
existing = None
|
|
256
|
+
if hasattr(agent_span, "attributes"):
|
|
257
|
+
existing = agent_span.attributes.get("gen_ai.input.messages") # type: ignore[union-attr]
|
|
258
|
+
if existing:
|
|
259
|
+
return
|
|
260
|
+
agent_span.set_attribute(
|
|
261
|
+
"gen_ai.input.messages",
|
|
262
|
+
truncate_and_serialize([{"role": "user", "parts": last_user_parts}]),
|
|
263
|
+
)
|
|
264
|
+
except Exception:
|
|
265
|
+
pass # Never break the application for telemetry
|