@temporalio/langsmith 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Workflow-isolate interceptors and the deterministic LangSmith context
3
+ * provider. Loaded into the workflow bundle (via the plugin's `workflowModules`)
4
+ * and runs entirely inside the V8 isolate, where `node:async_hooks` is
5
+ * unavailable so a synchronous stack-based context provider stands in for
6
+ * LangSmith's async-context store.
7
+ *
8
+ * Internal: the worker loads this module by specifier and the bundler aliases
9
+ * `node:async_hooks` to it — it is not a hand-import API.
10
+ *
11
+ * @module
12
+ * @internal
13
+ */
14
+
15
+ import type { RunTree } from 'langsmith/run_trees';
16
+ import { AsyncLocalStorageProviderSingleton } from 'langsmith/singletons/traceable';
17
+ import type {
18
+ ContinueAsNewInput,
19
+ Next,
20
+ WorkflowExecuteInput,
21
+ WorkflowInboundCallsInterceptor,
22
+ WorkflowInterceptors,
23
+ WorkflowOutboundCallsInterceptor,
24
+ } from '@temporalio/workflow';
25
+ import {
26
+ workflowInfo,
27
+ type ActivityInput,
28
+ type Headers,
29
+ type LocalActivityInput,
30
+ type QueryInput,
31
+ type SignalInput,
32
+ type SignalWorkflowInput,
33
+ type StartChildWorkflowExecutionInput,
34
+ type StartNexusOperationInput,
35
+ type StartNexusOperationOutput,
36
+ type UpdateInput,
37
+ } from '@temporalio/workflow';
38
+ import {
39
+ HEADER_KEY,
40
+ encodeContextString,
41
+ isInternalQuery,
42
+ readContextHeader,
43
+ scrubSensitive,
44
+ withContextHeader,
45
+ } from './propagation';
46
+ import {
47
+ ReplaySafeRunTree,
48
+ _RootReplaySafeRunTreeFactory,
49
+ RUN_TYPE,
50
+ asOutputs,
51
+ describeError,
52
+ emitMarkerRun,
53
+ handleQueryRunName,
54
+ handleSignalRunName,
55
+ handleUpdateRunName,
56
+ runHeaders,
57
+ runTreeFromContext,
58
+ runWorkflowRunName,
59
+ signalChildWorkflowRunName,
60
+ signalExternalWorkflowRunName,
61
+ startActivityRunName,
62
+ startChildWorkflowRunName,
63
+ startNexusOperationRunName,
64
+ validateUpdateRunName,
65
+ } from './run-tree';
66
+
67
+ /**
68
+ * Configuration the worker injects into the workflow bundle via the bundler's
69
+ * `DefinePlugin`. Mirrors the user-facing plugin options that the workflow
70
+ * isolate needs in order to decide whether to emit Temporal-operation runs.
71
+ *
72
+ * @internal
73
+ */
74
+ export interface WorkflowLangSmithConfig {
75
+ addTemporalRuns: boolean;
76
+ projectName?: string;
77
+ tags?: string[];
78
+ metadata?: Record<string, unknown>;
79
+ }
80
+
81
+ // Injected at bundle time by the plugin's DefinePlugin. Declared so the bare
82
+ // identifier is replaced textually (avoids the dotted-access DefinePlugin
83
+ // pitfall). Absent in environments that bundled without the plugin.
84
+ declare const __TEMPORAL_LANGSMITH_CONFIG__: string | undefined;
85
+
86
+ function readConfig(): WorkflowLangSmithConfig {
87
+ try {
88
+ if (typeof __TEMPORAL_LANGSMITH_CONFIG__ === 'string') {
89
+ return JSON.parse(__TEMPORAL_LANGSMITH_CONFIG__) as WorkflowLangSmithConfig;
90
+ }
91
+ } catch {
92
+ /* fall through to default */
93
+ }
94
+ return { addTemporalRuns: false };
95
+ }
96
+
97
+ /**
98
+ * Synchronous, isolate-safe replacement for LangSmith's async-context store,
99
+ * shared by the installed provider, the {@link AsyncLocalStorage} shim, and
100
+ * every interceptor so they all read and write one store. `workflowAmbient`
101
+ * persists across `await` boundaries; `stack` tracks synchronous `traceable`
102
+ * nesting. Under `Promise.all` fan-out parenting falls back to the ambient —
103
+ * trace-shape-only non-determinism, never workflow history.
104
+ */
105
+ class WorkflowContextManager {
106
+ private workflowAmbient: RunTree | undefined;
107
+ private readonly stack: (RunTree | undefined)[] = [];
108
+
109
+ getStore(): RunTree | undefined {
110
+ return this.stack.length > 0 ? this.stack[this.stack.length - 1] : this.workflowAmbient;
111
+ }
112
+
113
+ /**
114
+ * Push `context`, run `fn`, pop in `finally`, and return `fn`'s result — the
115
+ * `AsyncLocalStorage.run` contract langsmith relies on.
116
+ */
117
+ run<T>(context: RunTree | undefined, fn: () => T): T {
118
+ this.stack.push(context);
119
+ try {
120
+ return fn();
121
+ } finally {
122
+ this.stack.pop();
123
+ }
124
+ }
125
+
126
+ /** The ambient run an outbound interceptor should propagate / parent under. */
127
+ ambient(): RunTree | undefined {
128
+ return this.getStore();
129
+ }
130
+
131
+ /** Run an async scope with a dedicated ambient (workflow execute / handler). */
132
+ async withAmbient<T>(run: RunTree | undefined, fn: () => Promise<T>): Promise<T> {
133
+ const prev = this.workflowAmbient;
134
+ this.workflowAmbient = run;
135
+ try {
136
+ return await fn();
137
+ } finally {
138
+ this.workflowAmbient = prev;
139
+ }
140
+ }
141
+ }
142
+
143
+ let providerInstalled = false;
144
+ function ensureProviderInstalled(manager: WorkflowContextManager): void {
145
+ if (providerInstalled) {
146
+ return;
147
+ }
148
+ providerInstalled = true;
149
+ // LangSmith's `AsyncLocalStorageInterface` requires exactly two members,
150
+ // `getStore` and `run` (it never calls `enterWith`), so we provide only those.
151
+ // The cast bridges the generic `run<T>` signature to the interface's
152
+ // `run: (ctx, () => void) => void`.
153
+ AsyncLocalStorageProviderSingleton.initializeGlobalInstance({
154
+ getStore: () => manager.getStore(),
155
+ run: <T>(context: RunTree | undefined, fn: () => T): T => manager.run(context as RunTree | undefined, fn),
156
+ } as Parameters<typeof AsyncLocalStorageProviderSingleton.initializeGlobalInstance>[0]);
157
+ }
158
+
159
+ const sharedManager = new WorkflowContextManager();
160
+ ensureProviderInstalled(sharedManager);
161
+
162
+ /**
163
+ * Isolate-safe stand-in for Node's `AsyncLocalStorage`. The plugin's bundler
164
+ * aliases `node:async_hooks` (absent in the isolate) to this module so
165
+ * LangSmith's `import { AsyncLocalStorage } from "node:async_hooks"` resolves
166
+ * here. Every instance delegates to {@link sharedManager}.
167
+ *
168
+ * @internal
169
+ */
170
+ export class AsyncLocalStorage<T = unknown> {
171
+ getStore(): T | undefined {
172
+ return sharedManager.getStore() as unknown as T | undefined;
173
+ }
174
+
175
+ run<R>(store: T, fn: (...args: never[]) => R): R {
176
+ return sharedManager.run(store as unknown as RunTree | undefined, fn as () => R);
177
+ }
178
+
179
+ enterWith(_store: T): void {
180
+ /* no-op: parenting inside the isolate is stack-scoped via run() */
181
+ }
182
+
183
+ /**
184
+ * Best-effort equivalent of `AsyncLocalStorage.snapshot()` (used only by
185
+ * LangSmith's async-generator streaming paths). Runs the callback in the
186
+ * current synchronous scope; the isolate has no async context to capture.
187
+ */
188
+ static snapshot(): (fn: (...args: unknown[]) => unknown, ...args: unknown[]) => unknown {
189
+ return (fn, ...args) => fn(...args);
190
+ }
191
+ }
192
+
193
+ /** Reconstruct the propagated parent run from a Payload-keyed header map. */
194
+ function reconstructParent(headers: Record<string, unknown> | undefined): RunTree | undefined {
195
+ return runTreeFromContext(readContextHeader(headers as Record<string, never> | undefined));
196
+ }
197
+
198
+ /**
199
+ * Re-wrap a reconstructed (plain) parent so its `createChild` produces
200
+ * replay-safe children. Carries the same id/trace/dotted so descendants parent
201
+ * under the real propagated run; never emitted itself.
202
+ */
203
+ function asReplaySafeParent(
204
+ parent: RunTree | undefined,
205
+ generateNewUuid4?: () => string
206
+ ): ReplaySafeRunTree | undefined {
207
+ if (!parent) {
208
+ return undefined;
209
+ }
210
+ return new ReplaySafeRunTree(
211
+ {
212
+ name: parent.name,
213
+ run_type: parent.run_type,
214
+ id: parent.id,
215
+ trace_id: parent.trace_id,
216
+ dotted_order: parent.dotted_order,
217
+ parent_run_id: parent.parent_run_id,
218
+ project_name: parent.project_name,
219
+ },
220
+ generateNewUuid4
221
+ );
222
+ }
223
+
224
+ /**
225
+ * A placeholder parent for the no-propagated-parent case. Installed as the ambient
226
+ * so a workflow-body `traceable` always nests via `createChild`; never emitted,
227
+ * and its children are independent roots (see {@link _RootReplaySafeRunTreeFactory}).
228
+ */
229
+ function placeholderRoot(generateNewUuid4?: () => string): _RootReplaySafeRunTreeFactory {
230
+ return new _RootReplaySafeRunTreeFactory(
231
+ { name: workflowInfo().workflowType, run_type: RUN_TYPE.CHAIN },
232
+ generateNewUuid4
233
+ );
234
+ }
235
+
236
+ /** Emit a short-lived marker run as a child of `parent`. */
237
+ async function emitMarker(
238
+ parent: ReplaySafeRunTree,
239
+ name: string,
240
+ inputs: Record<string, unknown>
241
+ ): Promise<ReplaySafeRunTree> {
242
+ const marker = parent.createChild({ name, run_type: RUN_TYPE.CHAIN, inputs });
243
+ await emitMarkerRun(marker);
244
+ return marker;
245
+ }
246
+
247
+ function buildReplaySafeRunTree(
248
+ config: WorkflowLangSmithConfig,
249
+ params: {
250
+ name: string;
251
+ runType: string;
252
+ parent: ReplaySafeRunTree | undefined;
253
+ inputs: Record<string, unknown>;
254
+ generateNewUuid4?: () => string;
255
+ }
256
+ ): ReplaySafeRunTree {
257
+ return new ReplaySafeRunTree(
258
+ {
259
+ name: params.name,
260
+ run_type: params.runType,
261
+ parent_run: params.parent,
262
+ project_name: config.projectName,
263
+ tags: config.tags,
264
+ extra: { metadata: scrubSensitive(config.metadata) ?? {} },
265
+ inputs: params.inputs,
266
+ },
267
+ params.generateNewUuid4
268
+ );
269
+ }
270
+
271
+ class LangSmithWorkflowInbound implements WorkflowInboundCallsInterceptor {
272
+ constructor(
273
+ private readonly ctx: WorkflowContextManager,
274
+ private readonly config: WorkflowLangSmithConfig
275
+ ) {}
276
+
277
+ async execute(input: WorkflowExecuteInput, next: Next<WorkflowInboundCallsInterceptor, 'execute'>): Promise<unknown> {
278
+ const parent = reconstructParent(input.headers);
279
+ return this.runInbound(
280
+ runWorkflowRunName(workflowInfo().workflowType),
281
+ RUN_TYPE.CHAIN,
282
+ parent,
283
+ { args: input.args },
284
+ () => next(input)
285
+ );
286
+ }
287
+
288
+ async handleSignal(input: SignalInput, next: Next<WorkflowInboundCallsInterceptor, 'handleSignal'>): Promise<void> {
289
+ const parent = reconstructParent(input.headers);
290
+ await this.runInbound(
291
+ handleSignalRunName(input.signalName),
292
+ RUN_TYPE.CHAIN,
293
+ parent,
294
+ { args: input.args },
295
+ () => next(input),
296
+ true
297
+ );
298
+ }
299
+
300
+ async handleQuery(input: QueryInput, next: Next<WorkflowInboundCallsInterceptor, 'handleQuery'>): Promise<unknown> {
301
+ if (isInternalQuery(input.queryName)) {
302
+ return next(input);
303
+ }
304
+ const parent = reconstructParent(input.headers);
305
+ return this.runInbound(
306
+ handleQueryRunName(input.queryName),
307
+ RUN_TYPE.CHAIN,
308
+ parent,
309
+ { args: input.args },
310
+ () => next(input),
311
+ true,
312
+ // Read-only: mint run ids from the nondeterministic unsafe random source so the
313
+ // handler never draws from the Workflow main PRNG (which a clean replay would not advance).
314
+ workflowInfo().unsafe.random.uuid4
315
+ );
316
+ }
317
+
318
+ async handleUpdate(
319
+ input: UpdateInput,
320
+ next: Next<WorkflowInboundCallsInterceptor, 'handleUpdate'>
321
+ ): Promise<unknown> {
322
+ const parent = reconstructParent(input.headers);
323
+ return this.runInbound(
324
+ handleUpdateRunName(input.name),
325
+ RUN_TYPE.CHAIN,
326
+ parent,
327
+ { args: input.args },
328
+ () => next(input),
329
+ true
330
+ );
331
+ }
332
+
333
+ validateUpdate(input: UpdateInput, next: Next<WorkflowInboundCallsInterceptor, 'validateUpdate'>): void {
334
+ // Read-only: mint run ids from the nondeterministic unsafe random source so the validator
335
+ // never draws from the Workflow main PRNG (which a clean replay would not advance).
336
+ const generateNewUuid4 = workflowInfo().unsafe.random.uuid4;
337
+ if (!this.config.addTemporalRuns) {
338
+ // Propagation only: install the reconstructed parent (or a placeholder parent
339
+ // when none was propagated, to keep a validator-body `traceable` off the
340
+ // no-parent `crypto` path) so the validator body nests under the update's
341
+ // trace. Validators are synchronous, so install via the stack-based `run`,
342
+ // not the async `withAmbient`.
343
+ const ambient =
344
+ asReplaySafeParent(reconstructParent(input.headers), generateNewUuid4) ?? placeholderRoot(generateNewUuid4);
345
+ this.ctx.run(ambient, () => next(input));
346
+ return;
347
+ }
348
+ const run = buildReplaySafeRunTree(this.config, {
349
+ name: validateUpdateRunName(input.name),
350
+ runType: RUN_TYPE.CHAIN,
351
+ parent: asReplaySafeParent(reconstructParent(input.headers), generateNewUuid4),
352
+ inputs: { args: input.args },
353
+ generateNewUuid4,
354
+ });
355
+ // Validators are synchronous and must not be made async; emit start/end
356
+ // around the synchronous call, with the run installed on the stack so a
357
+ // `traceable` in the validator body nests under it.
358
+ void run.postRun();
359
+ try {
360
+ this.ctx.run(run, () => next(input));
361
+ void run.end({});
362
+ } catch (err) {
363
+ void run.end(undefined, describeError(err));
364
+ void run.patchRun();
365
+ throw err;
366
+ }
367
+ void run.patchRun();
368
+ }
369
+
370
+ /**
371
+ * Open the inbound run, install it as the active run for `next`, and close it.
372
+ *
373
+ * `scoped` handlers install the run on the synchronous stack so a handler running
374
+ * concurrently with the workflow body cannot leak its run into the body's ambient;
375
+ * `execute` (scoped: false) installs a persistent ambient that survives the body's
376
+ * awaits.
377
+ */
378
+ private async runInbound(
379
+ name: string,
380
+ runType: string,
381
+ parent: RunTree | undefined,
382
+ inputs: Record<string, unknown>,
383
+ next: () => Promise<unknown>,
384
+ scoped = false,
385
+ generateNewUuid4?: () => string
386
+ ): Promise<unknown> {
387
+ if (!this.config.addTemporalRuns) {
388
+ // Propagation only: install the reconstructed parent as the active run so
389
+ // user `traceable` runs nest under it; never emit a Temporal-operation run.
390
+ // With no propagated parent, install a placeholder parent instead of
391
+ // `undefined` so a workflow-body `traceable` takes LangSmith's
392
+ // `createChild` branch (deterministic id) rather than the no-parent branch
393
+ // that mints a uuid via `crypto`, which the isolate lacks.
394
+ const ambient = asReplaySafeParent(parent, generateNewUuid4) ?? placeholderRoot(generateNewUuid4);
395
+ return scoped ? this.ctx.run(ambient, next) : this.ctx.withAmbient(ambient, next);
396
+ }
397
+ const run = buildReplaySafeRunTree(this.config, {
398
+ name,
399
+ runType,
400
+ parent: asReplaySafeParent(parent, generateNewUuid4),
401
+ inputs,
402
+ generateNewUuid4,
403
+ });
404
+ await run.postRun();
405
+ const body = async (): Promise<unknown> => {
406
+ try {
407
+ const result = await next();
408
+ await run.end(asOutputs(result));
409
+ await run.patchRun();
410
+ return result;
411
+ } catch (err) {
412
+ await run.end(undefined, describeError(err));
413
+ await run.patchRun();
414
+ throw err;
415
+ }
416
+ };
417
+ return scoped ? this.ctx.run(run, body) : this.ctx.withAmbient(run, body);
418
+ }
419
+ }
420
+
421
+ class LangSmithWorkflowOutbound implements WorkflowOutboundCallsInterceptor {
422
+ constructor(
423
+ private readonly ctx: WorkflowContextManager,
424
+ private readonly config: WorkflowLangSmithConfig
425
+ ) {}
426
+
427
+ async scheduleActivity(
428
+ input: ActivityInput,
429
+ next: Next<WorkflowOutboundCallsInterceptor, 'scheduleActivity'>
430
+ ): Promise<unknown> {
431
+ return this.peerMarker(startActivityRunName(input.activityType), input, (headers: Headers) =>
432
+ next({ ...input, headers })
433
+ );
434
+ }
435
+
436
+ async scheduleLocalActivity(
437
+ input: LocalActivityInput,
438
+ next: Next<WorkflowOutboundCallsInterceptor, 'scheduleLocalActivity'>
439
+ ): Promise<unknown> {
440
+ return this.peerMarker(startActivityRunName(input.activityType), input, (headers: Headers) =>
441
+ next({ ...input, headers })
442
+ );
443
+ }
444
+
445
+ async startChildWorkflowExecution(
446
+ input: StartChildWorkflowExecutionInput,
447
+ next: Next<WorkflowOutboundCallsInterceptor, 'startChildWorkflowExecution'>
448
+ ): Promise<[Promise<string>, Promise<unknown>]> {
449
+ return this.peerMarker(startChildWorkflowRunName(input.workflowType), input, (headers: Headers) =>
450
+ next({ ...input, headers })
451
+ );
452
+ }
453
+
454
+ continueAsNew(
455
+ input: ContinueAsNewInput,
456
+ next: Next<WorkflowOutboundCallsInterceptor, 'continueAsNew'>
457
+ ): Promise<never> {
458
+ // Don't propagate a placeholder root: it is never emitted, so the successor's
459
+ // runs would dangle under a nonexistent parent. Let it install its own.
460
+ const ambient = this.ctx.ambient();
461
+ const context = ambient instanceof _RootReplaySafeRunTreeFactory ? undefined : runHeaders(ambient);
462
+ const headers = withContextHeader(input.headers, context);
463
+ return next({ ...input, headers });
464
+ }
465
+
466
+ async signalWorkflow(
467
+ input: SignalWorkflowInput,
468
+ next: Next<WorkflowOutboundCallsInterceptor, 'signalWorkflow'>
469
+ ): Promise<void> {
470
+ const isChild = input.target.type === 'child';
471
+ const name = isChild
472
+ ? signalChildWorkflowRunName(input.signalName)
473
+ : signalExternalWorkflowRunName(input.signalName);
474
+ return this.parentMarker(name, input, (headers: Headers) => next({ ...input, headers }));
475
+ }
476
+
477
+ async startNexusOperation(
478
+ input: StartNexusOperationInput,
479
+ next: Next<WorkflowOutboundCallsInterceptor, 'startNexusOperation'>
480
+ ): Promise<StartNexusOperationOutput> {
481
+ const ambient = this.ctx.ambient();
482
+ if (this.config.addTemporalRuns && ambient instanceof ReplaySafeRunTree) {
483
+ await emitMarker(ambient, startNexusOperationRunName(input.service, input.operation), {});
484
+ }
485
+ const ctx = runHeaders(ambient);
486
+ const headers = ctx ? { ...input.headers, [HEADER_KEY]: encodeContextString(ctx) } : input.headers;
487
+ return next({ ...input, headers });
488
+ }
489
+
490
+ /** Sibling-marker operations: marker is a peer of the remote run; propagate ambient. */
491
+ private async peerMarker<R>(
492
+ name: string,
493
+ input: { headers: Headers; args?: unknown[] },
494
+ next: (headers: Headers) => Promise<R>
495
+ ): Promise<R> {
496
+ const ambient = this.ctx.ambient();
497
+ if (this.config.addTemporalRuns && ambient instanceof ReplaySafeRunTree) {
498
+ await emitMarker(ambient, name, { args: input.args ?? [] });
499
+ }
500
+ const headers = withContextHeader(input.headers, runHeaders(ambient));
501
+ return next(headers);
502
+ }
503
+
504
+ /** Parent-marker operations: remote handler nests under the marker; propagate marker. */
505
+ private async parentMarker<R>(
506
+ name: string,
507
+ input: { headers: Headers; args?: unknown[] },
508
+ next: (headers: Headers) => Promise<R>
509
+ ): Promise<R> {
510
+ const ambient = this.ctx.ambient();
511
+ let propagate: RunTree | undefined = ambient;
512
+ if (this.config.addTemporalRuns && ambient instanceof ReplaySafeRunTree) {
513
+ propagate = await emitMarker(ambient, name, { args: input.args ?? [] });
514
+ }
515
+ const headers = withContextHeader(input.headers, runHeaders(propagate));
516
+ return next(headers);
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Workflow interceptors factory. Loaded by the Temporal worker for every
522
+ * workflow in the bundle (registered via the plugin's `workflowModules`).
523
+ *
524
+ * @internal
525
+ */
526
+ export function interceptors(): WorkflowInterceptors {
527
+ const config = readConfig();
528
+ return {
529
+ inbound: [new LangSmithWorkflowInbound(sharedManager, config)],
530
+ outbound: [new LangSmithWorkflowOutbound(sharedManager, config)],
531
+ };
532
+ }