Skip to content

Commit 90275fb

Browse files
authored
feat(langsmith): add @temporalio/langsmith package (#2099)
1 parent 55b7eb4 commit 90275fb

33 files changed

Lines changed: 3721 additions & 0 deletions

contrib/langsmith/README.md

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# @temporalio/langsmith — LangSmith tracing for Temporal
2+
3+
This plugin makes [LangSmith](https://docs.smith.langchain.com/) observability
4+
work inside Temporal Workflows and Activities **without changing your existing
5+
instrumentation**. Code you already trace with LangSmith's native `traceable`
6+
keeps working when you move it into a Workflow or Activity body — you only add
7+
the plugin to your `Client` and `Worker`.
8+
9+
This plugin is built on Temporal's Plugin API, which is experimental; its APIs
10+
may change in a future release.
11+
12+
It handles the parts that are otherwise hard:
13+
14+
- **Replay safety.** Workflows replay history; a naive tracer re-emits every run
15+
on every replay and floods your project with duplicates. This plugin emits
16+
runs with deterministic IDs out-of-isolate via a Temporal Sink that does not
17+
fire during replay.
18+
- **Run parenting across boundaries.** A trace started on the client threads
19+
through `workflow → activity → child-workflow → Nexus` so the runs nest the
20+
way you expect, instead of fragmenting into disconnected roots.
21+
22+
## Install
23+
24+
```bash
25+
npm install @temporalio/langsmith langsmith
26+
```
27+
28+
`langsmith` is a peer dependency — install the version your project already uses.
29+
30+
## Enable tracing
31+
32+
Tracing is **off by default**, matching the `langsmith` library: the plugin
33+
emits nothing unless LangSmith tracing is enabled in the worker/client process
34+
environment. Enable it with LangSmith's standard environment variables:
35+
36+
```bash
37+
export LANGSMITH_TRACING=true
38+
export LANGSMITH_API_KEY="<your key>"
39+
```
40+
41+
The plugin reads the same flags LangSmith itself uses (`LANGSMITH_TRACING` /
42+
`LANGSMITH_TRACING_V2` and their `LANGCHAIN_` aliases); with none set to `true`,
43+
tracing stays off and the plugin is a no-op.
44+
45+
## Hello world
46+
47+
Your existing LangSmith instrumentation does not change. Here a `traceable`
48+
runs inside an Activity:
49+
50+
```typescript
51+
// activities.ts
52+
import { traceable } from 'langsmith/traceable';
53+
54+
const callModel = traceable(
55+
async (prompt: string) => {
56+
// ... your real model call ...
57+
return `answer to: ${prompt}`;
58+
},
59+
{ name: 'inner_llm_call' }
60+
);
61+
62+
export async function answer(prompt: string): Promise<string> {
63+
return callModel(prompt);
64+
}
65+
```
66+
67+
```typescript
68+
// workflows.ts
69+
import { proxyActivities } from '@temporalio/workflow';
70+
import type * as activities from './activities';
71+
72+
const { answer } = proxyActivities<typeof activities>({
73+
startToCloseTimeout: '1 minute',
74+
});
75+
76+
export async function GreetingWorkflow(prompt: string): Promise<string> {
77+
return answer(prompt);
78+
}
79+
```
80+
81+
The only new code is the plugin registration on the `Client` and the `Worker`:
82+
83+
```typescript
84+
// worker.ts
85+
import { Worker } from '@temporalio/worker';
86+
import { Client } from '@temporalio/client';
87+
import { Client as LangSmithClient } from 'langsmith';
88+
import { LangSmithPlugin } from '@temporalio/langsmith';
89+
import * as activities from './activities';
90+
91+
const langsmith = new LangSmithClient(); // reads LANGSMITH_API_KEY from the env
92+
93+
// In TypeScript the Client and Worker are configured independently, so add the
94+
// plugin to each. (Construct one plugin instance and share it.)
95+
const plugin = new LangSmithPlugin({ client: langsmith, addTemporalRuns: true });
96+
97+
const client = new Client({ plugins: [plugin] });
98+
99+
const worker = await Worker.create({
100+
taskQueue: 'greeting',
101+
workflowsPath: require.resolve('./workflows'),
102+
activities,
103+
plugins: [plugin],
104+
});
105+
106+
await worker.run();
107+
```
108+
109+
```typescript
110+
// starter.ts — start the workflow from inside your own trace
111+
import { traceable } from 'langsmith/traceable';
112+
113+
const pipeline = traceable(
114+
async () => {
115+
return client.workflow.execute(GreetingWorkflow, {
116+
taskQueue: 'greeting',
117+
workflowId: 'greeting-1',
118+
args: ['hello'],
119+
});
120+
},
121+
{ name: 'user_pipeline' }
122+
);
123+
124+
await pipeline();
125+
```
126+
127+
With `addTemporalRuns: true`, the resulting trace nests like this:
128+
129+
```
130+
user_pipeline
131+
StartWorkflow:GreetingWorkflow
132+
RunWorkflow:GreetingWorkflow
133+
StartActivity:answer
134+
RunActivity:answer
135+
inner_llm_call
136+
```
137+
138+
Set `addTemporalRuns: false` (the default) to emit only your own `traceable`
139+
runs — the trace context still propagates across boundaries, so they nest
140+
correctly, but no `StartWorkflow:` / `RunActivity:` scaffolding is added:
141+
142+
```
143+
user_pipeline
144+
inner_llm_call
145+
```
146+
147+
## Options
148+
149+
| Option | Default | Meaning |
150+
| ----------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
151+
| `client` | `new Client()` | The LangSmith client runs are emitted to. Lives only in the worker/client process — it is never serialized or sent across a Temporal boundary. |
152+
| `addTemporalRuns` | `false` | Emit first-class runs for Temporal operations (`StartWorkflow:`, `RunActivity:`, `HandleSignal:`, …) in addition to your `traceable` runs. |
153+
| `projectName` | LangSmith default | Target LangSmith project for emitted runs. |
154+
| `tags` || Tags attached to every run the plugin emits. |
155+
| `metadata` || Metadata merged into every run the plugin emits. Credential-looking keys are scrubbed before emission. |
156+
157+
The LangSmith API key is never accepted as a plugin option and never crosses a
158+
Temporal boundary. Supply a pre-constructed `Client` (which reads
159+
`LANGSMITH_API_KEY` from the process environment) or let the plugin build a
160+
default client from the environment.
161+
162+
## Where `traceable` works
163+
164+
`traceable` from `langsmith/traceable` works **unchanged** in every position
165+
below. Each row is exercised by the plugin's test suite.
166+
167+
| Position | Works? | Notes |
168+
| ------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
169+
| Client side, before `client.workflow.start(...)` || The trace propagates into the workflow; `RunWorkflow:` nests under your run. |
170+
| Inside an **Activity** body || Runs in the real worker process. Nests under `RunActivity:` (or under the propagated parent when `addTemporalRuns: false`). |
171+
| Inside a **Workflow** body || Replay-safe: deterministic IDs, emitted out-of-isolate, suppressed on replay. Works whether or not a parent trace is propagated in. |
172+
| Inside **signal / query / update** handlers || Handler-body `traceable` runs nest under the handler's run (workflow-body semantics). Temporal-internal queries (`__temporal*`, `__stack_trace`) are never traced. |
173+
174+
**Concurrency caveat (workflow body only).** Inside a Workflow, parent
175+
resolution uses a synchronous context stack rather than `node:async_hooks`
176+
(which is unavailable in the workflow isolate). Sequential `await inner(...)`
177+
nesting is exact. Under `Promise.all(...)` fan-out, or for `traceable` calls
178+
made _after_ an `await` in the same scope, parenting falls back to the workflow
179+
run. This affects only the visual shape of the trace, never workflow history or
180+
control flow. Activity-body and client-side `traceable` use LangSmith's real
181+
async context and are unaffected.
182+
183+
## Process-wide effects to be aware of
184+
185+
- **Workflow context provider.** Loading the plugin's workflow interceptor
186+
module installs a global LangSmith async-context provider inside the workflow
187+
isolate (via `AsyncLocalStorageProviderSingleton.initializeGlobalInstance`).
188+
This is what lets unchanged `traceable` calls find their parent inside a
189+
Workflow. It is scoped to the workflow bundle and does not affect your
190+
client/worker process context.
191+
192+
## Composing with other plugins
193+
194+
Register observability **first** (outermost) so it observes everything beneath
195+
it, then governance, then agent-framework plugins:
196+
197+
```typescript
198+
const worker = await Worker.create({
199+
taskQueue: 'tq',
200+
workflowsPath: require.resolve('./workflows'),
201+
activities,
202+
plugins: [
203+
new LangSmithPlugin({ client: langsmith }), // observability — first
204+
// new GovernancePlugin(...),
205+
// new AgentFrameworkPlugin(...),
206+
],
207+
});
208+
```
209+
210+
The plugin is safe to register on both the `Client` and the `Worker`; it
211+
de-duplicates its own interceptors and sinks, so a worker built from a
212+
plugin-configured client will not double-instrument.
213+
214+
## License
215+
216+
MIT

contrib/langsmith/package.json

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
{
2+
"name": "@temporalio/langsmith",
3+
"version": "1.17.2",
4+
"description": "Temporal LangSmith observability integration package",
5+
"main": "lib/index.js",
6+
"types": "./lib/index.d.ts",
7+
"typesVersions": {
8+
"*": {
9+
"workflow-interceptors": [
10+
"./lib/workflow-interceptors.d.ts"
11+
]
12+
}
13+
},
14+
"scripts": {
15+
"build": "tsc --build",
16+
"test": "ava ./lib/__tests__/test-*.js"
17+
},
18+
"ava": {
19+
"files": [
20+
"./lib/__tests__/test-*.js"
21+
],
22+
"timeout": "120s",
23+
"concurrency": 1,
24+
"workerThreads": false
25+
},
26+
"exports": {
27+
".": {
28+
"types": "./lib/index.d.ts",
29+
"import": "./lib/index.js",
30+
"require": "./lib/index.js",
31+
"default": "./lib/index.js"
32+
},
33+
"./workflow-interceptors": {
34+
"types": "./lib/workflow-interceptors.d.ts",
35+
"import": "./lib/workflow-interceptors.js",
36+
"require": "./lib/workflow-interceptors.js",
37+
"default": "./lib/workflow-interceptors.js"
38+
}
39+
},
40+
"keywords": [
41+
"temporal",
42+
"workflow",
43+
"ai",
44+
"langsmith",
45+
"observability",
46+
"tracing"
47+
],
48+
"author": "Temporal Technologies Inc. <sdk@temporal.io>",
49+
"license": "MIT",
50+
"dependencies": {
51+
"@temporalio/activity": "workspace:*",
52+
"@temporalio/client": "workspace:*",
53+
"@temporalio/common": "workspace:*",
54+
"@temporalio/plugin": "workspace:*",
55+
"@temporalio/worker": "workspace:*",
56+
"@temporalio/workflow": "workspace:*"
57+
},
58+
"peerDependencies": {
59+
"langsmith": "^0.7.9"
60+
},
61+
"devDependencies": {
62+
"@temporalio/testing": "workspace:*",
63+
"ava": "^5.3.1",
64+
"langsmith": "^0.7.9",
65+
"nexus-rpc": "^0.0.2"
66+
},
67+
"engines": {
68+
"node": ">= 20.0.0"
69+
},
70+
"bugs": {
71+
"url": "https://github.com/temporalio/sdk-typescript/issues"
72+
},
73+
"repository": {
74+
"type": "git",
75+
"url": "git+https://github.com/temporalio/sdk-typescript.git",
76+
"directory": "contrib/langsmith"
77+
},
78+
"homepage": "https://github.com/temporalio/sdk-typescript/tree/main/contrib/langsmith",
79+
"publishConfig": {
80+
"access": "public"
81+
},
82+
"files": [
83+
"src",
84+
"lib",
85+
"!src/__tests__",
86+
"!lib/__tests__"
87+
]
88+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Activities for the comprehensive trace-tree test.
3+
*
4+
* @module
5+
*/
6+
7+
import { traceable } from 'langsmith/traceable';
8+
9+
/** Activity body that traces two levels: `comprehensive_activity` → `comprehensive_activity_inner`. */
10+
const comprehensiveActivityImpl = traceable(
11+
async (input: string): Promise<string> =>
12+
traceable(async (leaf: string): Promise<string> => `llm:${leaf}`, { name: 'comprehensive_activity_inner' })(input),
13+
{ name: 'comprehensive_activity' }
14+
);
15+
16+
export async function comprehensiveActivity(input: string): Promise<string> {
17+
return comprehensiveActivityImpl(input);
18+
}
19+
20+
/** A local activity with no instrumentation in its body. */
21+
export async function comprehensiveLocalActivity(input: string): Promise<string> {
22+
return `local:${input}`;
23+
}
24+
25+
// A module-level promise lets the workflow signal the driver that it has finished
26+
// its outbound boundaries and is ready for handler calls, keeping the emitted tree
27+
// deterministic.
28+
let readyResolve: (() => void) | undefined;
29+
30+
/** Called by the test before starting the workflow; returns a fresh "ready" promise. */
31+
export function resetReady(): Promise<void> {
32+
return new Promise<void>((resolve) => {
33+
readyResolve = resolve;
34+
});
35+
}
36+
37+
/** Run by the workflow once its outbound boundaries are done. */
38+
export async function notifyReady(): Promise<void> {
39+
readyResolve?.();
40+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Activity implementations for the plugin test suite.
3+
*
4+
* @module
5+
*/
6+
7+
import { ApplicationFailure, ApplicationFailureCategory } from '@temporalio/common';
8+
9+
export async function simpleActivity(input: string): Promise<string> {
10+
return `did:${input}`;
11+
}
12+
13+
/** A plain activity with no `traceable` in its body. */
14+
export async function plainActivity(input: string): Promise<string> {
15+
return `plain:${input}`;
16+
}
17+
18+
export async function failingActivity(): Promise<never> {
19+
throw ApplicationFailure.create({ message: 'activity-failed', type: 'ApplicationError' });
20+
}
21+
22+
/** Activity that fails with a BENIGN-category error. */
23+
export async function benignFailingActivity(): Promise<never> {
24+
throw ApplicationFailure.create({
25+
message: 'benign-stop',
26+
type: 'BenignStop',
27+
category: ApplicationFailureCategory.BENIGN,
28+
nonRetryable: true,
29+
});
30+
}

0 commit comments

Comments
 (0)