@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,330 @@
1
+ /**
2
+ * Replay-safe LangSmith run construction for workflow code, plus the pure
3
+ * run-name builders shared by every interceptor.
4
+ *
5
+ * Inside the workflow isolate LangSmith's default `RunTree` is unsafe on two
6
+ * counts that {@link ReplaySafeRunTree} fixes:
7
+ *
8
+ * 1. **Non-deterministic ids.** Its default random-tailed uuid7 would mint a
9
+ * fresh id on every replay, flooding the backend with duplicates.
10
+ * 2. **Network I/O on replay.** `postRun` / `patchRun` would re-POST runs on
11
+ * every history replay.
12
+ *
13
+ * Internal: every export here is consumed only by sibling modules in this
14
+ * package; none is reachable through a package entry point.
15
+ *
16
+ * @module
17
+ * @internal
18
+ */
19
+
20
+ import { RunTree, convertToDottedOrderFormat, type RunTreeConfig } from 'langsmith/run_trees';
21
+ import type { Client } from 'langsmith';
22
+ import { ApplicationFailure, ApplicationFailureCategory } from '@temporalio/common';
23
+ import { getRandomStream, proxySinks, workflowInfo } from '@temporalio/workflow';
24
+
25
+ import { scrubSensitive, type LangSmithTraceContext } from './propagation';
26
+ import { LANGSMITH_SINK_NAME, type EmitterConfig, type LangSmithSinks, type SerializedRun } from './sinks';
27
+
28
+ /** Named random stream for recorded-path run ids; isolated from the workflow's main RNG. */
29
+ const LANGSMITH_RUN_TREE_STREAM = 'package-langsmith-tracing';
30
+
31
+ /** LangSmith run-type constants used for Temporal-operation runs. */
32
+ export const RUN_TYPE = {
33
+ /** Workflow / child-workflow / scheduling / handler spans. */
34
+ CHAIN: 'chain',
35
+ /** Activity-body execution spans. */
36
+ TOOL: 'tool',
37
+ } as const;
38
+
39
+ /** `StartWorkflow:<type>` — client/parent-side workflow-start marker. */
40
+ export const startWorkflowRunName = (workflowType: string): string => `StartWorkflow:${workflowType}`;
41
+ /** `RunWorkflow:<type>` — the workflow-execution span (inbound). */
42
+ export const runWorkflowRunName = (workflowType: string): string => `RunWorkflow:${workflowType}`;
43
+ /** `StartActivity:<name>` — workflow-side activity-schedule marker. */
44
+ export const startActivityRunName = (activityType: string): string => `StartActivity:${activityType}`;
45
+ /** `RunActivity:<name>` — the activity-execution span (activity inbound). */
46
+ export const runActivityRunName = (activityType: string): string => `RunActivity:${activityType}`;
47
+ /** `StartChildWorkflow:<type>` — workflow-side child-start marker. */
48
+ export const startChildWorkflowRunName = (workflowType: string): string => `StartChildWorkflow:${workflowType}`;
49
+ /** `StartNexusOperation:<service>/<op>` — workflow-side Nexus-start marker. */
50
+ export const startNexusOperationRunName = (service: string, operation: string): string =>
51
+ `StartNexusOperation:${service}/${operation}`;
52
+ /** `RunStartNexusOperationHandler:<service>/<op>` — Nexus start-handler span. */
53
+ export const runStartNexusHandlerRunName = (service: string, operation: string): string =>
54
+ `RunStartNexusOperationHandler:${service}/${operation}`;
55
+ /** `RunCancelNexusOperationHandler:<service>/<op>` — Nexus cancel-handler span. */
56
+ export const runCancelNexusHandlerRunName = (service: string, operation: string): string =>
57
+ `RunCancelNexusOperationHandler:${service}/${operation}`;
58
+ /** `HandleSignal:<name>` — inbound signal handler span. */
59
+ export const handleSignalRunName = (signalName: string): string => `HandleSignal:${signalName}`;
60
+ /** `HandleQuery:<name>` — inbound query handler span. */
61
+ export const handleQueryRunName = (queryName: string): string => `HandleQuery:${queryName}`;
62
+ /** `HandleUpdate:<name>` — inbound update handler span. */
63
+ export const handleUpdateRunName = (updateName: string): string => `HandleUpdate:${updateName}`;
64
+ /** `ValidateUpdate:<name>` — inbound update validator span. */
65
+ export const validateUpdateRunName = (updateName: string): string => `ValidateUpdate:${updateName}`;
66
+ /** `SignalWorkflow:<name>` — client/outbound signal marker. */
67
+ export const signalWorkflowRunName = (signalName: string): string => `SignalWorkflow:${signalName}`;
68
+ /** `SignalChildWorkflow:<name>` — workflow-side signal-child marker. */
69
+ export const signalChildWorkflowRunName = (signalName: string): string => `SignalChildWorkflow:${signalName}`;
70
+ /** `SignalExternalWorkflow:<name>` — workflow-side signal-external marker. */
71
+ export const signalExternalWorkflowRunName = (signalName: string): string => `SignalExternalWorkflow:${signalName}`;
72
+ /** `SignalWithStartWorkflow:<type>` — client signal-with-start marker. */
73
+ export const signalWithStartRunName = (workflowType: string): string => `SignalWithStartWorkflow:${workflowType}`;
74
+ /** `QueryWorkflow:<name>` — client query marker. */
75
+ export const queryWorkflowRunName = (queryName: string): string => `QueryWorkflow:${queryName}`;
76
+ /** `StartWorkflowUpdate:<name>` — client update-start marker. */
77
+ export const startWorkflowUpdateRunName = (updateName: string): string => `StartWorkflowUpdate:${updateName}`;
78
+ /** `StartUpdateWithStartWorkflow:<name>` — client update-with-start marker. */
79
+ export const startUpdateWithStartRunName = (updateName: string): string => `StartUpdateWithStartWorkflow:${updateName}`;
80
+
81
+ /**
82
+ * True only while replaying genuine history events, when emission must be
83
+ * suppressed. Deliberately `isReplayingHistoryEvents`, NOT `isReplaying`: for
84
+ * live read-only handlers (queries, update validators) `isReplaying` is `true`
85
+ * but `isReplayingHistoryEvents` is `false`, so their runs still emit. Matches
86
+ * how the SDK gates `callDuringReplay: false` sinks.
87
+ */
88
+ function isReplaying(): boolean {
89
+ return workflowInfo().unsafe.isReplayingHistoryEvents;
90
+ }
91
+
92
+ let cachedSinks: LangSmithSinks | undefined;
93
+ /** Lazily-resolved workflow Sink proxy (must be called inside a workflow). */
94
+ function sinks(): LangSmithSinks {
95
+ if (cachedSinks === undefined) {
96
+ cachedSinks = proxySinks<LangSmithSinks>();
97
+ }
98
+ return cachedSinks;
99
+ }
100
+
101
+ /**
102
+ * A no-op LangSmith client. {@link ReplaySafeRunTree} never lets the underlying
103
+ * `RunTree` perform network I/O — emission goes through the Sink — so the
104
+ * client it carries must do nothing and must never read env / open sockets
105
+ * inside the isolate.
106
+ */
107
+ const NOOP_CLIENT = {
108
+ createRun: async (): Promise<void> => {},
109
+ updateRun: async (): Promise<void> => {},
110
+ } as unknown as Client;
111
+
112
+ /** Flatten a {@link RunTree} into the plain wire shape consumed by the Sink. */
113
+ function serializeRun(run: RunTree): SerializedRun {
114
+ const extra = run.extra ? { ...run.extra } : undefined;
115
+ if (extra && extra.metadata && typeof extra.metadata === 'object') {
116
+ extra.metadata = scrubSensitive(extra.metadata as Record<string, unknown>);
117
+ }
118
+ return {
119
+ id: run.id,
120
+ trace_id: run.trace_id,
121
+ dotted_order: run.dotted_order,
122
+ parent_run_id: run.parent_run_id,
123
+ name: run.name,
124
+ run_type: run.run_type,
125
+ start_time: run.start_time,
126
+ end_time: run.end_time,
127
+ inputs: run.inputs as Record<string, unknown> | undefined,
128
+ outputs: run.outputs as Record<string, unknown> | undefined,
129
+ error: run.error,
130
+ extra,
131
+ tags: run.tags,
132
+ project_name: run.project_name,
133
+ events: run.events as Record<string, unknown>[] | undefined,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Render an error into the LangSmith `error` string, honoring Temporal's
139
+ * benign-failure category.
140
+ *
141
+ * A `BENIGN`-category `ApplicationFailure` is an expected, non-alarming outcome
142
+ * (e.g. a control-flow signal), so it leaves the run's `error` unset. All other
143
+ * failures produce `"<type>: <message>"`.
144
+ */
145
+ export function describeError(err: unknown): string | undefined {
146
+ if (err instanceof ApplicationFailure) {
147
+ if (err.category === ApplicationFailureCategory.BENIGN) {
148
+ return undefined;
149
+ }
150
+ return `${err.type ?? 'ApplicationError'}: ${err.message}`;
151
+ }
152
+ if (err instanceof Error) {
153
+ return `${err.name}: ${err.message}`;
154
+ }
155
+ return String(err);
156
+ }
157
+
158
+ /** Coerce an arbitrary operation result into a LangSmith outputs object. */
159
+ export function asOutputs(result: unknown): Record<string, unknown> {
160
+ if (result !== null && typeof result === 'object' && !Array.isArray(result)) {
161
+ return result as Record<string, unknown>;
162
+ }
163
+ return { result };
164
+ }
165
+
166
+ /** The trace context to propagate for `run`, or `undefined` when absent. */
167
+ export function runHeaders(run: RunTree | undefined): LangSmithTraceContext | undefined {
168
+ return run ? (run.toHeaders() as LangSmithTraceContext) : undefined;
169
+ }
170
+
171
+ /** Emit a short-lived marker run. */
172
+ export async function emitMarkerRun(marker: RunTree): Promise<void> {
173
+ await marker.postRun();
174
+ await marker.end({});
175
+ await marker.patchRun();
176
+ }
177
+
178
+ /** Reconstruct a propagated parent {@link RunTree} from a trace context; never throws. */
179
+ export function runTreeFromContext(ctx: LangSmithTraceContext | undefined): RunTree | undefined {
180
+ if (!ctx) {
181
+ return undefined;
182
+ }
183
+ try {
184
+ return RunTree.fromHeaders(ctx) ?? undefined;
185
+ } catch {
186
+ return undefined;
187
+ }
188
+ }
189
+
190
+ interface RunTreeParams {
191
+ name: string;
192
+ runType: string;
193
+ parent: RunTree | undefined;
194
+ inputs?: Record<string, unknown>;
195
+ }
196
+
197
+ /**
198
+ * Build an emitter-side {@link RunTree} (client / activity / Nexus); force-enables
199
+ * tracing so nested body `traceable` runs emit — tracing gate is `isTracingEnabled()`.
200
+ */
201
+ export function buildRunTree(config: EmitterConfig, params: RunTreeParams): RunTree {
202
+ return new RunTree({
203
+ name: params.name,
204
+ run_type: params.runType,
205
+ inputs: params.inputs ?? {},
206
+ parent_run: params.parent,
207
+ client: config.client,
208
+ project_name: config.projectName ?? params.parent?.project_name,
209
+ tags: config.tags,
210
+ extra: { metadata: scrubSensitive(config.metadata) ?? {} },
211
+ tracingEnabled: true,
212
+ });
213
+ }
214
+
215
+ /**
216
+ * A {@link RunTree} subclass safe to construct and drive from workflow code.
217
+ *
218
+ * Subclasses rather than wraps because LangSmith's `traceable` resolves its
219
+ * parent with `instanceof RunTree` before calling `parent.createChild(...)`, so
220
+ * a plain wrapper would refuse to nest under it.
221
+ *
222
+ * @internal
223
+ */
224
+ export class ReplaySafeRunTree extends RunTree {
225
+ // Per-invocation run-id factory. Supplied by read-only handlers (queries, update
226
+ // validators) so their ids don't draw from the Workflow main PRNG; left
227
+ // undefined on recorded paths, where id minting falls back to the isolated
228
+ // LANGSMITH_RUN_TREE_STREAM named stream. Propagated to every child so the whole
229
+ // subtree mints from one factory.
230
+ protected readonly generateNewUuid4?: () => string;
231
+
232
+ constructor(config: RunTreeConfig, generateNewUuid4?: () => string) {
233
+ super(ReplaySafeRunTree.fill(config, generateNewUuid4));
234
+ this.generateNewUuid4 = generateNewUuid4;
235
+ }
236
+
237
+ private static fill(config: RunTreeConfig, generateNewUuid4?: () => string): RunTreeConfig {
238
+ const id =
239
+ config.id ?? (generateNewUuid4 ? generateNewUuid4() : getRandomStream(LANGSMITH_RUN_TREE_STREAM).uuid4());
240
+ const start_time = config.start_time ?? Date.now();
241
+ const trace_id = config.trace_id ?? config.parent_run?.trace_id ?? id;
242
+ let dotted_order = config.dotted_order;
243
+ if (dotted_order == null) {
244
+ const startMs = typeof start_time === 'number' ? start_time : Date.parse(String(start_time));
245
+ const { dottedOrder } = convertToDottedOrderFormat(startMs, id);
246
+ const parentDotted = config.parent_run?.dotted_order;
247
+ dotted_order = parentDotted ? `${parentDotted}.${dottedOrder}` : dottedOrder;
248
+ }
249
+ return {
250
+ ...config,
251
+ id,
252
+ start_time,
253
+ trace_id,
254
+ dotted_order,
255
+ parent_run_id: config.parent_run_id ?? config.parent_run?.id,
256
+ client: NOOP_CLIENT,
257
+ tracingEnabled: config.tracingEnabled ?? true,
258
+ };
259
+ }
260
+
261
+ /** Produce a replay-safe child so deeply-nested `traceable` runs stay deterministic. */
262
+ override createChild(config: RunTreeConfig): ReplaySafeRunTree {
263
+ const child = new ReplaySafeRunTree(
264
+ {
265
+ ...config,
266
+ run_type: config.run_type ?? RUN_TYPE.CHAIN,
267
+ parent_run: this,
268
+ parent_run_id: this.id,
269
+ trace_id: this.trace_id,
270
+ project_name: config.project_name ?? this.project_name,
271
+ },
272
+ this.generateNewUuid4
273
+ );
274
+ this.child_runs.push(child);
275
+ return child;
276
+ }
277
+
278
+ /** Emit a `createRun` via the Sink, unless replaying. */
279
+ override async postRun(): Promise<void> {
280
+ if (isReplaying()) {
281
+ return;
282
+ }
283
+ sinks()[LANGSMITH_SINK_NAME].createRun(serializeRun(this));
284
+ }
285
+
286
+ /** Record outputs/end-time locally then emit an `updateRun` via the Sink, unless replaying. */
287
+ override async patchRun(): Promise<void> {
288
+ if (isReplaying()) {
289
+ return;
290
+ }
291
+ sinks()[LANGSMITH_SINK_NAME].updateRun(serializeRun(this));
292
+ }
293
+
294
+ /** Record end-time / outputs / error on the run object (pure, replay-safe). */
295
+ override async end(outputs?: Record<string, unknown>, error?: string, endTime?: number): Promise<void> {
296
+ if (outputs !== undefined) {
297
+ this.outputs = outputs;
298
+ }
299
+ if (error !== undefined) {
300
+ this.error = error;
301
+ }
302
+ this.end_time = endTime ?? Date.now();
303
+ }
304
+ }
305
+
306
+ /**
307
+ * A placeholder, never-emitted parent whose {@link createChild} produces
308
+ * independent root children. Installed as the ambient so a workflow-body
309
+ * `traceable` takes LangSmith's `createChild` branch (deterministic id) instead
310
+ * of the no-parent branch that mints a uuid via `crypto`, which the isolate lacks.
311
+ *
312
+ * @internal
313
+ */
314
+ export class _RootReplaySafeRunTreeFactory extends ReplaySafeRunTree {
315
+ /** Produce a replay-safe child with no link back to this factory. */
316
+ override createChild(config: RunTreeConfig): ReplaySafeRunTree {
317
+ return new ReplaySafeRunTree(
318
+ {
319
+ ...config,
320
+ run_type: config.run_type ?? RUN_TYPE.CHAIN,
321
+ project_name: config.project_name ?? this.project_name,
322
+ },
323
+ this.generateNewUuid4
324
+ );
325
+ }
326
+
327
+ override async postRun(): Promise<void> {}
328
+
329
+ override async patchRun(): Promise<void> {}
330
+ }
package/src/sinks.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Worker-side LangSmith emission, exposed as a Temporal {@link InjectedSinks}.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { isTracingEnabled, type Client } from 'langsmith';
8
+ import type { Sinks } from '@temporalio/workflow';
9
+ import type { InjectedSinks } from '@temporalio/worker';
10
+
11
+ /** Plain-JSON description of a LangSmith run crossing the isolate→worker boundary. */
12
+ export interface SerializedRun {
13
+ id: string;
14
+ trace_id: string;
15
+ dotted_order: string;
16
+ parent_run_id?: string;
17
+ name: string;
18
+ run_type: string;
19
+ start_time: number;
20
+ /** End time (epoch ms); present only on the update record. */
21
+ end_time?: number;
22
+ inputs?: Record<string, unknown>;
23
+ outputs?: Record<string, unknown>;
24
+ /** Error string; non-null marks the run errored. */
25
+ error?: string;
26
+ extra?: Record<string, unknown>;
27
+ tags?: string[];
28
+ project_name?: string;
29
+ events?: Record<string, unknown>[];
30
+ }
31
+
32
+ /** Runtime configuration shared by the client and activity interceptors. */
33
+ export interface EmitterConfig {
34
+ /** The LangSmith client runs are emitted to. */
35
+ client: Client;
36
+ /** When false, no Temporal-operation runs are emitted (propagation only). */
37
+ addTemporalRuns: boolean;
38
+ /** Target LangSmith project. */
39
+ projectName?: string;
40
+ /** Tags attached to every emitted run. */
41
+ tags?: string[];
42
+ /** Metadata merged into every emitted run. */
43
+ metadata?: Record<string, unknown>;
44
+ }
45
+
46
+ /** The injected sink's wire name. Reserved-prefixed; allowlisted in `@temporalio/common`. */
47
+ export const LANGSMITH_SINK_NAME = '__temporal_langsmith' as const;
48
+
49
+ /** The Temporal sink surface this plugin injects. */
50
+ export interface LangSmithSinks extends Sinks {
51
+ [LANGSMITH_SINK_NAME]: {
52
+ /** Emit a `createRun` for a newly started run. */
53
+ createRun(run: SerializedRun): void;
54
+ /** Emit an `updateRun` (end / outputs / error / events) for a run. */
55
+ updateRun(run: SerializedRun): void;
56
+ };
57
+ }
58
+
59
+ // langsmith's CreateRunParams omits tags, but createRun spreads them into the request at runtime.
60
+ function toCreateParams(run: SerializedRun): Parameters<Client['createRun']>[0] & { tags?: string[] } {
61
+ return {
62
+ id: run.id,
63
+ trace_id: run.trace_id,
64
+ dotted_order: run.dotted_order,
65
+ parent_run_id: run.parent_run_id,
66
+ name: run.name,
67
+ run_type: run.run_type,
68
+ start_time: run.start_time,
69
+ inputs: run.inputs ?? {},
70
+ extra: run.extra,
71
+ tags: run.tags,
72
+ project_name: run.project_name,
73
+ };
74
+ }
75
+
76
+ function toUpdateParams(run: SerializedRun): Parameters<Client['updateRun']>[1] {
77
+ return {
78
+ end_time: run.end_time,
79
+ outputs: run.outputs,
80
+ error: run.error,
81
+ extra: run.extra,
82
+ tags: run.tags,
83
+ events: run.events,
84
+ dotted_order: run.dotted_order,
85
+ trace_id: run.trace_id,
86
+ parent_run_id: run.parent_run_id,
87
+ };
88
+ }
89
+
90
+ /** Build the worker-side {@link InjectedSinks} routing serialized runs to a real LangSmith client. */
91
+ export function createLangSmithSinks(client: Client): InjectedSinks<LangSmithSinks> {
92
+ return {
93
+ [LANGSMITH_SINK_NAME]: {
94
+ createRun: {
95
+ fn: (_info, run) => {
96
+ // Honor the LangSmith tracing gate at the real-env emission point.
97
+ if (!isTracingEnabled()) {
98
+ return;
99
+ }
100
+ void Promise.resolve(client.createRun(toCreateParams(run))).catch(() => {
101
+ /* swallow: emission must never fail the workflow */
102
+ });
103
+ },
104
+ callDuringReplay: false,
105
+ },
106
+ updateRun: {
107
+ fn: (_info, run) => {
108
+ if (!isTracingEnabled()) {
109
+ return;
110
+ }
111
+ void Promise.resolve(client.updateRun(run.id, toUpdateParams(run))).catch(() => {
112
+ /* swallow: emission must never fail the workflow */
113
+ });
114
+ },
115
+ callDuringReplay: false,
116
+ },
117
+ },
118
+ };
119
+ }