Skip to content

Commit 5bff3b0

Browse files
xumapleclaude
andauthored
AI-183: Respect LANGSMITH_TRACING env var in LangSmith plugin (#1509)
* AI-183: Respect LANGSMITH_TRACING env var in LangSmith plugin LangSmithInterceptor previously hard-coded `enabled=True` in every `tracing_context(...)` call (4 sites: workflow, activity, nexus start, nexus cancel) and `_maybe_run` gated only on `add_temporal_runs`. This meant `LANGSMITH_TRACING=false` was a no-op when the plugin was installed. Defer to langsmith's native polarity: drop `enabled` from all four `tracing_context(...)` calls so langsmith's own `tracing_is_enabled()` resolution takes over, and short-circuit `_maybe_run` on `tracing_is_enabled()` in addition to the existing `add_temporal_runs` gate. Behavior change: the plugin is no longer "on by default" once installed. Users must now explicitly opt in via `LANGSMITH_TRACING=true`, matching langsmith's standard mechanism. Cross-process: `tracing_is_enabled()` checks for an active parent run tree before consulting env vars, so an inbound parent trace propagated via headers will continue locally even when `LANGSMITH_TRACING=false` (documented in `_maybe_run`'s docstring). Adds 5 tests in tests/contrib/langsmith/test_tracing_env_override.py covering both `add_temporal_runs` modes, the legacy `LANGCHAIN_TRACING_V2` env var, the Nexus start path, and a positive control. Adds two autouse fixtures in tests/contrib/langsmith/conftest.py: one to clear langsmith's env-var lru_cache between tests, and one to set `LANGSMITH_TRACING=true` by default for the suite (preserving the prior "tracing on" assumption that the existing 87 tests rely on; individual tests override). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * AI-183: Move cross-process Note out of _maybe_run bullet list The Note paragraph was sandwiched between two bullets, breaking RST definition list parsing. Pydoctor reported "Definition list ends without a blank line; unexpected unindent." Move the Note after all bullets, as a standalone paragraph before ``Args:``. No content change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6ac702a commit 5bff3b0

4 files changed

Lines changed: 290 additions & 12 deletions

File tree

temporalio/contrib/langsmith/_interceptor.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing import Any, ClassVar, NoReturn, Protocol
1313

1414
import langsmith
15+
import langsmith.utils
1516
import nexusrpc.handler
1617
from langsmith import tracing_context
1718
from langsmith.run_helpers import get_current_run_tree
@@ -43,6 +44,7 @@
4344
}
4445
)
4546

47+
4648
# ---------------------------------------------------------------------------
4749
# Context helpers
4850
# ---------------------------------------------------------------------------
@@ -457,14 +459,24 @@ def _maybe_run(
457459
) -> Iterator[None]:
458460
"""Create a LangSmith run, handling errors.
459461
460-
- If add_temporal_runs is False, yields None (no run created).
462+
- If add_temporal_runs is False **or** ``langsmith.utils.tracing_is_enabled()``
463+
returns False, yields None (no run created).
461464
Context propagation is handled unconditionally by callers.
462465
- When a run IS created, uses :class:`_ReplaySafeRunTree` for
463466
replay and event loop safety, then sets it as ambient context via
464467
``tracing_context(parent=run_tree)`` so ``get_current_run_tree()``
465468
returns it and ``_inject_current_context()`` can inject it.
466469
- On exception: marks run as errored (unless benign ApplicationError), re-raises.
467470
471+
Note on ``tracing_is_enabled()`` and cross-process traces:
472+
``tracing_is_enabled()`` checks for an active run tree in context
473+
*before* consulting the ``LANGSMITH_TRACING`` env var (langsmith
474+
semantics). If a parent run is propagated into this worker via
475+
headers from an upstream tracer, tracing continues regardless of
476+
``LANGSMITH_TRACING=false``. This matches langsmith's "continue
477+
mid-trace" model: the env var suppresses *new* local traces but
478+
does not break an inbound parent trace.
479+
468480
Args:
469481
client: LangSmith client instance.
470482
name: Display name for the run.
@@ -477,7 +489,7 @@ def _maybe_run(
477489
project_name: LangSmith project name override.
478490
executor: ThreadPoolExecutor for background I/O.
479491
"""
480-
if not add_temporal_runs:
492+
if not add_temporal_runs or not langsmith.utils.tracing_is_enabled():
481493
yield None
482494
return
483495

