Compare commits

...

1 Commits

Author SHA1 Message Date
jacoblee93
4112f6edd3 Patch metadata with current tracing context for allowlisted keys 2026-04-19 00:25:55 -07:00
2 changed files with 123 additions and 6 deletions

View File

@@ -37,6 +37,23 @@ _LOGGED = set()
_EXECUTOR: ThreadPoolExecutor | None = None
LANGSMITH_INHERITABLE_METADATA_KEYS: frozenset[str] = frozenset(("ls_agent_type",))
"""Allowlist of metadata keys that can be overridden mid-run via
``langsmith.run_helpers.tracing_context(metadata=...)``.
For keys in this set, the tracer reads the live ``tracing_context`` at
run-start/post time and *overwrites* any existing value on the run
(last-wins). This lets nested code rescope keys like ``ls_agent_type``
without needing a fresh ``CallbackManager.configure`` call -- important
when a compiled graph reuses an outer ``CallbackManager`` and never
re-reads the tracing context via ``configure``.
Non-allowlisted keys set via ``tracing_context(metadata=...)`` keep the
existing first-wins semantics applied at ``configure`` time.
"""
# TODO: Expand this to cover all ``ls_``-prefixed metadata keys.
def log_error_once(method: str, exception: Exception) -> None:
"""Log an error once.
@@ -443,14 +460,36 @@ class LangChainTracer(BaseTracer):
def _patch_missing_metadata(self: LangChainTracer, run: Run) -> None:
if not self.tracing_metadata:
return
metadata = run.metadata
patched = None
for k, v in self.tracing_metadata.items():
if k not in metadata:
patched: dict[str, Any] | None = None
# Apply tracer-level defaults (first-wins: only fill missing keys).
if self.tracing_metadata:
for k, v in self.tracing_metadata.items():
if k not in metadata:
if patched is None:
# Copy on first write to avoid mutating the shared dict.
patched = {**metadata}
run.extra["metadata"] = patched
metadata = patched
patched[k] = v
# Apply live ``tracing_context`` metadata (last-wins) for allowlisted
# keys. This lets nested code rescope keys like ``ls_agent_type``
# mid-flight without a fresh ``CallbackManager.configure`` call --
# important when a compiled graph reuses an outer ``CallbackManager``
# (e.g. LangGraph's ``get_callback_manager_for_config`` reuse path)
# and therefore never re-reads ``get_tracing_context()`` via
# ``configure``.
tc_metadata = get_tracing_context().get("metadata")
if tc_metadata:
for k, v in tc_metadata.items():
if k not in LANGSMITH_INHERITABLE_METADATA_KEYS:
continue
if metadata.get(k) == v:
continue
if patched is None:
# Copy on first miss to avoid mutating the shared dict.
patched = {**metadata}
run.extra["metadata"] = patched
metadata = patched
patched[k] = v

View File

@@ -1299,3 +1299,81 @@ class TestLangsmithInheritableTracingDefaultsInConfigure:
} == {"alpha", "beta"}
assert tracer.run_map == {}
assert len(tracer.order_map) == 2
def test_live_tracing_context_overrides_allowlisted_keys_tracer_only(
self,
) -> None:
"""Mid-flight ``tracing_context`` rescopes allowlisted tracer metadata.
Covers the path where a compiled graph reuses an outer
``CallbackManager`` (so ``configure`` is never called again for
child runs) but inner code enters ``tracing_context`` to rescope
``ls_agent_type``. Asserts:
- outer run keeps the original ``ls_agent_type`` from
``langsmith_inheritable_metadata``,
- inner run picks up the rescoped value from ``tracing_context``,
- non-tracer handlers never observe ``ls_agent_type`` at all.
"""
tracer = _create_tracer_with_mocked_client()
captured: list[dict[str, Any]] = []
class MetadataCapture(BaseCallbackHandler):
def on_chain_start(self, *_args: Any, **kwargs: Any) -> None:
captured.append(dict(kwargs.get("metadata", {})))
@RunnableLambda
def inner(x: int) -> int:
return x
@RunnableLambda
def outer(x: int) -> int:
with tracing_context(metadata={"ls_agent_type": "subagent"}):
return inner.invoke(x)
cm = CallbackManager.configure(
inheritable_callbacks=[tracer, MetadataCapture()],
langsmith_inheritable_metadata={"ls_agent_type": "root"},
)
outer.invoke(1, {"callbacks": cm})
posts = _get_posts(tracer.client)
by_name = {post["name"]: post for post in posts}
assert {"outer", "inner"} <= by_name.keys()
assert (
by_name["outer"].get("extra", {}).get("metadata", {}).get("ls_agent_type")
== "root"
)
assert (
by_name["inner"].get("extra", {}).get("metadata", {}).get("ls_agent_type")
== "subagent"
)
for md in captured:
assert "ls_agent_type" not in md
def test_live_tracing_context_non_allowlisted_keys_do_not_override(
self,
) -> None:
"""Non-allowlisted ``tracing_context`` metadata keys keep first-wins."""
tracer = _create_tracer_with_mocked_client()
@RunnableLambda
def inner(x: int) -> int:
return x
@RunnableLambda
def outer(x: int) -> int:
with tracing_context(metadata={"env": "staging"}):
return inner.invoke(x)
cm = CallbackManager.configure(
inheritable_callbacks=[tracer],
langsmith_inheritable_metadata={"env": "prod"},
)
outer.invoke(1, {"callbacks": cm})
# ``env`` is not in LANGSMITH_INHERITABLE_METADATA_KEYS, so the
# outer tracer default ("prod") must not be overridden by the
# live tracing_context value ("staging").
for post in _get_posts(tracer.client):
assert post.get("extra", {}).get("metadata", {}).get("env") == "prod"