|
| 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 |
0 commit comments