@@ -717,12 +729,8 @@ async def execute_activity(
717729
"temporalRunID": info.workflow_run_id or "",
718730
"temporalActivityID": info.activity_id or "",
719731
}
720-
# Unconditionally set tracing context so @traceable functions inside
721-
# activities inherit the plugin's client and parent, regardless of
722-
# the add_temporal_runs toggle.
723732
tracing_args: dict[str, Any] = {
724733
"client": self._config._client,
725-
"enabled": True,
726734
"project_name": self._config._project_name,
727735
"parent": parent,
728736
}
@@ -786,7 +794,6 @@ def _workflow_maybe_run(
786794
)
787795
tracing_args: dict[str, Any] = {
788796
"client": self._config._client,
789-
"enabled": True,
790797
"project_name": self._config._project_name,
791798
"parent": tracing_parent,
792799
}
@@ -947,7 +954,6 @@ async def execute_nexus_operation_start(
947954
)
948955
tracing_args: dict[str, Any] = {
949956
"client": self._config._client,
950-
"enabled": True,
951957
"project_name": self._config._project_name,
952958
"parent": parent,
953959
}
@@ -967,7 +973,6 @@ async def execute_nexus_operation_cancel(
967973
)
968974
tracing_args: dict[str, Any] = {
969975
"client": self._config._client,
970-
"enabled": True,
971976
"project_name": self._config._project_name,
972977
"parent": parent,
973978
}

tests/contrib/langsmith/conftest.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,37 @@
66
from typing import Any
77
from unittest.mock import MagicMock
88

9+
import pytest
10+
11+
12+
@pytest.fixture(autouse=True)
13+
def _clear_langsmith_env_cache() -> Any: # pyright: ignore[reportUnusedFunction]
14+
"""Clear langsmith's lru_cache before and after each test.
15+
16+
Tests manipulate LANGSMITH_TRACING / LANGCHAIN_TRACING_V2 env vars.
17+
langsmith.utils.get_env_var caches results, so stale values would
18+
leak across tests (or into other test modules in the same session).
19+
"""
20+
import langsmith.utils
21+
22+
langsmith.utils.get_env_var.cache_clear() # type: ignore[attr-defined]
23+
yield
24+
langsmith.utils.get_env_var.cache_clear() # type: ignore[attr-defined]
25+
26+
27+
@pytest.fixture(autouse=True)
28+
def _enable_langsmith_tracing(monkeypatch: pytest.MonkeyPatch) -> None: # pyright: ignore[reportUnusedFunction]
29+
"""Enable LangSmith tracing by default for all tests in this directory.
30+
31+
The plugin defers to ``langsmith.utils.tracing_is_enabled()``, which
32+
requires ``LANGSMITH_TRACING=true`` (or equivalent). Without this
33+
fixture, tests that expect runs would see zero.
34+
35+
Individual tests can override with ``monkeypatch.setenv("LANGSMITH_TRACING", "false")``
36+
to verify disabled behavior.
37+
"""
38+
monkeypatch.setenv("LANGSMITH_TRACING", "true")
39+
940

1041
@dataclass
1142
class _RunRecord:

