@braintrust/temporal 0.1.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.
package/src/sinks.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { WorkflowInfo, Sinks } from "@temporalio/workflow";
2
+ import type { InjectedSinks } from "@temporalio/worker";
3
+ import * as braintrust from "braintrust";
4
+
5
+ // Sink interface (used in workflow code via proxySinks)
6
+ // NOTE: WorkflowInfo is NOT included here - it's automatically injected by the runtime
7
+ export interface BraintrustSinks extends Sinks {
8
+ braintrust: {
9
+ workflowStarted(parentContext?: string, workflowSpanId?: string): void;
10
+ workflowCompleted(error?: string): void;
11
+ };
12
+ }
13
+
14
+ // Active workflow spans tracked by run ID
15
+ const workflowSpans = new Map<string, braintrust.Span>();
16
+ // Workflow span exports tracked by run ID (as promises for async export)
17
+ const workflowSpanExports = new Map<string, Promise<string>>();
18
+
19
+ /**
20
+ * Get the exported span context for a workflow by run ID.
21
+ * Activities on the same worker can use this to parent to the workflow span.
22
+ */
23
+ export function getWorkflowSpanExport(
24
+ runId: string,
25
+ ): Promise<string> | undefined {
26
+ return workflowSpanExports.get(runId);
27
+ }
28
+
29
+ /**
30
+ * Create the Braintrust sinks for workflow span management.
31
+ * These sinks are called from the workflow isolate via proxySinks.
32
+ */
33
+ export function createBraintrustSinks(): InjectedSinks<BraintrustSinks> {
34
+ return {
35
+ braintrust: {
36
+ workflowStarted: {
37
+ fn: (
38
+ info: WorkflowInfo,
39
+ parentContext?: string,
40
+ workflowSpanId?: string,
41
+ ) => {
42
+ const span = braintrust.startSpan({
43
+ name: `temporal.workflow.${info.workflowType}`,
44
+ spanAttributes: { type: "task" },
45
+ parent: parentContext,
46
+ spanId: workflowSpanId,
47
+ event: {
48
+ metadata: {
49
+ "temporal.workflow_type": info.workflowType,
50
+ "temporal.workflow_id": info.workflowId,
51
+ "temporal.run_id": info.runId,
52
+ },
53
+ },
54
+ });
55
+ workflowSpans.set(info.runId, span);
56
+ workflowSpanExports.set(info.runId, span.export());
57
+ },
58
+ callDuringReplay: false,
59
+ },
60
+ workflowCompleted: {
61
+ fn: (info: WorkflowInfo, error?: string) => {
62
+ const span = workflowSpans.get(info.runId);
63
+ if (span) {
64
+ if (error) {
65
+ span.log({ error });
66
+ }
67
+ span.end();
68
+ workflowSpans.delete(info.runId);
69
+ workflowSpanExports.delete(info.runId);
70
+ }
71
+ },
72
+ callDuringReplay: false,
73
+ },
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,243 @@
1
+ import { expect, test, describe } from "vitest";
2
+ import {
3
+ serializeHeaderValue,
4
+ deserializeHeaderValue,
5
+ BRAINTRUST_SPAN_HEADER,
6
+ BRAINTRUST_WORKFLOW_SPAN_HEADER,
7
+ BRAINTRUST_WORKFLOW_SPAN_ID_HEADER,
8
+ } from "./utils";
9
+ import { SpanComponentsV3, SpanObjectTypeV3 } from "braintrust/util";
10
+ import {
11
+ BraintrustTemporalPlugin,
12
+ createBraintrustTemporalPlugin,
13
+ } from "./plugin";
14
+
15
+ describe("temporal header utilities", () => {
16
+ test("serializeHeaderValue encodes string correctly", () => {
17
+ const value = "test-span-id";
18
+ const payload = serializeHeaderValue(value);
19
+
20
+ expect(payload.metadata?.encoding).toBeDefined();
21
+ expect(payload.data).toBeDefined();
22
+ expect(new TextDecoder().decode(payload.metadata?.encoding)).toBe(
23
+ "json/plain",
24
+ );
25
+ expect(new TextDecoder().decode(payload.data)).toBe('"test-span-id"');
26
+ });
27
+
28
+ test("deserializeHeaderValue decodes payload correctly", () => {
29
+ const original = "test-value-123";
30
+ const payload = serializeHeaderValue(original);
31
+ const decoded = deserializeHeaderValue(payload);
32
+
33
+ expect(decoded).toBe(original);
34
+ });
35
+
36
+ test("deserializeHeaderValue handles undefined payload", () => {
37
+ expect(deserializeHeaderValue(undefined)).toBeUndefined();
38
+ });
39
+
40
+ test("deserializeHeaderValue handles payload without data", () => {
41
+ expect(deserializeHeaderValue({ metadata: {} })).toBeUndefined();
42
+ });
43
+
44
+ test("deserializeHeaderValue handles invalid JSON", () => {
45
+ const payload = {
46
+ data: new TextEncoder().encode("not valid json"),
47
+ };
48
+ expect(deserializeHeaderValue(payload)).toBeUndefined();
49
+ });
50
+
51
+ test("round-trip serialization preserves complex strings", () => {
52
+ const testCases = [
53
+ "simple",
54
+ "with spaces",
55
+ "with/slashes",
56
+ "unicode-日本語",
57
+ "emoji-👋",
58
+ "",
59
+ ];
60
+
61
+ for (const value of testCases) {
62
+ const payload = serializeHeaderValue(value);
63
+ const decoded = deserializeHeaderValue(payload);
64
+ expect(decoded).toBe(value);
65
+ }
66
+ });
67
+
68
+ test("header constants are defined", () => {
69
+ expect(BRAINTRUST_SPAN_HEADER).toBe("_braintrust-span");
70
+ expect(BRAINTRUST_WORKFLOW_SPAN_HEADER).toBe("_braintrust-workflow-span");
71
+ expect(BRAINTRUST_WORKFLOW_SPAN_ID_HEADER).toBe(
72
+ "_braintrust-workflow-span-id",
73
+ );
74
+ });
75
+ });
76
+
77
+ describe("SpanComponentsV3 cross-worker reconstruction", () => {
78
+ test("can parse and reconstruct span components with new span_id", () => {
79
+ const clientComponents = new SpanComponentsV3({
80
+ object_type: SpanObjectTypeV3.PROJECT_LOGS,
81
+ object_id: "project-123",
82
+ row_id: "row-456",
83
+ span_id: "client-span-id",
84
+ root_span_id: "root-span-id",
85
+ });
86
+
87
+ const clientContext = clientComponents.toStr();
88
+ const workflowSpanId = "workflow-span-id";
89
+
90
+ const parsed = SpanComponentsV3.fromStr(clientContext);
91
+ const data = parsed.data;
92
+
93
+ expect(data.row_id).toBe("row-456");
94
+ expect(data.root_span_id).toBe("root-span-id");
95
+ expect(data.span_id).toBe("client-span-id");
96
+
97
+ if (data.row_id && data.root_span_id) {
98
+ const workflowComponents = new SpanComponentsV3({
99
+ object_type: data.object_type,
100
+ object_id: data.object_id,
101
+ propagated_event: data.propagated_event,
102
+ row_id: data.row_id,
103
+ root_span_id: data.root_span_id,
104
+ span_id: workflowSpanId,
105
+ });
106
+
107
+ const reconstructed = SpanComponentsV3.fromStr(
108
+ workflowComponents.toStr(),
109
+ );
110
+ expect(reconstructed.data.span_id).toBe(workflowSpanId);
111
+ expect(reconstructed.data.row_id).toBe("row-456");
112
+ expect(reconstructed.data.root_span_id).toBe("root-span-id");
113
+ expect(reconstructed.data.object_id).toBe("project-123");
114
+ }
115
+ });
116
+
117
+ test("preserves object_type when reconstructing", () => {
118
+ const objectTypes = [
119
+ SpanObjectTypeV3.PROJECT_LOGS,
120
+ SpanObjectTypeV3.EXPERIMENT,
121
+ SpanObjectTypeV3.PLAYGROUND_LOGS,
122
+ ];
123
+
124
+ for (const objectType of objectTypes) {
125
+ const original = new SpanComponentsV3({
126
+ object_type: objectType,
127
+ object_id: "test-id",
128
+ row_id: "row-id",
129
+ span_id: "original-span-id",
130
+ root_span_id: "root-span-id",
131
+ });
132
+
133
+ const parsed = SpanComponentsV3.fromStr(original.toStr());
134
+
135
+ const reconstructed = new SpanComponentsV3({
136
+ object_type: parsed.data.object_type,
137
+ object_id: parsed.data.object_id,
138
+ propagated_event: parsed.data.propagated_event,
139
+ row_id: parsed.data.row_id!,
140
+ root_span_id: parsed.data.root_span_id!,
141
+ span_id: "new-span-id",
142
+ });
143
+
144
+ expect(reconstructed.data.object_type).toBe(objectType);
145
+ }
146
+ });
147
+
148
+ test("handles span components without row_id fields", () => {
149
+ const componentsWithoutRowId = new SpanComponentsV3({
150
+ object_type: SpanObjectTypeV3.PROJECT_LOGS,
151
+ object_id: "test-id",
152
+ });
153
+
154
+ const parsed = SpanComponentsV3.fromStr(componentsWithoutRowId.toStr());
155
+
156
+ expect(parsed.data.row_id).toBeUndefined();
157
+ expect(parsed.data.span_id).toBeUndefined();
158
+ expect(parsed.data.root_span_id).toBeUndefined();
159
+ });
160
+
161
+ test("preserves propagated_event when reconstructing", () => {
162
+ const propagatedEvent = { key: "value", nested: { inner: 123 } };
163
+
164
+ const original = new SpanComponentsV3({
165
+ object_type: SpanObjectTypeV3.PROJECT_LOGS,
166
+ object_id: "test-id",
167
+ propagated_event: propagatedEvent,
168
+ row_id: "row-id",
169
+ span_id: "original-span-id",
170
+ root_span_id: "root-span-id",
171
+ });
172
+
173
+ const parsed = SpanComponentsV3.fromStr(original.toStr());
174
+
175
+ const reconstructed = new SpanComponentsV3({
176
+ object_type: parsed.data.object_type,
177
+ object_id: parsed.data.object_id,
178
+ propagated_event: parsed.data.propagated_event,
179
+ row_id: parsed.data.row_id!,
180
+ root_span_id: parsed.data.root_span_id!,
181
+ span_id: "new-span-id",
182
+ });
183
+
184
+ expect(reconstructed.data.propagated_event).toEqual(propagatedEvent);
185
+ });
186
+ });
187
+
188
+ describe("BraintrustTemporalPlugin", () => {
189
+ test("createBraintrustTemporalPlugin returns a plugin instance", () => {
190
+ const plugin = createBraintrustTemporalPlugin();
191
+ expect(plugin).toBeInstanceOf(BraintrustTemporalPlugin);
192
+ expect(plugin.name).toBe("braintrust");
193
+ });
194
+
195
+ test("plugin has configureClient method", () => {
196
+ const plugin = createBraintrustTemporalPlugin();
197
+ expect(typeof plugin.configureClient).toBe("function");
198
+ });
199
+
200
+ test("plugin has configureWorker method", () => {
201
+ const plugin = createBraintrustTemporalPlugin();
202
+ expect(typeof plugin.configureWorker).toBe("function");
203
+ });
204
+
205
+ test("configureClient adds workflow interceptor", () => {
206
+ const plugin = createBraintrustTemporalPlugin();
207
+ const options = {};
208
+ const configured = plugin.configureClient(options);
209
+
210
+ expect(configured.interceptors).toBeDefined();
211
+ expect(configured.interceptors?.workflow).toBeDefined();
212
+ expect(Array.isArray(configured.interceptors?.workflow)).toBe(true);
213
+ expect(configured.interceptors?.workflow?.length).toBe(1);
214
+ });
215
+
216
+ test("configureClient preserves existing interceptors", () => {
217
+ const plugin = createBraintrustTemporalPlugin();
218
+ const existingInterceptor = { start: async (i: unknown, n: unknown) => n };
219
+ const options = {
220
+ interceptors: {
221
+ workflow: [existingInterceptor],
222
+ },
223
+ };
224
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
225
+ const configured = plugin.configureClient(options as any);
226
+
227
+ expect(configured.interceptors?.workflow?.length).toBe(2);
228
+ });
229
+
230
+ test("configureWorker adds activity interceptor and sinks", () => {
231
+ const plugin = createBraintrustTemporalPlugin();
232
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
233
+ const options = {} as any;
234
+ const configured = plugin.configureWorker(options);
235
+
236
+ expect(configured.interceptors).toBeDefined();
237
+ expect(configured.interceptors?.activity).toBeDefined();
238
+ expect(Array.isArray(configured.interceptors?.activity)).toBe(true);
239
+ expect(configured.interceptors?.activity?.length).toBe(1);
240
+ expect(configured.sinks).toBeDefined();
241
+ expect(configured.sinks?.braintrust).toBeDefined();
242
+ });
243
+ });
package/src/utils.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { Payload } from "@temporalio/common";
2
+
3
+ export const BRAINTRUST_SPAN_HEADER = "_braintrust-span";
4
+ export const BRAINTRUST_WORKFLOW_SPAN_HEADER = "_braintrust-workflow-span";
5
+ export const BRAINTRUST_WORKFLOW_SPAN_ID_HEADER =
6
+ "_braintrust-workflow-span-id";
7
+
8
+ export function serializeHeaderValue(value: string): Payload {
9
+ return {
10
+ metadata: {
11
+ encoding: new TextEncoder().encode("json/plain"),
12
+ },
13
+ data: new TextEncoder().encode(JSON.stringify(value)),
14
+ };
15
+ }
16
+
17
+ export function deserializeHeaderValue(
18
+ payload: Payload | undefined,
19
+ ): string | undefined {
20
+ if (!payload?.data) {
21
+ return undefined;
22
+ }
23
+ try {
24
+ const decoded = new TextDecoder().decode(payload.data);
25
+ return JSON.parse(decoded);
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Workflow interceptors for Braintrust tracing.
3
+ *
4
+ * IMPORTANT: This module is loaded into the Temporal workflow isolate.
5
+ * It cannot import Node.js modules or access external state directly.
6
+ * Communication with the outside world is done via sinks.
7
+ */
8
+ import {
9
+ WorkflowInterceptorsFactory,
10
+ WorkflowInboundCallsInterceptor,
11
+ WorkflowOutboundCallsInterceptor,
12
+ WorkflowExecuteInput,
13
+ Next,
14
+ proxySinks,
15
+ workflowInfo,
16
+ uuid4,
17
+ } from "@temporalio/workflow";
18
+ import type {
19
+ ActivityInput,
20
+ LocalActivityInput,
21
+ StartChildWorkflowExecutionInput,
22
+ } from "@temporalio/workflow";
23
+ import type { Payload } from "@temporalio/common";
24
+ import type { BraintrustSinks } from "./sinks";
25
+ import {
26
+ BRAINTRUST_SPAN_HEADER,
27
+ BRAINTRUST_WORKFLOW_SPAN_HEADER,
28
+ BRAINTRUST_WORKFLOW_SPAN_ID_HEADER,
29
+ serializeHeaderValue,
30
+ deserializeHeaderValue,
31
+ } from "./utils";
32
+
33
+ const { braintrust } = proxySinks<BraintrustSinks>();
34
+
35
+ /**
36
+ * Shared state between inbound and outbound interceptors for a single workflow.
37
+ * Created per-workflow by the factory function to avoid global state issues.
38
+ */
39
+ interface WorkflowSpanState {
40
+ parentContext: string | undefined;
41
+ spanId: string | undefined;
42
+ }
43
+
44
+ class BraintrustWorkflowInboundInterceptor
45
+ implements WorkflowInboundCallsInterceptor
46
+ {
47
+ constructor(private state: WorkflowSpanState) {}
48
+
49
+ async execute(
50
+ input: WorkflowExecuteInput,
51
+ next: Next<WorkflowInboundCallsInterceptor, "execute">,
52
+ ): Promise<unknown> {
53
+ // Extract parent context from headers
54
+ const parentContext = input.headers
55
+ ? deserializeHeaderValue(input.headers[BRAINTRUST_SPAN_HEADER])
56
+ : undefined;
57
+
58
+ // Store for the outbound interceptor to forward to activities
59
+ this.state.parentContext = parentContext;
60
+
61
+ // Generate a deterministic spanId for the workflow span
62
+ this.state.spanId = uuid4();
63
+
64
+ // Create workflow span via sink (only called if not replaying)
65
+ // NOTE: WorkflowInfo is injected automatically by the runtime
66
+ braintrust.workflowStarted(parentContext, this.state.spanId);
67
+
68
+ try {
69
+ const result = await next(input);
70
+ braintrust.workflowCompleted();
71
+ return result;
72
+ } catch (e) {
73
+ braintrust.workflowCompleted(e instanceof Error ? e.message : String(e));
74
+ throw e;
75
+ }
76
+ }
77
+ }
78
+
79
+ class BraintrustWorkflowOutboundInterceptor
80
+ implements WorkflowOutboundCallsInterceptor
81
+ {
82
+ constructor(private state: WorkflowSpanState) {}
83
+
84
+ private getHeaders(): Record<string, Payload> {
85
+ const info = workflowInfo();
86
+ const headers: Record<string, Payload> = {};
87
+
88
+ // Pass runId so activity can look up workflow span on same worker
89
+ headers[BRAINTRUST_WORKFLOW_SPAN_HEADER] = serializeHeaderValue(info.runId);
90
+
91
+ // Pass workflow span ID for cross-worker activities to construct parent
92
+ if (this.state.spanId) {
93
+ headers[BRAINTRUST_WORKFLOW_SPAN_ID_HEADER] = serializeHeaderValue(
94
+ this.state.spanId,
95
+ );
96
+ }
97
+
98
+ // Pass client context for cross-worker activities to construct parent
99
+ if (this.state.parentContext) {
100
+ headers[BRAINTRUST_SPAN_HEADER] = serializeHeaderValue(
101
+ this.state.parentContext,
102
+ );
103
+ }
104
+
105
+ return headers;
106
+ }
107
+
108
+ scheduleActivity(
109
+ input: ActivityInput,
110
+ next: Next<WorkflowOutboundCallsInterceptor, "scheduleActivity">,
111
+ ) {
112
+ return next({
113
+ ...input,
114
+ headers: {
115
+ ...input.headers,
116
+ ...this.getHeaders(),
117
+ },
118
+ });
119
+ }
120
+
121
+ scheduleLocalActivity(
122
+ input: LocalActivityInput,
123
+ next: Next<WorkflowOutboundCallsInterceptor, "scheduleLocalActivity">,
124
+ ) {
125
+ return next({
126
+ ...input,
127
+ headers: {
128
+ ...input.headers,
129
+ ...this.getHeaders(),
130
+ },
131
+ });
132
+ }
133
+
134
+ startChildWorkflowExecution(
135
+ input: StartChildWorkflowExecutionInput,
136
+ next: Next<WorkflowOutboundCallsInterceptor, "startChildWorkflowExecution">,
137
+ ) {
138
+ return next({
139
+ ...input,
140
+ headers: {
141
+ ...input.headers,
142
+ ...this.getHeaders(),
143
+ },
144
+ });
145
+ }
146
+ }
147
+
148
+ export const interceptors: WorkflowInterceptorsFactory = () => {
149
+ // Create shared state for this workflow instance
150
+ const state: WorkflowSpanState = {
151
+ parentContext: undefined,
152
+ spanId: undefined,
153
+ };
154
+
155
+ return {
156
+ inbound: [new BraintrustWorkflowInboundInterceptor(state)],
157
+ outbound: [new BraintrustWorkflowOutboundInterceptor(state)],
158
+ };
159
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "declaration": true,
4
+ "lib": ["es2022"],
5
+ "module": "commonjs",
6
+ "target": "es2022",
7
+ "moduleResolution": "node",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true
11
+ },
12
+ "include": ["."],
13
+ "exclude": ["node_modules/**", "**/dist/**"]
14
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig([
4
+ {
5
+ entry: ["src/index.ts", "src/workflow-interceptors.ts"],
6
+ format: ["cjs", "esm"],
7
+ outDir: "dist",
8
+ external: [
9
+ "braintrust",
10
+ "braintrust/util",
11
+ "@braintrust/temporal/workflow-interceptors",
12
+ "@temporalio/activity",
13
+ "@temporalio/client",
14
+ "@temporalio/common",
15
+ "@temporalio/worker",
16
+ "@temporalio/workflow",
17
+ ],
18
+ dts: true,
19
+ },
20
+ ]);
package/turbo.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": ["//"],
3
+ "tasks": {
4
+ "build": {
5
+ "outputs": ["**/dist/**"]
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,5 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {},
5
+ });