diff --git a/libs/core/langchain_core/callbacks/manager.py b/libs/core/langchain_core/callbacks/manager.py index 77387d0a79..1a52eac42d 100644 --- a/libs/core/langchain_core/callbacks/manager.py +++ b/libs/core/langchain_core/callbacks/manager.py @@ -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( diff --git a/libs/core/langchain_core/tracers/context.py b/libs/core/langchain_core/tracers/context.py index 2ad1766391..8e3d783412 100644 --- a/libs/core/langchain_core/tracers/context.py +++ b/libs/core/langchain_core/tracers/context.py @@ -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[ diff --git a/libs/core/langchain_core/tracers/core.py b/libs/core/langchain_core/tracers/core.py index c047214bf4..839955d2c9 100644 --- a/libs/core/langchain_core/tracers/core.py +++ b/libs/core/langchain_core/tracers/core.py @@ -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, diff --git a/libs/core/langchain_core/tracers/langchain.py b/libs/core/langchain_core/tracers/langchain.py index 954470e39c..307e721984 100644 --- a/libs/core/langchain_core/tracers/langchain.py +++ b/libs/core/langchain_core/tracers/langchain.py @@ -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", diff --git a/libs/core/pyproject.toml b/libs/core/pyproject.toml index 0f12282526..e404cd328a 100644 --- a/libs/core/pyproject.toml +++ b/libs/core/pyproject.toml @@ -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", diff --git a/libs/core/tests/unit_tests/tracers/test_address.py b/libs/core/tests/unit_tests/tracers/test_address.py new file mode 100644 index 0000000000..ee638665b5 --- /dev/null +++ b/libs/core/tests/unit_tests/tracers/test_address.py @@ -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 diff --git a/libs/core/uv.lock b/libs/core/uv.lock index 4638b652d2..4ad0199232 100644 --- a/libs/core/uv.lock +++ b/libs/core/uv.lock @@ -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"