tests/contrib/langsmith/test_interceptor.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,7 +1111,6 @@ async def test_false_still_propagates_context(
11111111
# (unconditionally, before _maybe_run)
11121112
mock_tracing_ctx.assert_called_once_with(
11131113
client=config._client,
1114-
enabled=True,
11151114
project_name=None,
11161115
parent=mock_extracted_parent,
11171116
)
@@ -1143,8 +1142,8 @@ async def test_false_activity_no_parent_no_context(
11431142
await act_interceptor.execute_activity(mock_act_input)
11441143

11451144
MockRunTree.assert_not_called()
1146-
# tracing_context called with client and enabled (no parent)
1145+
# tracing_context called with client (no parent)
11471146
mock_tracing_ctx.assert_called_once_with(
1148-
client=config._client, enabled=True, project_name=None, parent=None
1147+
client=config._client, project_name=None, parent=None
11491148
)
11501149
mock_act_next.execute_activity.assert_called_once()
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""Tests that LangSmithPlugin defers to ``langsmith.utils.tracing_is_enabled()``.
2+
3+
Tracing requires the env to explicitly say so (``LANGSMITH_TRACING=true`` etc);
4+
it is off when the env is unset or set to ``false``. Tests verify that
5+
``LANGSMITH_TRACING=false`` produces zero runs and ``LANGSMITH_TRACING=true``
6+
produces runs.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import uuid
12+
from datetime import timedelta
13+
14+
import pytest
15+
from langsmith import traceable
16+
17+
from temporalio import activity, workflow
18+
from temporalio.client import Client
19+
from temporalio.testing import WorkflowEnvironment
20+
from tests.contrib.langsmith.test_integration import (
21+
DirectTraceableNexusService,
22+
NexusDirectTraceableWorkflow,
23+
_make_client_and_collector,
24+
)
25+
from tests.helpers import new_worker
26+
from tests.helpers.nexus import make_nexus_endpoint_name
27+
28+
# ---------------------------------------------------------------------------
29+
# Sample workflow / activity
30+
# ---------------------------------------------------------------------------
31+
32+
33+
@traceable(name="inner_call")
34+
async def _inner_call(prompt: str) -> str:
35+
return f"response to: {prompt}"
36+
37+
38+
@traceable
39+
@activity.defn
40+
async def env_override_activity() -> str:
41+
result = await _inner_call("hello")
42+
return result
43+
44+
45+
@workflow.defn
46+
class EnvOverrideWorkflow:
47+
@workflow.run
48+
async def run(self) -> str:
49+
return await workflow.execute_activity(
50+
env_override_activity,
51+
start_to_close_timeout=timedelta(seconds=10),
52+
)
53+
54+
55+
# ---------------------------------------------------------------------------
56+
# Tests
57+
# ---------------------------------------------------------------------------
58+
59+
60+
class TestTracingEnvOverride:
61+
"""LangSmithPlugin must respect LANGSMITH_TRACING=false."""
62+
63+
async def test_no_runs_when_tracing_disabled_with_temporal_runs(
64+
self,
65+
client: Client,
66+
env: WorkflowEnvironment, # type:ignore[reportUnusedParameter]
67+
monkeypatch: pytest.MonkeyPatch,
68+
) -> None:
69+
"""With LANGSMITH_TRACING=false and add_temporal_runs=True, no runs."""
70+
monkeypatch.setenv("LANGSMITH_TRACING", "false")
71+
monkeypatch.delenv("LANGCHAIN_TRACING_V2", raising=False)
72+
73+
temporal_client, collector, _ = _make_client_and_collector(
74+
client, add_temporal_runs=True
75+
)
76+
77+
async with new_worker(
78+
temporal_client,
79+
EnvOverrideWorkflow,
80+
activities=[env_override_activity],
81+
max_cached_workflows=0,
82+
) as worker:
83+
handle = await temporal_client.start_workflow(
84+
EnvOverrideWorkflow.run,
85+
id=f"env-override-temporal-{uuid.uuid4()}",
86+
task_queue=worker.task_queue,
87+
)
88+
result = await handle.result()
89+
90+
assert result == "response to: hello"
91+
assert len(collector.runs) == 0, (
92+
f"Expected zero LangSmith runs when LANGSMITH_TRACING=false, "
93+
f"but got {len(collector.runs)}: "
94+
f"{[r.name for r in collector.runs]}"
95+
)
96+
97+
async def test_no_runs_when_tracing_disabled_without_temporal_runs(
98+
self,
99+
client: Client,
100+
env: WorkflowEnvironment, # type:ignore[reportUnusedParameter]
101+
monkeypatch: pytest.MonkeyPatch,
102+
) -> None:
103+
"""With LANGSMITH_TRACING=false and add_temporal_runs=False, no runs."""
104+
monkeypatch.setenv("LANGSMITH_TRACING", "false")
105+
monkeypatch.delenv("LANGCHAIN_TRACING_V2", raising=False)
106+
107+
temporal_client, collector, _ = _make_client_and_collector(
108+
client, add_temporal_runs=False
109+
)
110+
111+
async with new_worker(
112+
temporal_client,
113+
EnvOverrideWorkflow,
114+
activities=[env_override_activity],
115+
max_cached_workflows=0,
116+
) as worker:
117+
handle = await temporal_client.start_workflow(
118+
EnvOverrideWorkflow.run,
119+
id=f"env-override-no-temporal-{uuid.uuid4()}",
120+
task_queue=worker.task_queue,
121+
)
122+
result = await handle.result()
123+
124+
assert result == "response to: hello"
125+
assert len(collector.runs) == 0, (
126+
f"Expected zero LangSmith runs when LANGSMITH_TRACING=false, "
127+
f"but got {len(collector.runs)}: "
128+
f"{[r.name for r in collector.runs]}"
129+
)
130+
131+
async def test_no_runs_when_langchain_tracing_v2_disabled(
132+
self,
133+
client: Client,
134+
env: WorkflowEnvironment, # type:ignore[reportUnusedParameter]
135+
monkeypatch: pytest.MonkeyPatch,
136+
) -> None:
137+
"""LANGCHAIN_TRACING_V2=false also suppresses runs (legacy env var)."""
138+
monkeypatch.setenv("LANGCHAIN_TRACING_V2", "false")
139+
monkeypatch.delenv("LANGSMITH_TRACING", raising=False)
140+
141+
temporal_client, collector, _ = _make_client_and_collector(
142+
client, add_temporal_runs=True
143+
)
144+
145+
async with new_worker(
146+
temporal_client,
147+
EnvOverrideWorkflow,
148+
activities=[env_override_activity],
149+
max_cached_workflows=0,
150+
) as worker:
151+
handle = await temporal_client.start_workflow(
152+
EnvOverrideWorkflow.run,
153+
id=f"env-override-v2-{uuid.uuid4()}",
154+
task_queue=worker.task_queue,
155+
)
156+
result = await handle.result()
157+
158+
assert result == "response to: hello"
159+
assert len(collector.runs) == 0, (
160+
f"Expected zero LangSmith runs when LANGCHAIN_TRACING_V2=false, "
161+
f"but got {len(collector.runs)}: "
162+
f"{[r.name for r in collector.runs]}"
163+
)
164+
165+
async def test_no_runs_when_tracing_disabled_for_nexus_start(
166+
self,
167+
client: Client,
168+
env: WorkflowEnvironment,
169+
monkeypatch: pytest.MonkeyPatch,
170+
) -> None:
171+
"""With LANGSMITH_TRACING=false, nexus start handler emits no runs."""
172+
if env.supports_time_skipping:
173+
pytest.skip("Time-skipping server doesn't persist headers.")
174+
175+
monkeypatch.setenv("LANGSMITH_TRACING", "false")
176+
monkeypatch.delenv("LANGCHAIN_TRACING_V2", raising=False)
177+
178+
temporal_client, collector, _ = _make_client_and_collector(
179+
client, add_temporal_runs=True
180+
)
181+
182+
task_queue = f"env-override-nexus-{uuid.uuid4()}"
183+
async with new_worker(
184+
temporal_client,
185+
NexusDirectTraceableWorkflow,
186+
nexus_service_handlers=[DirectTraceableNexusService()],
187+
task_queue=task_queue,
188+
max_cached_workflows=0,
189+
) as worker:
190+
await env.create_nexus_endpoint(
191+
make_nexus_endpoint_name(worker.task_queue),
192+
worker.task_queue,
193+
)
194+
handle = await temporal_client.start_workflow(
195+
NexusDirectTraceableWorkflow.run,
196+
id=f"env-override-nexus-{uuid.uuid4()}",
197+
task_queue=worker.task_queue,
198+
)
199+
result = await handle.result()
200+
201+
assert result == "response to: nexus-input"
202+
assert len(collector.runs) == 0, (
203+
f"Expected zero LangSmith runs when LANGSMITH_TRACING=false "
204+
f"(nexus start path), but got {len(collector.runs)}: "
205+
f"{[r.name for r in collector.runs]}"
206+
)
207+
208+
# NOTE: test_no_runs_when_tracing_disabled_for_nexus_cancel is not
209+
# included — cancelling an in-flight nexus operation requires non-trivial
210+
# orchestration (long-running handler + external cancel signal). Flagged
211+
# for a follow-up ticket.
212+
213+
async def test_runs_emitted_when_tracing_enabled(
214+
self,
215+
client: Client,
216+
env: WorkflowEnvironment, # type:ignore[reportUnusedParameter]
217+
monkeypatch: pytest.MonkeyPatch,
218+
) -> None:
219+
"""Positive control: with LANGSMITH_TRACING=true, runs ARE emitted."""
220+
monkeypatch.setenv("LANGSMITH_TRACING", "true")
221+
monkeypatch.delenv("LANGCHAIN_TRACING_V2", raising=False)
222+
223+
temporal_client, collector, _ = _make_client_and_collector(
224+
client, add_temporal_runs=True
225+
)
226+
227+
async with new_worker(
228+
temporal_client,
229+
EnvOverrideWorkflow,
230+
activities=[env_override_activity],
231+
max_cached_workflows=0,
232+
) as worker:
233+
handle = await temporal_client.start_workflow(
234+
EnvOverrideWorkflow.run,
235+
id=f"env-enabled-{uuid.uuid4()}",
236+
task_queue=worker.task_queue,
237+
)
238+
result = await handle.result()
239+
240+
assert result == "response to: hello"
241+
assert (
242+
len(collector.runs) > 0
243+
), "Expected LangSmith runs when LANGSMITH_TRACING=true, but got none"

0 commit comments

Comments
 (0)