feat(core): accept address inside LangChainTracer

This commit is contained in:
Tat Dat Duong committed 2026-10-03 04:48:22 +07:00
1 parent 57236d55d9
commit c1116120b2
7 files changed
+636 -65

No files matched your search

+14 -14
View File
@@ -59,6 +59,7 @@ def trace_as_chain_group(
*,
inputs: dict[str, Any] | None = None,
project_name: str | None = None,
address: str | None = None,
example_id: str | UUID | None = None,
run_id: UUID | None = None,
tags: list[str] | None = None,
@@ -74,6 +75,10 @@ def trace_as_chain_group(
callback_manager: The callback manager to use.
inputs: The inputs to the chain group.
project_name: The name of the project.
address: The agent address to send traces to instead of a project.
!!! warning "Beta"
Agent addressing must be enabled for the LangSmith workspace.
example_id: The ID of the example.
run_id: The ID of the run.
tags: The inheritable tags to apply to all runs.
@@ -101,7 +106,7 @@ def trace_as_chain_group(
)
cb = _get_trace_callbacks(
project_name, example_id, callback_manager=callback_manager
project_name, example_id, callback_manager=callback_manager, address=address
)
cm = CallbackManager.configure(
inheritable_callbacks=cb,
@@ -139,6 +144,7 @@ async def atrace_as_chain_group(
*,
inputs: dict[str, Any] | None = None,
project_name: str | None = None,
address: str | None = None,
example_id: str | UUID | None = None,
run_id: UUID | None = None,
tags: list[str] | None = None,
@@ -155,6 +161,10 @@ async def atrace_as_chain_group(
other callback behavior.
inputs: The inputs to the chain group.
project_name: The name of the project.
address: The agent address to send traces to instead of a project.
!!! warning "Beta"
Agent addressing must be enabled for the LangSmith workspace.
example_id: The ID of the example.
run_id: The ID of the run.
tags: The inheritable tags to apply to all runs.
@@ -184,7 +194,7 @@ async def atrace_as_chain_group(
)
cb = _get_trace_callbacks(
project_name, example_id, callback_manager=callback_manager
project_name, example_id, callback_manager=callback_manager, address=address
)
cm = AsyncCallbackManager.configure(
inheritable_callbacks=cb, inheritable_tags=tags, inheritable_metadata=metadata
@@ -2427,7 +2437,7 @@ def _configure(
from langchain_core.tracers.context import ( # noqa: PLC0415
_configure_hooks,
_get_tracer_project,
_get_tracer_kwargs,
_tracing_v2_is_enabled,
tracing_v2_callback_var,
)
@@ -2505,7 +2515,6 @@ def _configure(
)
raise RuntimeError(msg)
tracer_project = _get_tracer_project()
debug = _get_debug()
if verbose or debug or tracing_v2_enabled_:
if verbose and not any(
@@ -2529,16 +2538,7 @@ def _configure(
callback_manager.add_handler(tracer_v2)
else:
try:
handler = LangChainTracer(
project_name=tracer_project,
client=(
run_tree.client
if run_tree is not None
else tracing_context["client"]
),
tags=tracing_tags,
metadata=tracing_metadata,
)
handler = LangChainTracer(**_get_tracer_kwargs())
callback_manager.add_handler(handler)
except Exception as e:
logger.warning(
+34 -24
View File
@@ -43,16 +43,22 @@ def tracing_v2_enabled(
example_id: str | UUID | None = None,
tags: list[str] | None = None,
client: LangSmithClient | None = None,
address: str | None = None,
) -> Generator[LangChainTracer, None, None]:
"""Instruct LangChain to log all runs in context to LangSmith.
Args:
project_name: The name of the project.
Defaults to `'default'`.
Resolved together with `address` from LangSmith config and the
environment when no destination is supplied.
example_id: The ID of the example.
tags: The tags to add to the run.
client: The client of the langsmith.
address: The agent address to send traces to instead of a project.
!!! warning "Beta"
Agent addressing must be enabled for the LangSmith workspace.
Yields:
The LangChain tracer.
@@ -72,6 +78,7 @@ def tracing_v2_enabled(
cb = LangChainTracer(
example_id=example_id,
project_name=project_name,
address=address,
tags=tags,
client=client,
)
@@ -106,12 +113,13 @@ def _get_trace_callbacks(
project_name: str | None = None,
example_id: str | UUID | None = None,
callback_manager: CallbackManager | AsyncCallbackManager | None = None,
*,
address: str | None = None,
) -> Callbacks:
if _tracing_v2_is_enabled():
project_name_ = project_name or _get_tracer_project()
tracer = tracing_v2_callback_var.get() or LangChainTracer(
project_name=project_name_,
example_id=example_id,
**_get_tracer_kwargs(project_name, address=address),
)
if callback_manager is None:
cb = cast("Callbacks", [tracer])
@@ -135,27 +143,29 @@ def _tracing_v2_is_enabled() -> bool | Literal["local"]:
return ls_utils.tracing_is_enabled()
def _get_tracer_project() -> str:
tracing_context = ls_rh.get_tracing_context()
run_tree = tracing_context["parent"]
if run_tree is None and tracing_context["project_name"] is not None:
return cast("str", tracing_context["project_name"])
return getattr(
run_tree,
"session_name",
getattr(
# Note, if people are trying to nest @traceable functions and the
# tracing_v2_enabled context manager, this will likely mess up the
# tree structure.
tracing_v2_callback_var.get(),
"project",
# Have to set this to a string even though it always will return
# a string because `get_tracer_project` technically can return
# None, but only when a specific argument is supplied.
# Therefore, this just tricks the mypy type checker
str(ls_utils.get_tracer_project()),
),
)
def _get_tracer_kwargs(
project_name: str | None = None, *, address: str | None = None
) -> dict[str, Any]:
"""Get tracer defaults from the current LangSmith context.
Forward project and address together so an addressed parent never picks up
an ambient project. Leave environment defaults and validation to RunTree.
"""
context = ls_rh.get_tracing_context()
parent = context["parent"]
if project_name is not None or address is not None:
project = project_name
elif parent is not None:
project, address = parent.session_name, parent.address
else:
project, address = context["project_name"], context["address"]
return {
"project_name": project,
"address": address,
"client": parent.client if parent is not None else context["client"],
"tags": context["tags"],
"metadata": context["metadata"],
}
_configure_hooks: list[
+13 -5
View File
@@ -174,6 +174,14 @@ class _TracerCore(ABC):
self.order_map[run.id] = (run.trace_id, run.dotted_order)
self.run_map[str(run.id)] = run
def _get_run_kwargs(
self,
parent_run_id: UUID | None, # noqa: ARG002
extra: dict[str, Any],
) -> dict[str, Any]:
"""Return construction arguments for a run."""
return {"extra": extra}
def _get_run(self, run_id: UUID, run_type: str | set[str] | None = None) -> Run:
try:
run = self.run_map[str(run_id)]
@@ -226,7 +234,7 @@ class _TracerCore(ABC):
parent_run_id=parent_run_id,
serialized=serialized,
inputs={"messages": [[dumpd(msg) for msg in batch] for batch in messages]},
extra=kwargs,
**self._get_run_kwargs(parent_run_id, kwargs),
events=[{"name": "start", "time": start_time}],
start_time=start_time,
# WARNING: This is valid ONLY for streaming_events.
@@ -258,7 +266,7 @@ class _TracerCore(ABC):
serialized=serialized,
# TODO: Figure out how to expose kwargs here
inputs={"prompts": prompts},
extra=kwargs,
**self._get_run_kwargs(parent_run_id, kwargs),
events=[{"name": "start", "time": start_time}],
start_time=start_time,
run_type="llm",
@@ -408,7 +416,7 @@ class _TracerCore(ABC):
parent_run_id=parent_run_id,
serialized=serialized,
inputs=self._get_chain_inputs(inputs),
extra=kwargs,
**self._get_run_kwargs(parent_run_id, kwargs),
events=[{"name": "start", "time": start_time}],
start_time=start_time,
child_runs=[],
@@ -504,7 +512,7 @@ class _TracerCore(ABC):
serialized=serialized,
# Wrapping in dict since Run requires a dict object.
inputs=inputs,
extra=kwargs,
**self._get_run_kwargs(parent_run_id, kwargs),
events=[{"name": "start", "time": start_time}],
start_time=start_time,
child_runs=[],
@@ -561,7 +569,7 @@ class _TracerCore(ABC):
parent_run_id=parent_run_id,
serialized=serialized,
inputs={"query": query},
extra=kwargs,
**self._get_run_kwargs(parent_run_id, kwargs),
events=[{"name": "start", "time": start_time}],
start_time=start_time,
tags=tags,
+31 -5
View File
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from langsmith import Client, get_tracing_context
from langsmith import address as ls_address
from langsmith import run_trees as rt
from langsmith import utils as ls_utils
from tenacity import (
@@ -144,6 +145,7 @@ class LangChainTracer(BaseTracer):
tags: list[str] | None = None,
*,
metadata: Mapping[str, str] | None = None,
address: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize the LangChain tracer.
@@ -152,7 +154,8 @@ class LangChainTracer(BaseTracer):
example_id: The example ID.
project_name: The project name.
Defaults to the tracer project.
Resolved together with `address` from LangSmith config and the
environment when no destination is supplied.
client: The client.
Defaults to the global client.
@@ -162,13 +165,19 @@ class LangChainTracer(BaseTracer):
metadata: Additional metadata to include if it isn't already in the run.
Defaults to None.
address: The agent address to send traces to instead of a project.
!!! warning "Beta"
Agent addressing is in beta and must be enabled for the
LangSmith workspace.
**kwargs: Additional keyword arguments.
"""
super().__init__(**kwargs)
self.example_id = (
UUID(example_id) if isinstance(example_id, str) else example_id
)
self.project_name = project_name or ls_utils.get_tracer_project()
self.project_name = project_name
self.address = ls_address.parse(address) if address is not None else None
self.client = client or get_client()
self.tags = tags or []
self.latest_run: Run | None = None
@@ -209,6 +218,7 @@ class LangChainTracer(BaseTracer):
return self.__class__(
example_id=self.example_id,
project_name=self.project_name,
address=self.address,
client=self.client,
tags=merged_tags,
metadata=merged_metadata,
@@ -217,9 +227,25 @@ class LangChainTracer(BaseTracer):
_external_run_ids=self._external_run_ids,
)
def _get_run_kwargs(
self, parent_run_id: UUID | None, extra: dict[str, Any]
) -> dict[str, Any]:
"""Forward explicit or inherited destinations to RunTree."""
parent = self.run_map.get(str(parent_run_id)) if parent_run_id else None
if parent is not None:
return {
"session_name": parent.session_name,
"address": parent.address,
"replicas": parent.replicas,
"extra": extra,
}
return {
"session_name": self.project_name,
"address": self.address,
"extra": extra,
}
def _start_trace(self, run: Run) -> None:
if self.project_name:
run.session_name = self.project_name
if self.tags is not None:
if run.tags:
run.tags = sorted(set(run.tags + self.tags))
@@ -267,7 +293,7 @@ class LangChainTracer(BaseTracer):
parent_run_id=parent_run_id,
serialized=serialized,
inputs={"messages": [[dumpd(msg) for msg in batch] for batch in messages]},
extra=kwargs,
**self._get_run_kwargs(parent_run_id, kwargs),
events=[{"name": "start", "time": start_time}],
start_time=start_time,
run_type="llm",
+1 -1
View File
@@ -24,7 +24,7 @@ classifiers = [
version = "1.6.6"
requires-python = ">=3.10.0,<4.0.0"
dependencies = [
"langsmith>=0.3.45,<1.0.0",
"langsmith>=0.14.4,<1.0.0",
"httpx>=0.23.0,<1.0.0",
"tenacity!=8.4.0,>=8.1.0,<10.0.0",
"jsonpatch>=1.33.0,<2.0.0",
@@ -0,0 +1,473 @@
"""Destination resolution for LangSmith tracing."""
from collections.abc import Iterator
from contextlib import nullcontext
from itertools import product
from typing import Any
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from langsmith import Client, address, configure, tracing_context
from langsmith.address import EnvAddressError
from langsmith.run_trees import RunTree, WriteReplica
from langsmith.utils import LangSmithUserError, get_env_var, get_tracer_project
from langchain_core.callbacks.manager import (
CallbackManager,
atrace_as_chain_group,
trace_as_chain_group,
)
from langchain_core.messages import HumanMessage
from langchain_core.outputs import LLMResult
from langchain_core.tracers.context import (
_get_trace_callbacks,
_get_tracer_kwargs,
tracing_v2_enabled,
)
from langchain_core.tracers.langchain import LangChainTracer
ADDRESS = address.agent("code-agent", "production")
ENV_ADDRESS = address.agent("env-agent", "staging")
@pytest.fixture(autouse=True)
def clean_destination(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
for name in (
"HOSTED_LANGSERVE_PROJECT_NAME",
"LANGSMITH_PROJECT",
"LANGCHAIN_PROJECT",
"LANGCHAIN_SESSION",
"LANGSMITH_AGENT_ID",
"LANGSMITH_AGENT_ENVIRONMENT",
):
monkeypatch.delenv(name, raising=False)
get_env_var.cache_clear() # type: ignore[attr-defined]
get_tracer_project.cache_clear()
yield
get_env_var.cache_clear() # type: ignore[attr-defined]
get_tracer_project.cache_clear()
@pytest.mark.parametrize(
("code_project", "code_address", "env_project", "env_address"),
list(product((False, True), repeat=4)),
)
@pytest.mark.parametrize("run_type", ["llm", "chat", "chain", "tool", "retriever"])
def test_destination_matrix(
monkeypatch: pytest.MonkeyPatch,
*,
code_project: bool,
code_address: bool,
env_project: bool,
env_address: bool,
run_type: str,
) -> None:
if env_project:
monkeypatch.setenv("LANGSMITH_PROJECT", "env-project")
if env_address:
monkeypatch.setenv("LANGSMITH_AGENT_ID", "env-agent")
monkeypatch.setenv("LANGSMITH_AGENT_ENVIRONMENT", "staging")
get_env_var.cache_clear() # type: ignore[attr-defined]
get_tracer_project.cache_clear()
client = MagicMock(spec=Client)
kwargs: dict[str, Any] = {
"client": client,
"project_name": "code-project" if code_project else None,
"address": ADDRESS if code_address else None,
}
tracer = LangChainTracer(**kwargs)
run_id = uuid4()
if code_project and code_address:
with pytest.raises(LangSmithUserError, match="not both"):
tracer.on_chain_start({}, {}, run_id=run_id)
client.create_run.assert_not_called()
client.update_run.assert_not_called()
return
invalid_env = not (code_project or code_address) and env_project and env_address
if invalid_env:
with pytest.raises(EnvAddressError):
tracer.on_chain_start({}, {}, run_id=run_id)
client.create_run.assert_not_called()
client.update_run.assert_not_called()
return
if run_type == "llm":
tracer.on_llm_start({}, ["hello"], run_id=run_id)
tracer.on_llm_end(LLMResult(generations=[]), run_id=run_id)
elif run_type == "chat":
tracer.on_chat_model_start({}, [[HumanMessage("hello")]], run_id=run_id)
tracer.on_llm_end(LLMResult(generations=[]), run_id=run_id)
elif run_type == "chain":
tracer.on_chain_start({}, {"input": "hello"}, run_id=run_id)
tracer.on_chain_end({}, run_id=run_id)
elif run_type == "tool":
tracer.on_tool_start({}, "hello", run_id=run_id)
tracer.on_tool_end("ok", run_id=run_id)
else:
tracer.on_retriever_start({}, "hello", run_id=run_id)
tracer.on_retriever_end([], run_id=run_id)
expected_address = (
ADDRESS
if code_address
else (ENV_ADDRESS if env_address and not code_project else None)
)
expected_project = (
None
if expected_address
else (
"code-project"
if code_project
else "env-project"
if env_project
else "default"
)
)
client.create_run.assert_called_once()
client.update_run.assert_called_once()
for payload in (
client.create_run.call_args.kwargs,
client.update_run.call_args.kwargs,
):
assert payload.get("session_name") == expected_project
assert payload.get("address") == expected_address
@pytest.mark.parametrize(
"destination", [{"address": ADDRESS}, {"project_name": "configured"}]
)
@pytest.mark.parametrize("mode", ["context", "configure", "parent"])
def test_ambient_destination(destination: dict[str, str], mode: str) -> None:
client = MagicMock(spec=Client)
if mode == "configure":
configure(
enabled=True,
client=client,
project_name=destination.get("project_name"),
address=destination.get("address"),
)
parent = (
RunTree(name="parent", ls_client=client, **destination)
if mode == "parent"
else None
)
context = {**destination} if mode == "context" else {}
try:
scope = (
nullcontext()
if mode == "configure"
else tracing_context(
enabled=True,
client=client,
parent=parent,
project_name=context.get("project_name"),
address=context.get("address"),
)
)
with scope:
manager = CallbackManager.configure()
run = manager.on_chain_start({}, {})
run.on_chain_end({})
payload = client.create_run.call_args.kwargs
assert payload.get("address") == destination.get("address")
assert payload.get("session_name") == destination.get("project_name")
finally:
if mode == "configure":
configure(project_name=None, address=None, enabled=None, client=None)
def test_copy_and_context_address() -> None:
client = MagicMock(spec=Client)
with tracing_v2_enabled(address=ADDRESS, client=client) as tracer:
copied = tracer.copy_with_metadata_defaults(metadata={"foo": "bar"})
assert copied.address == ADDRESS
assert copied.project_name is None
manager = CallbackManager.configure()
run = manager.on_chain_start({}, {})
run.on_chain_end({})
assert client.create_run.call_args.kwargs["address"] == ADDRESS
@pytest.mark.parametrize("address", ["invalid", {"agent_id": "agent"}])
def test_invalid_address(address: Any) -> None:
with pytest.raises(LangSmithUserError):
LangChainTracer(address=address, client=MagicMock(spec=Client))
@pytest.mark.parametrize("name", ["LANGSMITH_AGENT_ID", "LANGSMITH_AGENT_ENVIRONMENT"])
def test_incomplete_environment(monkeypatch: pytest.MonkeyPatch, name: str) -> None:
monkeypatch.setenv(name, "staging")
get_env_var.cache_clear() # type: ignore[attr-defined]
get_tracer_project.cache_clear()
client = MagicMock(spec=Client)
tracer = LangChainTracer(client=client)
run_id = uuid4()
with pytest.raises(EnvAddressError):
tracer.on_chain_start({}, {}, run_id=run_id)
client.create_run.assert_not_called()
client.update_run.assert_not_called()
def test_parent_destination_overrides_tracer() -> None:
client = MagicMock(spec=Client)
tracer = LangChainTracer(project_name="other-project", client=client)
parent = RunTree(name="parent", address=ADDRESS, ls_client=client)
tracer.run_map[str(parent.id)] = parent
tracer.order_map[parent.id] = (parent.trace_id, parent.dotted_order)
run_id = uuid4()
tracer.on_chain_start({}, {}, run_id=run_id, parent_run_id=parent.id)
tracer.on_chain_end({}, run_id=run_id)
assert parent.child_runs[0].address == ADDRESS
assert parent.child_runs[0].session_name is None
assert client.create_run.call_args.kwargs["address"] == ADDRESS
def test_context_conflicting_arguments() -> None:
client = MagicMock(spec=Client)
with (
tracing_v2_enabled(
project_name="project", address=ADDRESS, client=client
) as tracer,
pytest.raises(LangSmithUserError, match="not both"),
):
tracer.on_chain_start({}, {}, run_id=uuid4())
client.create_run.assert_not_called()
client.update_run.assert_not_called()
def test_trace_callbacks_resolve_environment_address(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("LANGSMITH_TRACING", "true")
monkeypatch.setenv("LANGSMITH_AGENT_ID", "env-agent")
monkeypatch.setenv("LANGSMITH_AGENT_ENVIRONMENT", "staging")
get_env_var.cache_clear() # type: ignore[attr-defined]
get_tracer_project.cache_clear()
client = MagicMock(spec=Client)
with tracing_context(client=client):
callbacks = _get_trace_callbacks()
assert isinstance(callbacks, list)
tracer = callbacks[0]
assert isinstance(tracer, LangChainTracer)
run_id = uuid4()
tracer.on_chain_start({}, {}, run_id=run_id)
tracer.on_chain_end({}, run_id=run_id)
assert client.create_run.call_args.kwargs["address"] == ENV_ADDRESS
@pytest.mark.parametrize(
"destination", [{"address": ADDRESS}, {"project_name": "parent"}]
)
def test_tracer_kwargs_parent_destination(destination: dict[str, str]) -> None:
client = MagicMock(spec=Client)
parent = RunTree(name="parent", ls_client=client, **destination)
with tracing_context(
parent=parent, project_name="ambient", tags=["tag"], metadata={"key": "value"}
):
kwargs = _get_tracer_kwargs()
assert kwargs["project_name"] == destination.get("project_name")
assert kwargs["address"] == destination.get("address")
assert kwargs["client"] is client
assert kwargs["tags"] == ["tag"]
assert kwargs["metadata"] == {"key": "value"}
explicit = _get_tracer_kwargs("explicit")
assert explicit["project_name"] == "explicit"
assert explicit["address"] is None
def test_tracer_kwargs_leave_environment_to_sdk(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("LANGSMITH_PROJECT", "env-project")
with tracing_context():
kwargs = _get_tracer_kwargs()
assert kwargs["project_name"] is None
assert kwargs["address"] is None
@pytest.mark.parametrize("project_name", [None, "explicit"])
def test_explicit_address_kwargs(project_name: str | None) -> None:
with tracing_context(project_name="ambient"):
kwargs = _get_tracer_kwargs(project_name, address=ADDRESS)
assert kwargs["project_name"] == project_name
assert kwargs["address"] == ADDRESS
@pytest.mark.parametrize("project_name", [None, "conflicting"])
def test_trace_callbacks_explicit_address(project_name: str | None) -> None:
client = MagicMock(spec=Client)
with tracing_context(enabled=True, client=client, project_name="ambient"):
callbacks = _get_trace_callbacks(project_name, address=ADDRESS)
assert isinstance(callbacks, list)
tracer = callbacks[0]
assert isinstance(tracer, LangChainTracer)
if project_name is not None:
with pytest.raises(LangSmithUserError, match="not both"):
tracer.on_chain_start({}, {}, run_id=uuid4())
client.create_run.assert_not_called()
else:
run_id = uuid4()
tracer.on_chain_start({}, {}, run_id=run_id)
tracer.on_chain_end({}, run_id=run_id)
assert client.create_run.call_args.kwargs["address"] == ADDRESS
def test_chain_group_explicit_address() -> None:
client = MagicMock(spec=Client)
with (
tracing_context(enabled=True, client=client, project_name="ambient"),
trace_as_chain_group("group", address=ADDRESS) as manager,
):
run = manager.on_chain_start({}, {})
run.on_chain_end({})
assert client.create_run.call_count == 2
for call in client.create_run.call_args_list:
assert call.kwargs["address"] == ADDRESS
assert call.kwargs.get("session_name") is None
async def test_async_chain_group_explicit_address() -> None:
client = MagicMock(spec=Client)
with tracing_context(enabled=True, client=client, project_name="ambient"):
async with atrace_as_chain_group("group", address=ADDRESS) as manager:
run = await manager.on_chain_start({}, {})
await run.on_chain_end({})
assert client.create_run.call_count == 2
for call in client.create_run.call_args_list:
assert call.kwargs["address"] == ADDRESS
assert call.kwargs.get("session_name") is None
@pytest.mark.parametrize("root_address", [False, True])
@pytest.mark.parametrize("replica_mode", ["inherit", "project", "address"])
def test_replica_destinations(*, root_address: bool, replica_mode: str) -> None:
client = MagicMock(spec=Client)
replica_client = MagicMock(spec=Client)
replica: WriteReplica = {"client": replica_client, "primary": False}
replica_address = address.agent("replica-agent", "staging")
if replica_mode == "project":
replica["project_name"] = "replica-project"
elif replica_mode == "address":
replica["address"] = replica_address
tracer = LangChainTracer(
project_name=None if root_address else "root-project",
address=ADDRESS if root_address else None,
client=client,
)
parent_id, child_id = uuid4(), uuid4()
with tracing_context(replicas=[replica]):
tracer.on_chain_start({}, {}, run_id=parent_id)
tracer.on_chain_start({}, {}, run_id=child_id, parent_run_id=parent_id)
tracer.on_chain_end({}, run_id=child_id)
tracer.on_chain_end({}, run_id=parent_id)
expected_address = (
replica_address
if replica_mode == "address"
else ADDRESS
if replica_mode == "inherit" and root_address
else None
)
expected_project = (
"replica-project"
if replica_mode == "project"
else "root-project"
if replica_mode == "inherit" and not root_address
else None
)
client.create_run.assert_not_called()
client.update_run.assert_not_called()
assert replica_client.create_run.call_count == 2
assert replica_client.update_run.call_count == 2
creates = [call.kwargs for call in replica_client.create_run.call_args_list]
updates = [call.kwargs for call in replica_client.update_run.call_args_list]
for payload in creates + updates:
assert payload.get("address") == expected_address
assert payload.get("session_name") == expected_project
parent, child = creates
assert parent["id"] != parent_id
assert child["id"] != child_id
assert child["parent_run_id"] == parent["id"]
assert child["trace_id"] == parent["trace_id"]
assert child["dotted_order"].startswith(parent["dotted_order"] + ".")
assert {payload["run_id"] for payload in updates} == {
payload["id"] for payload in creates
}
def test_replica_preserves_primary_ids() -> None:
client = MagicMock(spec=Client)
primary_client = MagicMock(spec=Client)
secondary_client = MagicMock(spec=Client)
secondary_address = address.agent("secondary-agent", "staging")
run_id = uuid4()
tracer = LangChainTracer(address=ADDRESS, client=client)
with tracing_context(
replicas=[
{"client": primary_client, "primary": True},
{"client": secondary_client, "address": secondary_address},
]
):
tracer.on_chain_start({}, {}, run_id=run_id)
tracer.on_chain_end({}, run_id=run_id)
assert primary_client.create_run.call_args.kwargs["id"] == run_id
assert primary_client.update_run.call_args.kwargs["run_id"] == run_id
assert primary_client.create_run.call_args.kwargs["address"] == ADDRESS
secondary_id = secondary_client.create_run.call_args.kwargs["id"]
assert secondary_id != run_id
assert secondary_client.update_run.call_args.kwargs["run_id"] == secondary_id
assert secondary_client.create_run.call_args.kwargs["address"] == secondary_address
client.create_run.assert_not_called()
def test_replica_conflicting_destination() -> None:
client = MagicMock(spec=Client)
tracer = LangChainTracer(address=ADDRESS, client=client)
with (
tracing_context(replicas=[{"project_name": "conflict", "address": ADDRESS}]),
pytest.raises(LangSmithUserError, match="not both"),
):
tracer.on_chain_start({}, {}, run_id=uuid4())
client.create_run.assert_not_called()
client.update_run.assert_not_called()
def test_addressed_parent_replica_inherited_outside_context() -> None:
client = MagicMock(spec=Client)
replica_client = MagicMock(spec=Client)
parent = RunTree(
name="parent",
address=ADDRESS,
ls_client=client,
replicas=[{"client": replica_client, "project_name": "replica-project"}],
)
tracer = LangChainTracer(client=client)
tracer.run_map[str(parent.id)] = parent
tracer.order_map[parent.id] = (parent.trace_id, parent.dotted_order)
run_id = uuid4()
tracer.on_chain_start({}, {}, run_id=run_id, parent_run_id=parent.id)
tracer.on_chain_end({}, run_id=run_id)
replica_client.create_run.assert_called_once()
replica_client.update_run.assert_called_once()
assert (
replica_client.create_run.call_args.kwargs["session_name"] == "replica-project"
)
assert replica_client.create_run.call_args.kwargs.get("address") is None
assert parent.child_runs[0].address == ADDRESS
client.create_run.assert_not_called()
@pytest.mark.parametrize("use_address", [False, True])
def test_chain_group_tracing_disabled(*, use_address: bool) -> None:
with (
tracing_context(enabled=False),
trace_as_chain_group("group", address=ADDRESS if use_address else None),
):
pass
@pytest.mark.parametrize("use_address", [False, True])
async def test_async_chain_group_tracing_disabled(*, use_address: bool) -> None:
with tracing_context(enabled=False):
async with atrace_as_chain_group(
"group", address=ADDRESS if use_address else None
):
pass
+70 -16
View File
@@ -8,8 +8,10 @@ resolution-markers = [
"python_full_version == '3.14.*' and platform_python_implementation != 'PyPy'",
"python_full_version == '3.13.*' and platform_python_implementation == 'PyPy'",
"python_full_version == '3.13.*' and platform_python_implementation != 'PyPy'",
"python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'",
"python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'",
"python_full_version == '3.12.*' and platform_python_implementation == 'PyPy' and sys_platform == 'emscripten'",
"(python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and platform_python_implementation == 'PyPy' and sys_platform == 'emscripten')",
"python_full_version == '3.12.*' and platform_python_implementation != 'PyPy' and sys_platform == 'emscripten'",
"(python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and platform_python_implementation != 'PyPy' and sys_platform == 'emscripten')",
"python_full_version < '3.11' and platform_python_implementation == 'PyPy'",
"python_full_version < '3.11' and platform_python_implementation != 'PyPy'",
]
@@ -618,6 +620,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpcore2"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
@@ -634,12 +649,38 @@ wheels = [
]
[[package]]
name = "idna"
version = "3.15"
name = "httpx2"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" },
{ name = "idna" },
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
{ url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" },
]
[[package]]
name = "httpx2-jsfetch"
version = "1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
@@ -713,8 +754,10 @@ resolution-markers = [
"python_full_version == '3.14.*' and platform_python_implementation != 'PyPy'",
"python_full_version == '3.13.*' and platform_python_implementation == 'PyPy'",
"python_full_version == '3.13.*' and platform_python_implementation != 'PyPy'",
"python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'",
"python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'",
"python_full_version == '3.12.*' and platform_python_implementation == 'PyPy' and sys_platform == 'emscripten'",
"(python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and platform_python_implementation == 'PyPy' and sys_platform == 'emscripten')",
"python_full_version == '3.12.*' and platform_python_implementation != 'PyPy' and sys_platform == 'emscripten'",
"(python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and platform_python_implementation != 'PyPy' and sys_platform == 'emscripten')",
]
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1127,7 +1170,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.23.0,<1.0.0" },
{ name = "jsonpatch", specifier = ">=1.33.0,<2.0.0" },
{ name = "langchain-protocol", specifier = ">=0.0.17" },
{ name = "langsmith", specifier = ">=0.3.45,<1.0.0" },
{ name = "langsmith", specifier = ">=0.14.4,<1.0.0" },
{ name = "packaging", specifier = ">=23.2.0" },
{ name = "pydantic", specifier = ">=2.7.4,<3.0.0" },
{ name = "pyyaml", specifier = ">=5.3.0,<7.0.0" },
@@ -1227,7 +1270,7 @@ typing = [
[[package]]
name = "langchain-text-splitters"
version = "1.1.2"
version = "1.1.3"
source = { directory = "../text-splitters" }
dependencies = [
{ name = "langchain-core" },
@@ -1269,12 +1312,12 @@ typing = [
[[package]]
name = "langsmith"
version = "0.11.1"
version = "0.14.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "httpx2" },
{ name = "orjson", marker = "platform_python_implementation != 'PyPy'" },
{ name = "packaging" },
{ name = "pydantic" },
@@ -1287,9 +1330,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/57/7b6c11080c9e082ebf1456a2e2372fae8f23a85a5ae2869bdd5ab9a6507d/langsmith-0.11.1.tar.gz", hash = "sha256:47998977366acb3ba3093881fd465cbf11a5f8c2f4e87e40a17dda203f6dedf4", size = 4814724, upload-time = "2026-08-19T15:47:44.116Z" }
sdist = { url = "https://files.pythonhosted.org/packages/15/b9/10243797b942e7475ae21545fde1be484958672ae5c2f377f781c761a375/langsmith-0.14.4.tar.gz", hash = "sha256:a340199fcd2af60358f09dd1d7011bf7282f0d46a98aaa101bc7a521746da764", size = 4968005, upload-time = "2026-10-02T17:16:37.074Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/85/0ad6df25588122760b2b40ec182eafc55532c9e66015c83e16675bd34a29/langsmith-0.11.1-py3-none-any.whl", hash = "sha256:cfc3437a9cf27440cd0095c24df945edbceb6df10b579bc8b3980b4ad367835f", size = 744589, upload-time = "2026-08-19T15:47:42.043Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1f/5c2919fef6e3cf15167699abeb069a371d68da14a0e5cab89eeea11261d7/langsmith-0.14.4-py3-none-any.whl", hash = "sha256:ae0d98c2b0f9292ef7e12451d480937d79a0e7dcf2412ba847de6f053391b44a", size = 813097, upload-time = "2026-10-02T17:16:34.724Z" },
]
[[package]]
@@ -1798,8 +1841,10 @@ resolution-markers = [
"python_full_version == '3.14.*' and platform_python_implementation != 'PyPy'",
"python_full_version == '3.13.*' and platform_python_implementation == 'PyPy'",
"python_full_version == '3.13.*' and platform_python_implementation != 'PyPy'",
"python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'",
"python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'",
"python_full_version == '3.12.*' and platform_python_implementation == 'PyPy' and sys_platform == 'emscripten'",
"(python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and platform_python_implementation == 'PyPy' and sys_platform == 'emscripten')",
"python_full_version == '3.12.*' and platform_python_implementation != 'PyPy' and sys_platform == 'emscripten'",
"(python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and platform_python_implementation != 'PyPy' and sys_platform == 'emscripten')",
]
sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" }
wheels = [
@@ -3002,6 +3047,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
]
[[package]]
name = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20260408"