struct-sdk 0.1.0__tar.gz → 0.2.0__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.0}/PKG-INFO +4 -3
- {struct_sdk-0.1.0 → struct_sdk-0.2.0}/pyproject.toml +7 -6
- {struct_sdk-0.1.0 → struct_sdk-0.2.0}/src/struct_sdk/anthropic.py +45 -3
- {struct_sdk-0.1.0 → struct_sdk-0.2.0}/src/struct_sdk/core.py +21 -0
- {struct_sdk-0.1.0 → struct_sdk-0.2.0}/src/struct_sdk/langchain.py +157 -14
- {struct_sdk-0.1.0 → struct_sdk-0.2.0}/.gitignore +0 -0
- {struct_sdk-0.1.0 → struct_sdk-0.2.0}/LICENSE +0 -0
- {struct_sdk-0.1.0 → struct_sdk-0.2.0}/README.md +0 -0
- {struct_sdk-0.1.0 → struct_sdk-0.2.0}/src/struct_sdk/__init__.py +0 -0
- {struct_sdk-0.1.0 → struct_sdk-0.2.0}/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.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "struct-sdk"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
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,41 @@ 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
|
+
_safe(lambda: host_span.set_attribute("error.type", type(e).__name__),
|
|
146
|
+
site="anthropic.create.enrich.error_type")
|
|
147
|
+
raise
|
|
148
|
+
_safe(
|
|
149
|
+
lambda: _set_response_attrs(host_span, sdk, model, result, otel_logger),
|
|
150
|
+
site="anthropic.create.enrich.set_response_attrs",
|
|
151
|
+
)
|
|
152
|
+
return result # noqa: B901
|
|
153
|
+
|
|
124
154
|
with tracer.start_as_current_span(
|
|
125
155
|
f"chat {model}", kind=trace.SpanKind.CLIENT
|
|
126
156
|
) as span:
|
|
@@ -273,9 +303,16 @@ def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logge
|
|
|
273
303
|
if is_async:
|
|
274
304
|
@functools.wraps(original)
|
|
275
305
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
276
|
-
from struct_sdk.core import _safe, _current_session_id
|
|
306
|
+
from struct_sdk.core import _safe, _current_session_id, _current_langchain_chat_span
|
|
277
307
|
model = kwargs.get("model", "unknown")
|
|
278
308
|
|
|
309
|
+
# Enrich path: a LangChain handler upstream already owns a chat
|
|
310
|
+
# span for this call. Don't create a duplicate; just pass through.
|
|
311
|
+
# (Stream end-handling will set response attrs on the host span
|
|
312
|
+
# when the LangChain handler's on_llm_end fires.)
|
|
313
|
+
if _current_langchain_chat_span.get(None) is not None:
|
|
314
|
+
return await original(*args, **kwargs) if _is_coroutine(original) else original(*args, **kwargs)
|
|
315
|
+
|
|
279
316
|
span: Optional[trace.Span] = None
|
|
280
317
|
|
|
281
318
|
def start() -> None:
|
|
@@ -318,9 +355,14 @@ def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logge
|
|
|
318
355
|
else:
|
|
319
356
|
@functools.wraps(original)
|
|
320
357
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
321
|
-
from struct_sdk.core import _safe, _current_session_id
|
|
358
|
+
from struct_sdk.core import _safe, _current_session_id, _current_langchain_chat_span
|
|
322
359
|
model = kwargs.get("model", "unknown")
|
|
323
360
|
|
|
361
|
+
# Enrich path: a LangChain handler upstream already owns a chat
|
|
362
|
+
# span for this call. Don't create a duplicate; just pass through.
|
|
363
|
+
if _current_langchain_chat_span.get(None) is not None:
|
|
364
|
+
return original(*args, **kwargs)
|
|
365
|
+
|
|
324
366
|
span: Optional[trace.Span] = None
|
|
325
367
|
|
|
326
368
|
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
|
|
File without changes
|