@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,109 @@
1
+ /**
2
+ * Client-side LangSmith interceptor. Execution ops (`start`) emit a peer marker
3
+ * and propagate the **ambient** context (the remote run is a sibling); messaging
4
+ * ops (`signal`, `query`, `update`, …) emit the marker and propagate the
5
+ * **marker** context (the remote handler nests under it).
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { isTracingEnabled } from 'langsmith';
11
+ import type { RunTree } from 'langsmith/run_trees';
12
+ import { getCurrentRunTree } from 'langsmith/traceable';
13
+ import type { WorkflowClientInterceptor, WorkflowStartInput } from '@temporalio/client';
14
+
15
+ import { withContextHeader } from './propagation';
16
+ import {
17
+ RUN_TYPE,
18
+ buildRunTree,
19
+ emitMarkerRun,
20
+ queryWorkflowRunName,
21
+ runHeaders,
22
+ signalWithStartRunName,
23
+ signalWorkflowRunName,
24
+ startUpdateWithStartRunName,
25
+ startWorkflowRunName,
26
+ startWorkflowUpdateRunName,
27
+ } from './run-tree';
28
+ import type { EmitterConfig } from './sinks';
29
+
30
+ type NextFn<I, O> = (input: I) => Promise<O>;
31
+
32
+ export function createClientInterceptor(config: EmitterConfig): WorkflowClientInterceptor {
33
+ const peerStart = async <I extends { headers: WorkflowStartInput['headers'] }, O>(
34
+ input: I,
35
+ next: NextFn<I, O>,
36
+ name: string,
37
+ args: unknown[]
38
+ ): Promise<O> => {
39
+ if (!isTracingEnabled()) {
40
+ return next(input);
41
+ }
42
+ const ambient = getCurrentRunTree(true);
43
+ if (config.addTemporalRuns) {
44
+ await emitMarkerRun(buildRunTree(config, { name, runType: RUN_TYPE.CHAIN, parent: ambient, inputs: { args } }));
45
+ }
46
+ const headers = withContextHeader(input.headers, runHeaders(ambient));
47
+ return next({ ...input, headers });
48
+ };
49
+
50
+ const parentMarker = async <I extends { headers: WorkflowStartInput['headers'] }, O>(
51
+ input: I,
52
+ next: NextFn<I, O>,
53
+ name: string,
54
+ args: unknown[]
55
+ ): Promise<O> => {
56
+ if (!isTracingEnabled()) {
57
+ return next(input);
58
+ }
59
+ const ambient = getCurrentRunTree(true);
60
+ let propagate: RunTree | undefined = ambient;
61
+ if (config.addTemporalRuns) {
62
+ const marker = buildRunTree(config, { name, runType: RUN_TYPE.CHAIN, parent: ambient, inputs: { args } });
63
+ await emitMarkerRun(marker);
64
+ propagate = marker;
65
+ }
66
+ const headers = withContextHeader(input.headers, runHeaders(propagate));
67
+ return next({ ...input, headers });
68
+ };
69
+
70
+ return {
71
+ start(input, next) {
72
+ return peerStart(input, next, startWorkflowRunName(input.workflowType), input.options.args);
73
+ },
74
+ startWithDetails(input, next) {
75
+ return peerStart(input, next, startWorkflowRunName(input.workflowType), input.options.args);
76
+ },
77
+ signal(input, next) {
78
+ return parentMarker(input, next, signalWorkflowRunName(input.signalName), input.args);
79
+ },
80
+ signalWithStart(input, next) {
81
+ return parentMarker(input, next, signalWithStartRunName(input.workflowType), input.signalArgs);
82
+ },
83
+ query(input, next) {
84
+ return parentMarker(input, next, queryWorkflowRunName(input.queryType), input.args);
85
+ },
86
+ startUpdate(input, next) {
87
+ return parentMarker(input, next, startWorkflowUpdateRunName(input.updateName), input.args);
88
+ },
89
+ async startUpdateWithStart(input, next) {
90
+ if (!isTracingEnabled()) {
91
+ return next(input);
92
+ }
93
+ const ambient = getCurrentRunTree(true);
94
+ let propagate: RunTree | undefined = ambient;
95
+ if (config.addTemporalRuns) {
96
+ const marker = buildRunTree(config, {
97
+ name: startUpdateWithStartRunName(input.updateName),
98
+ runType: RUN_TYPE.CHAIN,
99
+ parent: ambient,
100
+ inputs: { args: input.updateArgs },
101
+ });
102
+ await emitMarkerRun(marker);
103
+ propagate = marker;
104
+ }
105
+ const workflowStartHeaders = withContextHeader(input.workflowStartHeaders, runHeaders(propagate));
106
+ return next({ ...input, workflowStartHeaders });
107
+ },
108
+ };
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `@temporalio/langsmith` — LangSmith observability for Temporal.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ export { LangSmithPlugin } from './plugin';
8
+ export type { LangSmithPluginOptions } from './plugin';
package/src/plugin.ts ADDED
@@ -0,0 +1,300 @@
1
+ /**
2
+ * {@link LangSmithPlugin} — the single object a user adds to their Temporal
3
+ * `Client` and `Worker` to get LangSmith tracing across workflow / activity /
4
+ * child-workflow / Nexus boundaries with no changes to their existing
5
+ * `traceable` instrumentation.
6
+ *
7
+ * The plugin wires four things:
8
+ * 1. a **client interceptor** that propagates trace context out of any
9
+ * `start` / `signal` / `query` / `update` call;
10
+ * 2. **activity + Nexus inbound interceptors** that reconstruct the trace and
11
+ * install it as the active run (so body `traceable` calls nest correctly);
12
+ * 3. the **workflow interceptor module** (loaded into the workflow bundle) plus
13
+ * a bundle-time global carrying the plugin config; and
14
+ * 4. the **LangSmith sink** that performs the actual out-of-isolate emission.
15
+ *
16
+ * On worker shutdown it flushes the LangSmith client so in-flight traces are
17
+ * not lost.
18
+ *
19
+ * @module
20
+ */
21
+
22
+ import { Client } from 'langsmith';
23
+
24
+ import type * as nexus from 'nexus-rpc';
25
+ import { SimplePlugin } from '@temporalio/plugin';
26
+ import type { ClientOptions } from '@temporalio/client';
27
+ import type { BundleOptions, ReplayWorkerOptions, Worker, WorkerInterceptors, WorkerOptions } from '@temporalio/worker';
28
+ import { createActivityInboundInterceptor, createNexusInboundInterceptor } from './activity-interceptor';
29
+ import { createClientInterceptor } from './client-interceptor';
30
+ import { createLangSmithSinks } from './sinks';
31
+ import type { EmitterConfig } from './sinks';
32
+ import type { WorkflowLangSmithConfig } from './workflow-interceptors';
33
+
34
+ // Derived: @temporalio/worker doesn't export WebpackConfiguration from its root.
35
+ type WebpackConfiguration = Parameters<NonNullable<BundleOptions['webpackConfigHook']>>[0];
36
+
37
+ /**
38
+ * The module specifier for the workflow-side interceptors.
39
+ */
40
+ const WORKFLOW_INTERCEPTOR_MODULE = '@temporalio/langsmith/workflow-interceptors';
41
+
42
+ // Bare identifier (not dotted) so DefinePlugin substitutes textually.
43
+ const CONFIG_GLOBAL = '__TEMPORAL_LANGSMITH_CONFIG__';
44
+
45
+ // Bare builtin name added to `ignoreModules`; see {@link aliasAsyncHooks}.
46
+ const ASYNC_HOOKS_MODULE = 'async_hooks';
47
+
48
+ /**
49
+ * Options for {@link LangSmithPlugin}.
50
+ *
51
+ * Secrets (the LangSmith API key) are never accepted here as plaintext and
52
+ * never cross a Temporal boundary — supply a pre-constructed {@link Client}
53
+ * (which reads `LANGSMITH_API_KEY` from the worker/client process env) or let
54
+ * the plugin construct a default client from the environment.
55
+ *
56
+ * @experimental Plugins is an experimental feature; APIs may change without notice.
57
+ */
58
+ export interface LangSmithPluginOptions {
59
+ /**
60
+ * The LangSmith client runs are emitted to. Defaults to `new Client()`, which
61
+ * reads `LANGSMITH_API_KEY` / `LANGSMITH_ENDPOINT` from the process
62
+ * environment. Lives only in the worker/client process — never serialized.
63
+ */
64
+ client?: Client;
65
+ /** Target LangSmith project for every emitted run. */
66
+ projectName?: string;
67
+ /**
68
+ * When `true`, emit first-class runs for Temporal operations themselves
69
+ * (`StartWorkflow:`, `RunActivity:`, `HandleSignal:`, …) in addition to the
70
+ * user's `traceable` runs. When `false` (default), only the user's
71
+ * `traceable` runs are emitted — trace context still propagates across
72
+ * boundaries so they nest correctly.
73
+ */
74
+ addTemporalRuns?: boolean;
75
+ /** Tags attached to every run the plugin emits. */
76
+ tags?: string[];
77
+ /** Metadata merged into every run the plugin emits (credential keys scrubbed). */
78
+ metadata?: Record<string, unknown>;
79
+ }
80
+
81
+ /**
82
+ * Temporal plugin that adds LangSmith observability to a Client + Worker.
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * import { traceable } from 'langsmith/traceable';
87
+ * import { LangSmithPlugin } from '@temporalio/langsmith';
88
+ *
89
+ * const plugin = new LangSmithPlugin({ addTemporalRuns: true });
90
+ * const worker = await Worker.create({ workflowsPath, taskQueue: 'tq', plugins: [plugin] });
91
+ * ```
92
+ *
93
+ * @experimental Plugins is an experimental feature; APIs may change without notice.
94
+ */
95
+ export class LangSmithPlugin extends SimplePlugin {
96
+ private readonly client: Client;
97
+ private readonly emitter: EmitterConfig;
98
+ private readonly workflowConfig: WorkflowLangSmithConfig;
99
+
100
+ constructor(options: LangSmithPluginOptions = {}) {
101
+ // super() reads options.name immediately
102
+ super({ name: 'langchain.LangSmithPlugin' });
103
+ this.client = options.client ?? new Client();
104
+ const addTemporalRuns = options.addTemporalRuns ?? false;
105
+ this.emitter = {
106
+ client: this.client,
107
+ addTemporalRuns,
108
+ projectName: options.projectName,
109
+ tags: options.tags,
110
+ metadata: options.metadata,
111
+ };
112
+ this.workflowConfig = {
113
+ addTemporalRuns,
114
+ projectName: options.projectName,
115
+ tags: options.tags,
116
+ metadata: options.metadata,
117
+ };
118
+ }
119
+
120
+ override configureClient(options: ClientOptions): ClientOptions {
121
+ const base = super.configureClient(options);
122
+ const existing = base.interceptors ?? {};
123
+ const workflow = Array.isArray(existing.workflow) ? [...existing.workflow] : [];
124
+ workflow.push(createClientInterceptor(this.emitter));
125
+ return { ...base, interceptors: { ...existing, workflow } };
126
+ }
127
+
128
+ override configureWorker(options: WorkerOptions): WorkerOptions {
129
+ return this.withLangSmithWorker(super.configureWorker(options));
130
+ }
131
+
132
+ override configureReplayWorker(options: ReplayWorkerOptions): ReplayWorkerOptions {
133
+ return this.withLangSmithWorker(super.configureReplayWorker(options));
134
+ }
135
+
136
+ private withLangSmithWorker<T extends WorkerOptions | ReplayWorkerOptions>(options: T): T {
137
+ const interceptors: WorkerInterceptors = options.interceptors ?? {};
138
+
139
+ const activityInbound = [...(interceptors.activityInbound ?? [])];
140
+ activityInbound.push(createActivityInboundInterceptor(this.emitter));
141
+
142
+ const workflowModules = [...(interceptors.workflowModules ?? [])];
143
+ if (!workflowModules.includes(WORKFLOW_INTERCEPTOR_MODULE)) {
144
+ workflowModules.push(WORKFLOW_INTERCEPTOR_MODULE);
145
+ }
146
+
147
+ const nexusInbound = createNexusInboundInterceptor(this.emitter);
148
+ const nexusInterceptors = [...(interceptors.nexus ?? [])];
149
+ nexusInterceptors.push((_ctx: nexus.OperationContext) => ({ inbound: nexusInbound }));
150
+
151
+ // The user's own Worker sinks take precedence on any key collision.
152
+ const sinks = { ...createLangSmithSinks(this.client), ...(options.sinks ?? {}) };
153
+
154
+ return {
155
+ ...options,
156
+ interceptors: { ...interceptors, activityInbound, workflowModules, nexus: nexusInterceptors },
157
+ sinks,
158
+ } as T;
159
+ }
160
+
161
+ override configureBundler(options: BundleOptions): BundleOptions {
162
+ const base = super.configureBundler(options);
163
+ const workflowInterceptorModules = [...(base.workflowInterceptorModules ?? [])];
164
+ if (!workflowInterceptorModules.includes(WORKFLOW_INTERCEPTOR_MODULE)) {
165
+ workflowInterceptorModules.push(WORKFLOW_INTERCEPTOR_MODULE);
166
+ }
167
+ // Dismiss the SDK bundler's disallowed-builtin guard for `async_hooks` (see aliasAsyncHooks).
168
+ const ignoreModules = [...(base.ignoreModules ?? [])];
169
+ if (!ignoreModules.includes(ASYNC_HOOKS_MODULE)) {
170
+ ignoreModules.push(ASYNC_HOOKS_MODULE);
171
+ }
172
+ const prevHook = base.webpackConfigHook;
173
+ const webpackConfigHook = (config: WebpackConfiguration): WebpackConfiguration => {
174
+ const merged = prevHook ? prevHook(config) : config;
175
+ // Double-encode so the injected token is a string literal in the bundle.
176
+ const definitions = { [CONFIG_GLOBAL]: JSON.stringify(JSON.stringify(this.workflowConfig)) };
177
+ const withDefine = injectDefinePlugin(merged, definitions);
178
+ return aliasLangSmithNodeUtils(aliasAsyncHooks(withDefine));
179
+ };
180
+ return { ...base, workflowInterceptorModules, ignoreModules, webpackConfigHook };
181
+ }
182
+
183
+ /**
184
+ * Flush the LangSmith client on worker shutdown so traces that were emitted
185
+ * just before exit are delivered. Wraps the worker run loop and flushes in a
186
+ * `finally`, swallowing flush errors (shutdown must not fail on telemetry).
187
+ */
188
+ override async runWorker(worker: Worker, next: (worker: Worker) => Promise<void>): Promise<void> {
189
+ try {
190
+ await next(worker);
191
+ } finally {
192
+ await this.flush();
193
+ }
194
+ }
195
+
196
+ private async flush(): Promise<void> {
197
+ try {
198
+ await this.client.awaitPendingTraceBatches();
199
+ } catch {
200
+ /* swallow: telemetry flush must never fail worker shutdown */
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Redirect `node:async_hooks` to the workflow interceptor module's isolate-safe
207
+ * shim. webpack routes `node:` requests through a scheme handler before alias
208
+ * resolution, so `resolve.alias` cannot do this; `NormalModuleReplacementPlugin`
209
+ * rewrites the request in `beforeResolve` instead. Must be paired with the
210
+ * `ignoreModules` whitelist (see {@link LangSmithPlugin.configureBundler}). The
211
+ * rewrite target is the package specifier so webpack dedupes to one isolate
212
+ * instance regardless of issuer.
213
+ */
214
+ function aliasAsyncHooks(config: WebpackConfiguration): WebpackConfiguration {
215
+ const plugins = [...(config.plugins ?? [])];
216
+ try {
217
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
218
+ const webpack = require('webpack') as {
219
+ NormalModuleReplacementPlugin: new (re: RegExp, cb: (resource: { request: string }) => void) => unknown;
220
+ };
221
+ plugins.push(
222
+ new webpack.NormalModuleReplacementPlugin(/^node:async_hooks$/, (resource) => {
223
+ resource.request = WORKFLOW_INTERCEPTOR_MODULE;
224
+ }) as (typeof plugins)[number]
225
+ );
226
+ } catch (err) {
227
+ if ((err as NodeJS.ErrnoException)?.code !== 'MODULE_NOT_FOUND') throw err;
228
+ /* webpack genuinely absent (MODULE_NOT_FOUND): leave plugins untouched; any other failure rethrows. */
229
+ }
230
+ return { ...config, plugins };
231
+ }
232
+
233
+ /**
234
+ * Redirect langsmith's node-only CJS utilities to its `.browser.cjs` siblings so
235
+ * the workflow bundle keeps node builtins out of the isolate. Scoped to langsmith
236
+ * so a user's own `fs.cjs` is never rewritten.
237
+ */
238
+ function aliasLangSmithNodeUtils(config: WebpackConfiguration): WebpackConfiguration {
239
+ const plugins = [...(config.plugins ?? [])];
240
+ const swap = (s: string): string => s.replace(/\.cjs$/, '.browser.cjs');
241
+ try {
242
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
243
+ const webpack = require('webpack') as {
244
+ NormalModuleReplacementPlugin: new (
245
+ re: RegExp,
246
+ cb: (resource: {
247
+ request: string;
248
+ context?: string;
249
+ createData?: { resource?: string; userRequest?: string; request?: string };
250
+ }) => void
251
+ ) => unknown;
252
+ };
253
+ plugins.push(
254
+ new webpack.NormalModuleReplacementPlugin(/[/\\]utils[/\\](?:fs|worker_threads)\.cjs$/, (resource) => {
255
+ // Resolved sibling-relative requires (`./fs.cjs`) carry the absolute
256
+ // path on `createData.resource`; raw `beforeResolve` requests carry it
257
+ // on `request`.
258
+ const createData = resource.createData;
259
+ if (createData && typeof createData.resource === 'string') {
260
+ if (!createData.resource.includes('langsmith') || createData.resource.includes('.browser.cjs')) {
261
+ return;
262
+ }
263
+ createData.resource = swap(createData.resource);
264
+ if (typeof createData.userRequest === 'string') {
265
+ createData.userRequest = swap(createData.userRequest);
266
+ }
267
+ if (typeof createData.request === 'string') {
268
+ createData.request = swap(createData.request);
269
+ }
270
+ return;
271
+ }
272
+ if (!resource.context || !resource.context.includes('langsmith') || resource.request.includes('.browser.cjs')) {
273
+ return;
274
+ }
275
+ resource.request = swap(resource.request);
276
+ }) as (typeof plugins)[number]
277
+ );
278
+ } catch (err) {
279
+ if ((err as NodeJS.ErrnoException)?.code !== 'MODULE_NOT_FOUND') throw err;
280
+ /* webpack genuinely absent (MODULE_NOT_FOUND): leave plugins untouched; any other failure rethrows. */
281
+ }
282
+ return { ...config, plugins };
283
+ }
284
+
285
+ /**
286
+ * Append a `DefinePlugin`-equivalent to a webpack config without a static
287
+ * `webpack` import (webpack is provided transitively by the worker).
288
+ */
289
+ function injectDefinePlugin(config: WebpackConfiguration, definitions: Record<string, string>): WebpackConfiguration {
290
+ const plugins = [...(config.plugins ?? [])];
291
+ try {
292
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
293
+ const webpack = require('webpack') as { DefinePlugin: new (d: Record<string, string>) => unknown };
294
+ plugins.push(new webpack.DefinePlugin(definitions) as (typeof plugins)[number]);
295
+ } catch (err) {
296
+ if ((err as NodeJS.ErrnoException)?.code !== 'MODULE_NOT_FOUND') throw err;
297
+ /* webpack genuinely absent (MODULE_NOT_FOUND): leave plugins untouched, workflow uses defaults; any other failure rethrows. */
298
+ }
299
+ return { ...config, plugins };
300
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Cross-boundary trace-context propagation for the LangSmith Temporal plugin.
3
+ *
4
+ * The LangSmith run context travels across every Temporal boundary
5
+ * (client→workflow, workflow→activity, workflow→child-workflow,
6
+ * workflow→Nexus, continue-as-new) as a single Temporal {@link Header}
7
+ * entry. The wire value is exactly the object produced by
8
+ * `RunTree.toHeaders()` — `{ "langsmith-trace": <dottedOrder>, baggage: <string> }`
9
+ * — which the receiving side feeds straight back into
10
+ * `RunTree.fromHeaders(...)` to reconstruct the parent run.
11
+ *
12
+ * Two encodings are supported because Temporal exposes two header shapes:
13
+ * - The binary/Temporal path (`Headers = Record<string, Payload>`): the
14
+ * context is encoded with the default payload converter.
15
+ * - The Nexus path (`Record<string, string>`): Nexus headers are plain
16
+ * strings with no Payload encoding, so the context is JSON-serialized.
17
+ *
18
+ * @module
19
+ */
20
+
21
+ import { defaultPayloadConverter, type Payload } from '@temporalio/common';
22
+
23
+ /**
24
+ * Temporal header key under which the LangSmith trace context is carried.
25
+ *
26
+ * Must byte-match the Python plugin's key so trace context propagates across SDKs.
27
+ */
28
+ export const HEADER_KEY = '_temporal-langsmith-context';
29
+
30
+ /**
31
+ * The serialized LangSmith trace context that crosses a Temporal boundary.
32
+ *
33
+ * This is structurally the output of `RunTree.toHeaders()`. Keeping it as a
34
+ * plain JSON object lets the same value ride either the Payload transport or
35
+ * the Nexus plain-string transport unchanged.
36
+ */
37
+ export interface LangSmithTraceContext extends Record<string, string> {
38
+ /** The parent run's dotted order — the LangSmith trace pointer. */
39
+ 'langsmith-trace': string;
40
+ /** LangSmith baggage (metadata / tags / project), comma-encoded. */
41
+ baggage: string;
42
+ }
43
+
44
+ /** Case-insensitive key prefixes scrubbed from runs and propagated headers. */
45
+ const SENSITIVE_KEY_PREFIXES: readonly string[] = [
46
+ 'auth',
47
+ 'api_key',
48
+ 'api-key',
49
+ 'x-api-key',
50
+ 'apikey',
51
+ 'cookie',
52
+ 'set-cookie',
53
+ 'bearer',
54
+ 'secret',
55
+ 'token',
56
+ 'password',
57
+ ];
58
+
59
+ /** Returns true when `key` looks like a credential-bearing header/metadata key. */
60
+ function isSensitiveKey(key: string): boolean {
61
+ const lower = key.toLowerCase();
62
+ return SENSITIVE_KEY_PREFIXES.some((prefix) => lower.startsWith(prefix));
63
+ }
64
+
65
+ /** Return a shallow copy of `record` with all credential-bearing keys removed. */
66
+ export function scrubSensitive<T = unknown>(record: Record<string, T> | undefined): Record<string, T> | undefined {
67
+ if (record == null) {
68
+ return record;
69
+ }
70
+ const out: Record<string, T> = {};
71
+ for (const [key, value] of Object.entries(record)) {
72
+ if (!isSensitiveKey(key)) {
73
+ out[key] = value;
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** Encode a trace context into a Temporal `Payload`; never throws (failure → undefined, header omitted). */
80
+ function encodeContextPayload(context: LangSmithTraceContext): Payload | undefined {
81
+ try {
82
+ return defaultPayloadConverter.toPayload(context);
83
+ } catch {
84
+ return undefined;
85
+ }
86
+ }
87
+
88
+ /** Decode a trace context from a Temporal `Payload`; never throws (malformed → undefined). */
89
+ function decodeContextPayload(payload: Payload | undefined): LangSmithTraceContext | undefined {
90
+ if (payload == null) {
91
+ return undefined;
92
+ }
93
+ try {
94
+ const value = defaultPayloadConverter.fromPayload<LangSmithTraceContext>(payload);
95
+ return isTraceContext(value) ? value : undefined;
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ /** Encode a trace context as a plain string for the Nexus header transport. */
102
+ export function encodeContextString(context: LangSmithTraceContext): string {
103
+ return JSON.stringify(context);
104
+ }
105
+
106
+ /** Decode a trace context from a Nexus plain-string header; never throws. */
107
+ export function decodeContextString(value: string | undefined): LangSmithTraceContext | undefined {
108
+ if (value == null || value === '') {
109
+ return undefined;
110
+ }
111
+ try {
112
+ const parsed = JSON.parse(value) as unknown;
113
+ return isTraceContext(parsed) ? parsed : undefined;
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ }
118
+
119
+ function isTraceContext(value: unknown): value is LangSmithTraceContext {
120
+ return (
121
+ typeof value === 'object' &&
122
+ value !== null &&
123
+ typeof (value as Record<string, unknown>)['langsmith-trace'] === 'string'
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Inject a trace context into a Payload-keyed Temporal header map, returning a
129
+ * new map (interceptor inputs treat `headers` as immutable). Returns the input
130
+ * unchanged when `context` is `undefined`.
131
+ */
132
+ export function withContextHeader(
133
+ headers: Record<string, Payload>,
134
+ context: LangSmithTraceContext | undefined
135
+ ): Record<string, Payload> {
136
+ if (context === undefined) {
137
+ return headers;
138
+ }
139
+ const payload = encodeContextPayload(context);
140
+ return payload === undefined ? headers : { ...headers, [HEADER_KEY]: payload };
141
+ }
142
+
143
+ export function readContextHeader(headers: Record<string, Payload> | undefined): LangSmithTraceContext | undefined {
144
+ return decodeContextPayload(headers?.[HEADER_KEY]);
145
+ }
146
+
147
+ /**
148
+ * Temporal-internal query names that must NOT produce a LangSmith run: any
149
+ * `__temporal`-prefixed query plus the stack-trace introspection queries.
150
+ */
151
+ export function isInternalQuery(queryName: string): boolean {
152
+ return queryName.startsWith('__temporal') || queryName === '__stack_trace' || queryName === '__enhanced_stack_trace';
153
+ }