struct-sdk 0.1.0__tar.gz → 0.2.1__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.1.0 → struct_sdk-0.2.1}/PKG-INFO +52 -6
- {struct_sdk-0.1.0 → struct_sdk-0.2.1}/README.md +48 -3
- {struct_sdk-0.1.0 → struct_sdk-0.2.1}/pyproject.toml +7 -6
- {struct_sdk-0.1.0 → struct_sdk-0.2.1}/src/struct_sdk/anthropic.py +74 -9
- {struct_sdk-0.1.0 → struct_sdk-0.2.1}/src/struct_sdk/core.py +21 -0
- {struct_sdk-0.1.0 → struct_sdk-0.2.1}/src/struct_sdk/langchain.py +157 -14
- {struct_sdk-0.1.0 → struct_sdk-0.2.1}/.gitignore +0 -0
- {struct_sdk-0.1.0 → struct_sdk-0.2.1}/LICENSE +0 -0
- {struct_sdk-0.1.0 → struct_sdk-0.2.1}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.1.0 → struct_sdk-0.2.1}/src/struct_sdk/claude_agent.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: struct-sdk
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
@@ -30,8 +30,9 @@ Provides-Extra: claude-agent-sdk
|
|
|
30
30
|
Requires-Dist: claude-agent-sdk>=0.1.59; extra == 'claude-agent-sdk'
|
|
31
31
|
Provides-Extra: demo
|
|
32
32
|
Requires-Dist: langchain-anthropic>=0.3.0; extra == 'demo'
|
|
33
|
-
Requires-Dist: langchain-core>=
|
|
33
|
+
Requires-Dist: langchain-core>=1.3.3; extra == 'demo'
|
|
34
34
|
Requires-Dist: langchain-openai>=0.2.0; extra == 'demo'
|
|
35
|
+
Requires-Dist: langchain>=1.3.0; extra == 'demo'
|
|
35
36
|
Requires-Dist: langgraph>=0.2.0; extra == 'demo'
|
|
36
37
|
Requires-Dist: python-dotenv>=1.0.0; extra == 'demo'
|
|
37
38
|
Provides-Extra: dev
|
|
@@ -40,7 +41,7 @@ Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
|
|
|
40
41
|
Requires-Dist: pytest>=9.0.3; extra == 'dev'
|
|
41
42
|
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
42
43
|
Provides-Extra: langchain
|
|
43
|
-
Requires-Dist: langchain-core>=
|
|
44
|
+
Requires-Dist: langchain-core>=1.3.3; extra == 'langchain'
|
|
44
45
|
Description-Content-Type: text/markdown
|
|
45
46
|
|
|
46
47
|
# struct-sdk
|
|
@@ -107,7 +108,7 @@ async with struct.agent(name="checkout"):
|
|
|
107
108
|
| `langchain_core` `BaseChatModel` | `chat {model}` | Skipped when an underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic` → a single span). |
|
|
108
109
|
| `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
|
|
109
110
|
| `langchain_core` `BaseRetriever` | `retrieval {name}` | |
|
|
110
|
-
| `langgraph` `Pregel` | `invoke_agent {name}` | Covers `create_react_agent` and custom graphs. `thread_id`
|
|
111
|
+
| `langgraph` `Pregel` | `invoke_agent {name}` | Covers `create_react_agent`, `langchain.agents.create_agent`, and custom graphs. Reads conversation id from any of: `configurable.thread_id` (LangGraph canonical), or `metadata.{thread_id, session_id, conversation_id}` (LangSmith conventions). For multi-turn HTTP-style threading, wrap your entry point in [`struct.agent(session_id=conv_id)`](#recommended-pattern-wrap-langchain-entry-points-in-structagent) — that's the struct-native replacement for `ls.tracing_context(parent=run_tree)`. |
|
|
111
112
|
|
|
112
113
|
## Framework integration
|
|
113
114
|
|
|
@@ -155,6 +156,40 @@ from langgraph.prebuilt import create_react_agent
|
|
|
155
156
|
# execute_tool spans. BaseRetriever.invoke gets retrieval spans.
|
|
156
157
|
```
|
|
157
158
|
|
|
159
|
+
##### Recommended pattern: wrap LangChain entry points in `struct.agent`
|
|
160
|
+
|
|
161
|
+
For multi-turn HTTP-style usage (every request continues the same
|
|
162
|
+
conversation), wrap your request handler in
|
|
163
|
+
`struct.agent(session_id=conversation_id)`. This is the struct-native
|
|
164
|
+
replacement for `with ls.tracing_context(parent=run_tree):` and gives
|
|
165
|
+
you two things you can't get from `configurable.thread_id` alone:
|
|
166
|
+
|
|
167
|
+
1. **Threading without per-call config plumbing.** Every nested
|
|
168
|
+
LangChain call inherits the conversation id via the SDK's ambient
|
|
169
|
+
contextvar — you don't have to ensure each `compiled_graph.ainvoke`
|
|
170
|
+
gets `thread_id` on its config.
|
|
171
|
+
2. **One trace per request.** `struct.agent` creates a parent OTel span
|
|
172
|
+
so all the LangChain work for the request nests under one trace
|
|
173
|
+
(clean tree, "Subagents" / "Spawned by" UI links work). Without it,
|
|
174
|
+
each `.invoke()` becomes its own root trace, and the UI's session
|
|
175
|
+
list shows a non-deterministic agent name (`omni_agent`,
|
|
176
|
+
`LangGraph`, the first sub-agent it sees…).
|
|
177
|
+
|
|
178
|
+
Migrating from LangSmith:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
# Before — LangSmith convention, fragments under struct-sdk
|
|
182
|
+
with ls.tracing_context(parent=run_tree):
|
|
183
|
+
await orchestrator.ainvoke(inputs, config=config)
|
|
184
|
+
|
|
185
|
+
# After — struct-native, threads correctly, no langsmith dep
|
|
186
|
+
async with struct.agent(session_id=conversation_id):
|
|
187
|
+
await orchestrator.ainvoke(inputs, config=config)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Also works as a sync context manager (`with struct.agent(...)`) for
|
|
191
|
+
non-async handlers.
|
|
192
|
+
|
|
158
193
|
### LLM SDKs used directly — manual agent + tool scopes required
|
|
159
194
|
|
|
160
195
|
When you call an LLM SDK directly (no agent framework wrapping it), only
|
|
@@ -268,8 +303,19 @@ async def run_checkout(order_id: str):
|
|
|
268
303
|
return await charge()
|
|
269
304
|
```
|
|
270
305
|
|
|
271
|
-
|
|
272
|
-
|
|
306
|
+
Sub-agents (e.g. a `create_agent` graph invoked from inside another
|
|
307
|
+
agent's tool body) record their parent via the
|
|
308
|
+
`struct.agent.parent_session_id` attribute on the inner `invoke_agent`
|
|
309
|
+
span. This powers the UI's "Spawned by" backlink, which works for any
|
|
310
|
+
nested invocation.
|
|
311
|
+
|
|
312
|
+
The parent's "Subagents" forward list — the inverse direction —
|
|
313
|
+
additionally requires that the nested invoke shares the outer agent's
|
|
314
|
+
trace. This works automatically when the outer tool is built with the
|
|
315
|
+
`@tool` decorator (or any callback-aware wrapping) since the nested
|
|
316
|
+
invoke inherits the parent's run state. Bare `Tool(...)` constructors
|
|
317
|
+
that bypass the callback chain can break the forward link; the
|
|
318
|
+
backlink still renders.
|
|
273
319
|
|
|
274
320
|
## Semantic conventions
|
|
275
321
|
|
|
@@ -62,7 +62,7 @@ async with struct.agent(name="checkout"):
|
|
|
62
62
|
| `langchain_core` `BaseChatModel` | `chat {model}` | Skipped when an underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic` → a single span). |
|
|
63
63
|
| `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
|
|
64
64
|
| `langchain_core` `BaseRetriever` | `retrieval {name}` | |
|
|
65
|
-
| `langgraph` `Pregel` | `invoke_agent {name}` | Covers `create_react_agent` and custom graphs. `thread_id`
|
|
65
|
+
| `langgraph` `Pregel` | `invoke_agent {name}` | Covers `create_react_agent`, `langchain.agents.create_agent`, and custom graphs. Reads conversation id from any of: `configurable.thread_id` (LangGraph canonical), or `metadata.{thread_id, session_id, conversation_id}` (LangSmith conventions). For multi-turn HTTP-style threading, wrap your entry point in [`struct.agent(session_id=conv_id)`](#recommended-pattern-wrap-langchain-entry-points-in-structagent) — that's the struct-native replacement for `ls.tracing_context(parent=run_tree)`. |
|
|
66
66
|
|
|
67
67
|
## Framework integration
|
|
68
68
|
|
|
@@ -110,6 +110,40 @@ from langgraph.prebuilt import create_react_agent
|
|
|
110
110
|
# execute_tool spans. BaseRetriever.invoke gets retrieval spans.
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
##### Recommended pattern: wrap LangChain entry points in `struct.agent`
|
|
114
|
+
|
|
115
|
+
For multi-turn HTTP-style usage (every request continues the same
|
|
116
|
+
conversation), wrap your request handler in
|
|
117
|
+
`struct.agent(session_id=conversation_id)`. This is the struct-native
|
|
118
|
+
replacement for `with ls.tracing_context(parent=run_tree):` and gives
|
|
119
|
+
you two things you can't get from `configurable.thread_id` alone:
|
|
120
|
+
|
|
121
|
+
1. **Threading without per-call config plumbing.** Every nested
|
|
122
|
+
LangChain call inherits the conversation id via the SDK's ambient
|
|
123
|
+
contextvar — you don't have to ensure each `compiled_graph.ainvoke`
|
|
124
|
+
gets `thread_id` on its config.
|
|
125
|
+
2. **One trace per request.** `struct.agent` creates a parent OTel span
|
|
126
|
+
so all the LangChain work for the request nests under one trace
|
|
127
|
+
(clean tree, "Subagents" / "Spawned by" UI links work). Without it,
|
|
128
|
+
each `.invoke()` becomes its own root trace, and the UI's session
|
|
129
|
+
list shows a non-deterministic agent name (`omni_agent`,
|
|
130
|
+
`LangGraph`, the first sub-agent it sees…).
|
|
131
|
+
|
|
132
|
+
Migrating from LangSmith:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
# Before — LangSmith convention, fragments under struct-sdk
|
|
136
|
+
with ls.tracing_context(parent=run_tree):
|
|
137
|
+
await orchestrator.ainvoke(inputs, config=config)
|
|
138
|
+
|
|
139
|
+
# After — struct-native, threads correctly, no langsmith dep
|
|
140
|
+
async with struct.agent(session_id=conversation_id):
|
|
141
|
+
await orchestrator.ainvoke(inputs, config=config)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Also works as a sync context manager (`with struct.agent(...)`) for
|
|
145
|
+
non-async handlers.
|
|
146
|
+
|
|
113
147
|
### LLM SDKs used directly — manual agent + tool scopes required
|
|
114
148
|
|
|
115
149
|
When you call an LLM SDK directly (no agent framework wrapping it), only
|
|
@@ -223,8 +257,19 @@ async def run_checkout(order_id: str):
|
|
|
223
257
|
return await charge()
|
|
224
258
|
```
|
|
225
259
|
|
|
226
|
-
|
|
227
|
-
|
|
260
|
+
Sub-agents (e.g. a `create_agent` graph invoked from inside another
|
|
261
|
+
agent's tool body) record their parent via the
|
|
262
|
+
`struct.agent.parent_session_id` attribute on the inner `invoke_agent`
|
|
263
|
+
span. This powers the UI's "Spawned by" backlink, which works for any
|
|
264
|
+
nested invocation.
|
|
265
|
+
|
|
266
|
+
The parent's "Subagents" forward list — the inverse direction —
|
|
267
|
+
additionally requires that the nested invoke shares the outer agent's
|
|
268
|
+
trace. This works automatically when the outer tool is built with the
|
|
269
|
+
`@tool` decorator (or any callback-aware wrapping) since the nested
|
|
270
|
+
invoke inherits the parent's run state. Bare `Tool(...)` constructors
|
|
271
|
+
that bypass the callback chain can break the forward link; the
|
|
272
|
+
backlink still renders.
|
|
228
273
|
|
|
229
274
|
## Semantic conventions
|
|
230
275
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "struct-sdk"
|
|
7
|
-
version = "0.1
|
|
7
|
+
version = "0.2.1"
|
|
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"
|
|
@@ -38,9 +38,10 @@ Issues = "https://struct.ai/support"
|
|
|
38
38
|
[project.optional-dependencies]
|
|
39
39
|
anthropic = ["anthropic>=0.30.0"]
|
|
40
40
|
claude-agent-sdk = ["claude-agent-sdk>=0.1.59"]
|
|
41
|
-
langchain = ["langchain-core>=
|
|
41
|
+
langchain = ["langchain-core>=1.3.3"]
|
|
42
42
|
demo = [
|
|
43
|
-
"langchain
|
|
43
|
+
"langchain>=1.3.0",
|
|
44
|
+
"langchain-core>=1.3.3",
|
|
44
45
|
"langchain-openai>=0.2.0",
|
|
45
46
|
"langchain-anthropic>=0.3.0",
|
|
46
47
|
"langgraph>=0.2.0",
|
|
@@ -64,10 +65,10 @@ dev = [
|
|
|
64
65
|
[tool.uv]
|
|
65
66
|
override-dependencies = [
|
|
66
67
|
"cryptography>=46.0.7",
|
|
67
|
-
"langgraph>=1.0
|
|
68
|
-
"langgraph-checkpoint>=4.0.0",
|
|
68
|
+
"langgraph>=1.2.0,<1.3.0",
|
|
69
|
+
"langgraph-checkpoint>=4.1.0,<5.0.0",
|
|
69
70
|
"langchain-text-splitters>=1.1.2",
|
|
70
|
-
"python-multipart>=0.0.
|
|
71
|
+
"python-multipart>=0.0.27",
|
|
71
72
|
]
|
|
72
73
|
|
|
73
74
|
[tool.mypy]
|
|
@@ -116,11 +116,49 @@ def _create_common(
|
|
|
116
116
|
extraction, error-path attribute writes) are wrapped in ``_safe`` so a
|
|
117
117
|
failure inside instrumentation can never replace the user's response or
|
|
118
118
|
mask the user's API exception.
|
|
119
|
+
|
|
120
|
+
Two paths:
|
|
121
|
+
|
|
122
|
+
1. **Enrich** — when ``_current_langchain_chat_span`` is set, this call
|
|
123
|
+
is happening underneath a LangChain handler that's already created
|
|
124
|
+
a ``chat <model>`` span. We do NOT create our own span (that's the
|
|
125
|
+
duplicate-Anthropic-spans issue). Instead we attach HTTP-layer
|
|
126
|
+
attrs (the real provider msg_id, exact response_model, usage,
|
|
127
|
+
finish_reasons, error info on failure) onto the langchain span.
|
|
128
|
+
Pre-call attrs are skipped — LangChain already set them.
|
|
129
|
+
|
|
130
|
+
2. **Standalone** — no LangChain in the picture. Create our own span
|
|
131
|
+
and set the full attribute set as before.
|
|
119
132
|
"""
|
|
120
|
-
from struct_sdk.core import _safe
|
|
133
|
+
from struct_sdk.core import _safe, _current_langchain_chat_span
|
|
121
134
|
|
|
122
135
|
model = kwargs.get("model", "unknown")
|
|
123
136
|
|
|
137
|
+
# Enrich path: a LangChain handler upstream already created a ``chat
|
|
138
|
+
# <model>`` span for this call. Attach Anthropic HTTP-layer detail to it
|
|
139
|
+
# without creating a duplicate span.
|
|
140
|
+
host_span = _current_langchain_chat_span.get(None)
|
|
141
|
+
if host_span is not None:
|
|
142
|
+
try:
|
|
143
|
+
result = yield f, args, kwargs
|
|
144
|
+
except Exception as e:
|
|
145
|
+
# Capture the type name OUTSIDE the lambda — ``except X as e``
|
|
146
|
+
# binds ``e`` only for the duration of the except block, but
|
|
147
|
+
# ``_safe`` is opaque to static analysis (ruff flags F841 +
|
|
148
|
+
# F821 thinking the lambda outlives the binding). Snapshotting
|
|
149
|
+
# to a local makes the closure capture trivially correct.
|
|
150
|
+
err_type = type(e).__name__
|
|
151
|
+
_safe(
|
|
152
|
+
lambda: host_span.set_attribute("error.type", err_type),
|
|
153
|
+
site="anthropic.create.enrich.error_type",
|
|
154
|
+
)
|
|
155
|
+
raise
|
|
156
|
+
_safe(
|
|
157
|
+
lambda: _set_response_attrs(host_span, sdk, model, result, otel_logger),
|
|
158
|
+
site="anthropic.create.enrich.set_response_attrs",
|
|
159
|
+
)
|
|
160
|
+
return result # noqa: B901
|
|
161
|
+
|
|
124
162
|
with tracer.start_as_current_span(
|
|
125
163
|
f"chat {model}", kind=trace.SpanKind.CLIENT
|
|
126
164
|
) as span:
|
|
@@ -171,12 +209,27 @@ def _create_common(
|
|
|
171
209
|
try:
|
|
172
210
|
result = yield f, args, kwargs
|
|
173
211
|
except Exception as e:
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
212
|
+
# Snapshot ``e`` outside the lambdas — ``except X as e`` unbinds
|
|
213
|
+
# ``e`` at end-of-block, and ruff (correctly) flags closures that
|
|
214
|
+
# reference it. The lambdas run synchronously inside the except
|
|
215
|
+
# via ``_safe``, so this is mechanically equivalent — just legible
|
|
216
|
+
# to static analysis. ``type(exc).__name__`` is cheap and won't
|
|
217
|
+
# raise; ``str(exc)`` runs inside ``_safe`` so a broken
|
|
218
|
+
# ``__str__`` can't mask the original exception we're re-raising.
|
|
219
|
+
exc = e
|
|
220
|
+
err_type = type(exc).__name__
|
|
221
|
+
_safe(
|
|
222
|
+
lambda: span.set_attribute("error.type", err_type),
|
|
223
|
+
site="anthropic.create.error_type",
|
|
224
|
+
)
|
|
225
|
+
_safe(
|
|
226
|
+
lambda: span.set_status(StatusCode.ERROR, str(exc)),
|
|
227
|
+
site="anthropic.create.error_status",
|
|
228
|
+
)
|
|
229
|
+
_safe(
|
|
230
|
+
lambda: span.record_exception(exc),
|
|
231
|
+
site="anthropic.create.record_exception",
|
|
232
|
+
)
|
|
180
233
|
raise
|
|
181
234
|
_safe(lambda: _set_response_attrs(span, sdk, model, result, otel_logger),
|
|
182
235
|
site="anthropic.create.set_response_attrs")
|
|
@@ -273,9 +326,16 @@ def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logge
|
|
|
273
326
|
if is_async:
|
|
274
327
|
@functools.wraps(original)
|
|
275
328
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
276
|
-
from struct_sdk.core import _safe, _current_session_id
|
|
329
|
+
from struct_sdk.core import _safe, _current_session_id, _current_langchain_chat_span
|
|
277
330
|
model = kwargs.get("model", "unknown")
|
|
278
331
|
|
|
332
|
+
# Enrich path: a LangChain handler upstream already owns a chat
|
|
333
|
+
# span for this call. Don't create a duplicate; just pass through.
|
|
334
|
+
# (Stream end-handling will set response attrs on the host span
|
|
335
|
+
# when the LangChain handler's on_llm_end fires.)
|
|
336
|
+
if _current_langchain_chat_span.get(None) is not None:
|
|
337
|
+
return await original(*args, **kwargs) if _is_coroutine(original) else original(*args, **kwargs)
|
|
338
|
+
|
|
279
339
|
span: Optional[trace.Span] = None
|
|
280
340
|
|
|
281
341
|
def start() -> None:
|
|
@@ -318,9 +378,14 @@ def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logge
|
|
|
318
378
|
else:
|
|
319
379
|
@functools.wraps(original)
|
|
320
380
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
321
|
-
from struct_sdk.core import _safe, _current_session_id
|
|
381
|
+
from struct_sdk.core import _safe, _current_session_id, _current_langchain_chat_span
|
|
322
382
|
model = kwargs.get("model", "unknown")
|
|
323
383
|
|
|
384
|
+
# Enrich path: a LangChain handler upstream already owns a chat
|
|
385
|
+
# span for this call. Don't create a duplicate; just pass through.
|
|
386
|
+
if _current_langchain_chat_span.get(None) is not None:
|
|
387
|
+
return original(*args, **kwargs)
|
|
388
|
+
|
|
324
389
|
span: Optional[trace.Span] = None
|
|
325
390
|
|
|
326
391
|
def start() -> None:
|
|
@@ -47,6 +47,27 @@ _current_session_id: contextvars.ContextVar[Optional[str]] = contextvars.Context
|
|
|
47
47
|
_current_conversation_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("_current_conversation_id", default=None)
|
|
48
48
|
_current_agent_span: contextvars.ContextVar[Optional[trace.Span]] = contextvars.ContextVar("_current_agent_span", default=None)
|
|
49
49
|
|
|
50
|
+
# When the LangChain handler is creating a ``chat <model>`` span for an LLM
|
|
51
|
+
# call that LangChain will dispatch through a provider SDK (anthropic,
|
|
52
|
+
# openai, etc.) that we ALSO instrument, set this contextvar to the
|
|
53
|
+
# in-progress langchain chat span. Provider-SDK instrumentations check it
|
|
54
|
+
# at the top of their ``messages.create`` / equivalent wrapper:
|
|
55
|
+
#
|
|
56
|
+
# - If set: enrich the existing langchain span with HTTP-layer attributes
|
|
57
|
+
# (real provider response.id, exact retries, rate-limit headers, etc.)
|
|
58
|
+
# and SKIP creating their own span — there's already a span for this
|
|
59
|
+
# call, we just want to attach more data to it.
|
|
60
|
+
#
|
|
61
|
+
# - If not set: this is a standalone provider-SDK invocation (no LangChain
|
|
62
|
+
# in the picture); the provider instrumentation creates its own span as
|
|
63
|
+
# usual.
|
|
64
|
+
#
|
|
65
|
+
# This eliminates the duplicate-span / orphan-Anthropic-span problem while
|
|
66
|
+
# preserving both layers' data on a single span.
|
|
67
|
+
_current_langchain_chat_span: contextvars.ContextVar[Optional[trace.Span]] = contextvars.ContextVar(
|
|
68
|
+
"_current_langchain_chat_span", default=None
|
|
69
|
+
)
|
|
70
|
+
|
|
50
71
|
# Pending tool_use ids keyed by tool name (FIFO per name).
|
|
51
72
|
# Populated by the Anthropic monkey-patch when a chat response arrives with
|
|
52
73
|
# tool_use blocks, consumed by @struct.tool() / struct.tool(...) when the
|
|
@@ -251,6 +251,7 @@ _AGENT_CLASSES = {
|
|
|
251
251
|
# ``serialized`` is usually ``None`` for these, so we filter on run_name via a
|
|
252
252
|
# denylist. Matches LangSmith's promotion heuristic.
|
|
253
253
|
_INTERNAL_RUN_NAMES = {
|
|
254
|
+
# Runnable wiring/plumbing
|
|
254
255
|
"RunnableSequence",
|
|
255
256
|
"RunnableLambda",
|
|
256
257
|
"RunnablePassthrough",
|
|
@@ -263,15 +264,31 @@ _INTERNAL_RUN_NAMES = {
|
|
|
263
264
|
"RunnableEach",
|
|
264
265
|
"RunnablePick",
|
|
265
266
|
"RunnableGenerator",
|
|
267
|
+
# Prompt templates
|
|
266
268
|
"Prompt",
|
|
267
269
|
"ChatPromptTemplate",
|
|
268
270
|
"PromptTemplate",
|
|
271
|
+
# langchain.agents (1.x) / legacy create_react_agent internal node names
|
|
269
272
|
"agent",
|
|
270
273
|
"tools",
|
|
271
274
|
"call_model",
|
|
272
275
|
"should_continue",
|
|
273
276
|
"__start__",
|
|
274
277
|
"__end__",
|
|
278
|
+
# Output parsers — invoked as Runnables but not agents. LangChain's
|
|
279
|
+
# ``langchain.agents.create_agent`` with ``ToolStrategy`` (or fallback
|
|
280
|
+
# from ``ProviderStrategy`` on models without native structured output)
|
|
281
|
+
# invokes these as a separate step and they fire on_chain_start.
|
|
282
|
+
"PydanticToolsParser",
|
|
283
|
+
"PydanticOutputParser",
|
|
284
|
+
"JsonOutputParser",
|
|
285
|
+
"JsonOutputToolsParser",
|
|
286
|
+
"JsonOutputKeyToolsParser",
|
|
287
|
+
"StrOutputParser",
|
|
288
|
+
"OutputParser",
|
|
289
|
+
"BaseOutputParser",
|
|
290
|
+
"OpenAIToolsAgentOutputParser",
|
|
291
|
+
"OpenAIFunctionsAgentOutputParser",
|
|
275
292
|
}
|
|
276
293
|
|
|
277
294
|
_INTERNAL_RUN_NAME_PREFIXES = (
|
|
@@ -281,17 +298,68 @@ _INTERNAL_RUN_NAME_PREFIXES = (
|
|
|
281
298
|
)
|
|
282
299
|
|
|
283
300
|
|
|
301
|
+
# Threading-id metadata keys, in resolution order.
|
|
302
|
+
#
|
|
303
|
+
# ``thread_id`` is LangGraph's canonical name (checkpointer key); ``session_id``
|
|
304
|
+
# and ``conversation_id`` are the LangSmith conventions documented at
|
|
305
|
+
# https://docs.langchain.com/langsmith/threads — when users tag a run with any
|
|
306
|
+
# of these, we treat it as the conversation grouping key. This makes
|
|
307
|
+
# struct-sdk drop-in compatible for users following either naming.
|
|
308
|
+
_THREAD_KEYS: tuple[str, ...] = ("thread_id", "session_id", "conversation_id")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _metadata_thread_id(metadata: Optional[dict[str, Any]]) -> Optional[str]:
|
|
312
|
+
"""Pull the conversation/thread id from a LangChain ``metadata`` dict.
|
|
313
|
+
|
|
314
|
+
Reads ``thread_id`` first (LangGraph canonical), then falls back to
|
|
315
|
+
``session_id`` and ``conversation_id`` (LangSmith conventions). Returns
|
|
316
|
+
``None`` if none are present as non-empty strings, so callers can chain
|
|
317
|
+
to their own fallbacks (ambient ``_current_session_id``, fresh UUID).
|
|
318
|
+
"""
|
|
319
|
+
if not metadata:
|
|
320
|
+
return None
|
|
321
|
+
for key in _THREAD_KEYS:
|
|
322
|
+
v = metadata.get(key)
|
|
323
|
+
if isinstance(v, str) and v:
|
|
324
|
+
return v
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
284
328
|
def _is_agent_chain(
|
|
285
329
|
serialized: Optional[dict[str, Any]],
|
|
286
330
|
run_type: Optional[str],
|
|
287
331
|
run_name: Optional[str],
|
|
332
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
288
333
|
) -> bool:
|
|
289
|
-
"""Only promote user-meaningful chains to ``invoke_agent`` spans.
|
|
334
|
+
"""Only promote user-meaningful chains to ``invoke_agent`` spans.
|
|
335
|
+
|
|
336
|
+
Decision order:
|
|
337
|
+
|
|
338
|
+
1. Explicit ``run_type='agent'`` (legacy AgentExecutor) → agent.
|
|
339
|
+
2. ``serialized`` class identifies a Pregel/CompiledStateGraph → agent.
|
|
340
|
+
3. ``metadata['langgraph_node'] == run_name`` → INTERNAL Pregel node
|
|
341
|
+
(every internal step of a ``create_agent`` Pregel fires on_chain_start
|
|
342
|
+
with metadata.langgraph_node set to its node name; for real top-level
|
|
343
|
+
agents or sub-agents the names differ or langgraph_node is absent).
|
|
344
|
+
4. Known LangChain plumbing run names (denylist) → not agent.
|
|
345
|
+
5. Otherwise, if there's a run_name → agent (user-named chain).
|
|
346
|
+
"""
|
|
290
347
|
if run_type == "agent":
|
|
291
348
|
return True
|
|
292
349
|
cls = _extract_class_name(serialized)
|
|
293
350
|
if cls in _AGENT_CLASSES:
|
|
294
351
|
return True
|
|
352
|
+
# Internal Pregel node detection: LangGraph populates metadata with
|
|
353
|
+
# ``langgraph_node`` (and ``langgraph_step``) on every internal node
|
|
354
|
+
# callback. The run_name of an internal node matches its langgraph_node;
|
|
355
|
+
# for the top-level Pregel invocation, langgraph_node is absent; for a
|
|
356
|
+
# sub-agent invoked from a tool body, langgraph_node may be set BUT
|
|
357
|
+
# contains the *parent's* node name (e.g. "tools"), which differs from
|
|
358
|
+
# the sub-agent's own run_name. So equality is the discriminator.
|
|
359
|
+
if metadata and run_name:
|
|
360
|
+
lg_node = metadata.get("langgraph_node")
|
|
361
|
+
if isinstance(lg_node, str) and lg_node and lg_node == run_name:
|
|
362
|
+
return False
|
|
295
363
|
if run_name:
|
|
296
364
|
if run_name in _INTERNAL_RUN_NAMES:
|
|
297
365
|
return False
|
|
@@ -526,7 +594,7 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
526
594
|
key = str(run_id)
|
|
527
595
|
parent_key = str(parent_run_id) if parent_run_id else None
|
|
528
596
|
|
|
529
|
-
if not _is_agent_chain(serialized or {}, run_type, run_name):
|
|
597
|
+
if not _is_agent_chain(serialized or {}, run_type, run_name, metadata):
|
|
530
598
|
# Skipped chain — record entry so descendants can walk the parent
|
|
531
599
|
# chain and find the nearest agent ancestor's session id.
|
|
532
600
|
effective_parent = self._resolve_parent(parent_key).span
|
|
@@ -590,10 +658,19 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
590
658
|
# Don't set gen_ai.agent.id from session_id — the spec uses agent.id
|
|
591
659
|
# for a stable agent-definition identifier, not per-invocation.
|
|
592
660
|
span.set_attribute("gen_ai.conversation.id", session_id)
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
)
|
|
661
|
+
# Always set parent_session_id when there's a parent agent — even
|
|
662
|
+
# if it matches our own session_id (which happens when the SDK's
|
|
663
|
+
# ambient ``_current_session_id`` propagates through nested
|
|
664
|
+
# invoke_agent spans, e.g. ``struct.agent(session_id=conv_id)``
|
|
665
|
+
# wrapping multiple inner agents).
|
|
666
|
+
#
|
|
667
|
+
# The attribute encodes "this span has a parent agent" as
|
|
668
|
+
# structural information — the UI uses it to decide whether to
|
|
669
|
+
# inline a subagent under its triggering tool call vs render it
|
|
670
|
+
# as a top-level turn. When the values match, this signals an
|
|
671
|
+
# *intentionally inlined* subagent; when they differ, the UI's
|
|
672
|
+
# existing "View sub-agent →" drill-in flow kicks in.
|
|
673
|
+
if parent_agent_session_id:
|
|
597
674
|
span.set_attribute("struct.agent.parent_session_id", parent_agent_session_id)
|
|
598
675
|
|
|
599
676
|
_safe(set_attrs, site="langchain.on_chain_start.start_attrs")
|
|
@@ -722,6 +799,17 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
722
799
|
|
|
723
800
|
_safe(set_attrs, site="langchain.on_chat_model_start.start_attrs")
|
|
724
801
|
|
|
802
|
+
# Announce this langchain chat span to any provider-SDK instrumentation
|
|
803
|
+
# (anthropic, etc.) that runs UNDER LangChain — they'll enrich this
|
|
804
|
+
# span with HTTP-layer attrs instead of creating their own duplicate.
|
|
805
|
+
# The token is saved on the RunState so on_llm_end can reset it.
|
|
806
|
+
from struct_sdk.core import _current_langchain_chat_span
|
|
807
|
+
enrich_token = None
|
|
808
|
+
try:
|
|
809
|
+
enrich_token = _current_langchain_chat_span.set(span)
|
|
810
|
+
except Exception: # noqa: BLE001 — never fail the host call on instrumentation
|
|
811
|
+
enrich_token = None
|
|
812
|
+
|
|
725
813
|
self._runs[key] = _RunState(
|
|
726
814
|
span=span,
|
|
727
815
|
effective_parent_span=span,
|
|
@@ -729,6 +817,7 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
729
817
|
nearest_agent_session_id=self._inherited_agent_session_id(parent_key),
|
|
730
818
|
nearest_agent_span=self._inherited_agent_span(parent_key),
|
|
731
819
|
kind="llm",
|
|
820
|
+
enrich_token=enrich_token,
|
|
732
821
|
)
|
|
733
822
|
|
|
734
823
|
def on_llm_start(
|
|
@@ -761,13 +850,31 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
761
850
|
parent_run_id: Optional[UUID] = None,
|
|
762
851
|
**kwargs: Any,
|
|
763
852
|
) -> None:
|
|
764
|
-
from struct_sdk.core import _safe
|
|
853
|
+
from struct_sdk.core import _safe, _current_langchain_chat_span
|
|
765
854
|
|
|
766
855
|
r = self._runs.pop(str(run_id), None)
|
|
767
856
|
if not r or not r.span:
|
|
857
|
+
# Even if the span was never created (telemetry-disabled fallback),
|
|
858
|
+
# we still need to reset the enrich-contextvar token so it doesn't
|
|
859
|
+
# leak into the next operation in this task.
|
|
860
|
+
if r is not None and r.enrich_token is not None:
|
|
861
|
+
_safe(
|
|
862
|
+
lambda: _current_langchain_chat_span.reset(r.enrich_token),
|
|
863
|
+
site="langchain.on_llm_end.reset_enrich_token",
|
|
864
|
+
)
|
|
768
865
|
return
|
|
769
866
|
span = r.span
|
|
770
867
|
|
|
868
|
+
# Reset the enrich-token contextvar BEFORE ending the span. Any
|
|
869
|
+
# post-end attribute set by provider-SDK instrumentation would race
|
|
870
|
+
# against ``span.end()`` and likely no-op anyway, so we close the
|
|
871
|
+
# door before we close the span.
|
|
872
|
+
if r.enrich_token is not None:
|
|
873
|
+
_safe(
|
|
874
|
+
lambda: _current_langchain_chat_span.reset(r.enrich_token),
|
|
875
|
+
site="langchain.on_llm_end.reset_enrich_token",
|
|
876
|
+
)
|
|
877
|
+
|
|
771
878
|
def set_response_attrs() -> None:
|
|
772
879
|
generations = getattr(response, "generations", None) or []
|
|
773
880
|
first = generations[0][0] if generations and generations[0] else None
|
|
@@ -811,10 +918,20 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
811
918
|
parent_run_id: Optional[UUID] = None,
|
|
812
919
|
**kwargs: Any,
|
|
813
920
|
) -> None:
|
|
814
|
-
from struct_sdk.core import _safe
|
|
921
|
+
from struct_sdk.core import _safe, _current_langchain_chat_span
|
|
815
922
|
|
|
816
923
|
r = self._runs.pop(str(run_id), None)
|
|
817
|
-
if not r
|
|
924
|
+
if not r:
|
|
925
|
+
return
|
|
926
|
+
# Always reset the enrich-token contextvar, even when there's no
|
|
927
|
+
# span — leaving it set would leak the (now-defunct) span into the
|
|
928
|
+
# next operation in this task.
|
|
929
|
+
if r.enrich_token is not None:
|
|
930
|
+
_safe(
|
|
931
|
+
lambda: _current_langchain_chat_span.reset(r.enrich_token),
|
|
932
|
+
site="langchain.on_llm_error.reset_enrich_token",
|
|
933
|
+
)
|
|
934
|
+
if not r.span:
|
|
818
935
|
return
|
|
819
936
|
span = r.span
|
|
820
937
|
_safe(lambda: _record_error(span, error),
|
|
@@ -1134,8 +1251,9 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
1134
1251
|
p = self._runs.get(parent_run_id)
|
|
1135
1252
|
if p and p.session_id:
|
|
1136
1253
|
return p.session_id
|
|
1137
|
-
|
|
1138
|
-
|
|
1254
|
+
thread_id = _metadata_thread_id(metadata)
|
|
1255
|
+
if thread_id:
|
|
1256
|
+
return thread_id
|
|
1139
1257
|
from struct_sdk.core import _current_session_id
|
|
1140
1258
|
ambient = _current_session_id.get(None)
|
|
1141
1259
|
if ambient:
|
|
@@ -1159,8 +1277,8 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
|
|
|
1159
1277
|
against the nearest-agent-ancestor's session and assign a fresh
|
|
1160
1278
|
UUID if they match.
|
|
1161
1279
|
"""
|
|
1162
|
-
thread_id = metadata
|
|
1163
|
-
if
|
|
1280
|
+
thread_id = _metadata_thread_id(metadata)
|
|
1281
|
+
if thread_id:
|
|
1164
1282
|
if parent_agent_session_id and thread_id == parent_agent_session_id:
|
|
1165
1283
|
return str(uuid.uuid4())
|
|
1166
1284
|
return thread_id
|
|
@@ -1186,6 +1304,13 @@ class _RunState:
|
|
|
1186
1304
|
"nearest_agent_session_id",
|
|
1187
1305
|
"nearest_agent_span",
|
|
1188
1306
|
"kind",
|
|
1307
|
+
# Only set on LLM / chat runs. Holds the ``contextvars.Token`` returned
|
|
1308
|
+
# by ``_current_langchain_chat_span.set(span)`` so on_llm_end /
|
|
1309
|
+
# on_llm_error can reset the contextvar. The contextvar's purpose:
|
|
1310
|
+
# tell provider-SDK instrumentations (anthropic, openai, etc.)
|
|
1311
|
+
# "you're running underneath this LangChain chat span — enrich it
|
|
1312
|
+
# with your HTTP-layer attrs, don't create a duplicate span."
|
|
1313
|
+
"enrich_token",
|
|
1189
1314
|
)
|
|
1190
1315
|
|
|
1191
1316
|
def __init__(
|
|
@@ -1197,6 +1322,7 @@ class _RunState:
|
|
|
1197
1322
|
nearest_agent_session_id: Optional[str],
|
|
1198
1323
|
nearest_agent_span: Optional[trace.Span],
|
|
1199
1324
|
kind: str,
|
|
1325
|
+
enrich_token: Any = None,
|
|
1200
1326
|
) -> None:
|
|
1201
1327
|
self.span = span
|
|
1202
1328
|
self.effective_parent_span = effective_parent_span
|
|
@@ -1204,6 +1330,7 @@ class _RunState:
|
|
|
1204
1330
|
self.nearest_agent_session_id = nearest_agent_session_id
|
|
1205
1331
|
self.nearest_agent_span = nearest_agent_span
|
|
1206
1332
|
self.kind = kind
|
|
1333
|
+
self.enrich_token = enrich_token
|
|
1207
1334
|
|
|
1208
1335
|
|
|
1209
1336
|
class _ParentInfo:
|
|
@@ -1282,7 +1409,23 @@ def _set_llm_response_attrs(
|
|
|
1282
1409
|
|
|
1283
1410
|
resp_id = getattr(message, "id", None) or resp_meta.get("id")
|
|
1284
1411
|
if isinstance(resp_id, str):
|
|
1285
|
-
|
|
1412
|
+
# If a provider-SDK instrumentation (e.g. struct-sdk-anthropic via
|
|
1413
|
+
# the enrich path) has already set gen_ai.response.id to the
|
|
1414
|
+
# real provider message id (e.g. ``msg_…``), don't clobber it
|
|
1415
|
+
# with LangChain's run UUID (``lc_run--…``). The provider id is
|
|
1416
|
+
# more useful for API-level debugging. LangChain's run UUID is
|
|
1417
|
+
# preserved under ``langchain.run.id`` for joining back to
|
|
1418
|
+
# LangSmith / LangChain run data.
|
|
1419
|
+
try:
|
|
1420
|
+
existing = (span.attributes or {}).get("gen_ai.response.id") \
|
|
1421
|
+
if hasattr(span, "attributes") else None
|
|
1422
|
+
except Exception: # noqa: BLE001
|
|
1423
|
+
existing = None
|
|
1424
|
+
if not existing:
|
|
1425
|
+
span.set_attribute("gen_ai.response.id", resp_id)
|
|
1426
|
+
elif resp_id != existing:
|
|
1427
|
+
# LangChain's id is distinct from the provider's — keep both.
|
|
1428
|
+
span.set_attribute("langchain.run.id", resp_id)
|
|
1286
1429
|
|
|
1287
1430
|
if sdk.emit_events and otel_logger:
|
|
1288
1431
|
_emit_choice_event(otel_logger, message, provider or "langchain", session_id, span)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|