@outputai/core 0.9.3-next.5289bca.0 → 0.9.3-next.c318502.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/core",
3
- "version": "0.9.3-next.5289bca.0",
3
+ "version": "0.9.3-next.c318502.0",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,32 +1,79 @@
1
+ import { FatalError } from '#errors';
2
+
3
+ /** Matches red int "hot-red-pie", but not int "redact" */
4
+ const wordMatcher = term => new RegExp( `(?<![a-z\\d])${term}(?![a-z\\d])`, 'i' );
5
+
6
+ /** Matches red in "acquired", but not in "redact" */
7
+ const wordEndMatcher = term => new RegExp( `${term}(?![a-z\\d])`, 'i' );
8
+
9
+ /**
10
+ * Redacts sensitive headers
11
+ * @param {object} headers
12
+ * @returns {object} The redacted headers
13
+ */
14
+ export const redactHeaders = headers => {
15
+ /** Header names that look sensitive by substring rules but are not secret material. */
16
+ const ignoreHeaders = new Set( [
17
+ 'x-csrf-token',
18
+ 'public-key-pins'
19
+ ] );
20
+
21
+ /** * Sensitive header patterns for redaction (case-insensitive). */
22
+ const sensitiveHeadersPatterns = [
23
+ // matches headers that contain these exact words
24
+ wordMatcher( 'authorization' ),
25
+ wordMatcher( 'token' ),
26
+ wordMatcher( 'secret' ),
27
+ wordMatcher( 'password' ),
28
+ wordMatcher( 'pwd' ),
29
+ wordMatcher( 'cookie' ),
30
+ // matches header that contain words ending with these sequences
31
+ wordEndMatcher( 'key' )
32
+ ];
33
+
34
+ return Object.entries( headers ).reduce( ( redacted, [ key, value ] ) => {
35
+ const lowKey = key.toLowerCase();
36
+ const isSensitive = !ignoreHeaders.has( lowKey ) && sensitiveHeadersPatterns.some( rx => rx.test( lowKey ) );
37
+ return Object.assign( redacted, { [key]: isSensitive ? '[REDACTED]' : value } );
38
+ }, {} );
39
+ };
40
+
41
+ /**
42
+ * Consume the body of a Response according it its content-type and returns it
43
+ * @param {Response} response
44
+ * @returns {string|object|undefined|null} The response body content
45
+ */
46
+ const consumeBody = async response => {
47
+ const headers = Object.fromEntries( response.headers ) ?? {};
48
+ const contentType = ( headers['content-type'] ?? '' ).trim().toLowerCase();
49
+ const jsonMatcher = /^application\/(?:json|[^;\s]+?\+json)(?:\s*;.*)?$/i;
50
+ if ( jsonMatcher.test( contentType ) ) {
51
+ return response.json();
52
+ }
53
+ if ( contentType.startsWith( 'text/' ) ) {
54
+ return response.text();
55
+ }
56
+ return response.arrayBuffer().then( buf => Buffer.from( buf ).toString( 'base64' ) );
57
+ };
58
+
1
59
  /**
2
60
  * Consume Fetch's HTTP Response and return a serialized version of it;
3
61
  *
4
62
  * @param {Response} response
63
+ * @param {options} responseOptions
64
+ * @param {boolean} responseOptions.includeBody - If the body must be included in the response (default false)
65
+ * @param {boolean} responseOptions.includeHeaders - If the redacted headers must be included in the response - headers are always redacted (default false)
5
66
  * @returns {object} Serialized response
6
67
  */
7
- export const serializeFetchResponse = async response => {
8
- const headers = Object.fromEntries( response.headers );
9
- const contentType = headers['content-type'] || '';
10
-
11
- const body = await ( async () => {
12
- if ( contentType.includes( 'application/json' ) ) {
13
- return response.json();
14
- }
15
- if ( contentType.startsWith( 'text/' ) ) {
16
- return response.text();
17
- }
18
- return response.arrayBuffer().then( buf => Buffer.from( buf ).toString( 'base64' ) );
19
- } )();
20
-
21
- return {
22
- url: response.url,
23
- status: response.status,
24
- statusText: response.statusText,
25
- ok: response.ok,
26
- headers,
27
- body
28
- };
29
- };
68
+ export const serializeResponse = async ( response, { includeHeaders = false, includeBody = false } = {} ) => ( {
69
+ url: response.url,
70
+ status: response.status,
71
+ statusText: response.statusText,
72
+ ok: response.ok,
73
+ ...( includeHeaders && { headers: redactHeaders( Object.fromEntries( response.headers ) ) } ),
74
+ ...( includeBody && { body: await consumeBody( response ) } )
75
+ } );
76
+
30
77
  /**
31
78
  * Duck-typing to detect a Node Readable (Stream) without importing anything
32
79
  *
@@ -103,3 +150,21 @@ export const serializeBodyAndInferContentType = payload => {
103
150
 
104
151
  return { body: String( payload ), contentType: 'text/plain; charset=UTF-8' };
105
152
  };
153
+
154
+ const getSecretFromEnv = varName => {
155
+ const value = process.env[varName];
156
+ if ( value === undefined ) {
157
+ throw new FatalError( `Missing environment variable "${varName}" while hydrating headers.` );
158
+ }
159
+ return value;
160
+ };
161
+
162
+ /**
163
+ * Replaces $VAR_NAME tokens in header values
164
+ * @param {object} headers
165
+ * @returns {object} Hydrated headers
166
+ */
167
+ export const hydrateHeaders = headers =>
168
+ Object.entries( headers ?? {} ).reduce( ( o, [ key, value ] ) =>
169
+ Object.assign( o, { [key]: ( '' + value ).replace( /\$([A-Z_][A-Z0-9_]*)/g, ( _, _var ) => getSecretFromEnv( _var ) ) } )
170
+ , {} );
@@ -1,9 +1,136 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { afterEach, describe, it, expect } from 'vitest';
2
2
  import { Readable } from 'node:stream';
3
- import { serializeBodyAndInferContentType, serializeFetchResponse } from './fetch.js';
3
+ import { hydrateHeaders, redactHeaders, serializeBodyAndInferContentType, serializeResponse } from './fetch.js';
4
4
 
5
- describe( 'serializeFetchResponse', () => {
6
- it( 'serializes JSON response body and flattens headers', async () => {
5
+ describe( 'redactHeaders', () => {
6
+ it( 'redacts sensitive header names', () => {
7
+ const result = redactHeaders( {
8
+ Authorization: 'Bearer token',
9
+ 'X-Api-Key': 'api-key',
10
+ Cookie: 'session=id',
11
+ 'x-client-secret': 'secret'
12
+ } );
13
+
14
+ expect( result ).toEqual( {
15
+ Authorization: '[REDACTED]',
16
+ 'X-Api-Key': '[REDACTED]',
17
+ Cookie: '[REDACTED]',
18
+ 'x-client-secret': '[REDACTED]'
19
+ } );
20
+ } );
21
+
22
+ it( 'preserves non-sensitive header names and ignored false positives', () => {
23
+ const result = redactHeaders( {
24
+ Accept: 'application/json',
25
+ 'Content-Type': 'application/json',
26
+ 'x-csrf-token': 'csrf-token',
27
+ 'public-key-pins': 'pin'
28
+ } );
29
+
30
+ expect( result ).toEqual( {
31
+ Accept: 'application/json',
32
+ 'Content-Type': 'application/json',
33
+ 'x-csrf-token': 'csrf-token',
34
+ 'public-key-pins': 'pin'
35
+ } );
36
+ } );
37
+
38
+ it( 'handles empty headers', () => {
39
+ expect( redactHeaders( {} ) ).toEqual( {} );
40
+ } );
41
+ } );
42
+
43
+ describe( 'hydrateHeaders', () => {
44
+ afterEach( () => {
45
+ delete process.env.TOKEN;
46
+ delete process.env.API_KEY;
47
+ } );
48
+
49
+ it( 'replaces environment variable tokens in header values', () => {
50
+ process.env.TOKEN = 'secret-token';
51
+
52
+ expect( hydrateHeaders( {
53
+ Authorization: 'Bearer $TOKEN'
54
+ } ) ).toEqual( {
55
+ Authorization: 'Bearer secret-token'
56
+ } );
57
+ } );
58
+
59
+ it( 'replaces repeated and multiple environment variable tokens', () => {
60
+ process.env.TOKEN = 'secret-token';
61
+ process.env.API_KEY = 'api-key';
62
+
63
+ expect( hydrateHeaders( {
64
+ Authorization: 'Bearer $TOKEN',
65
+ 'X-Composite': '$TOKEN:$API_KEY:$TOKEN'
66
+ } ) ).toEqual( {
67
+ Authorization: 'Bearer secret-token',
68
+ 'X-Composite': 'secret-token:api-key:secret-token'
69
+ } );
70
+ } );
71
+
72
+ it( 'preserves headers without environment variable tokens', () => {
73
+ expect( hydrateHeaders( {
74
+ Accept: 'application/json',
75
+ 'Content-Type': 'application/json'
76
+ } ) ).toEqual( {
77
+ Accept: 'application/json',
78
+ 'Content-Type': 'application/json'
79
+ } );
80
+ } );
81
+
82
+ it( 'handles missing headers', () => {
83
+ expect( hydrateHeaders() ).toEqual( {} );
84
+ } );
85
+
86
+ it( 'throws when an environment variable token is missing', () => {
87
+ expect( () => hydrateHeaders( {
88
+ Authorization: 'Bearer $TOKEN'
89
+ } ) ).toThrow( 'Missing environment variable "TOKEN" while hydrating headers.' );
90
+ } );
91
+ } );
92
+
93
+ describe( 'serializeResponse', () => {
94
+ it( 'serializes response metadata by default', async () => {
95
+ const response = new Response( '{not json', {
96
+ status: 200,
97
+ statusText: 'OK',
98
+ headers: { 'content-type': 'application/json' }
99
+ } );
100
+
101
+ await expect( serializeResponse( response ) ).resolves.toEqual( {
102
+ url: '',
103
+ status: 200,
104
+ statusText: 'OK',
105
+ ok: true
106
+ } );
107
+ } );
108
+
109
+ it( 'includes redacted headers when requested', async () => {
110
+ const response = new Response( null, {
111
+ status: 204,
112
+ statusText: 'No Content',
113
+ headers: {
114
+ authorization: 'Bearer token',
115
+ 'content-type': 'application/json'
116
+ }
117
+ } );
118
+
119
+ const result = await serializeResponse( response, { includeHeaders: true } );
120
+
121
+ expect( result ).toEqual( {
122
+ url: '',
123
+ status: 204,
124
+ statusText: 'No Content',
125
+ ok: true,
126
+ headers: {
127
+ authorization: '[REDACTED]',
128
+ 'content-type': 'application/json'
129
+ }
130
+ } );
131
+ } );
132
+
133
+ it( 'includes JSON body when requested', async () => {
7
134
  const payload = { a: 1, b: 'two' };
8
135
  const response = new Response( JSON.stringify( payload ), {
9
136
  status: 200,
@@ -11,32 +138,39 @@ describe( 'serializeFetchResponse', () => {
11
138
  headers: { 'content-type': 'application/json' }
12
139
  } );
13
140
 
14
- const result = await serializeFetchResponse( response );
15
- expect( result.status ).toBe( 200 );
16
- expect( result.ok ).toBe( true );
17
- expect( result.statusText ).toBe( 'OK' );
18
- expect( result.headers['content-type'] ).toContain( 'application/json' );
141
+ const result = await serializeResponse( response, { includeBody: true } );
142
+
143
+ expect( result.body ).toEqual( payload );
144
+ } );
145
+
146
+ it( 'includes structured syntax JSON body when requested', async () => {
147
+ const payload = { error: 'Invalid input' };
148
+ const response = new Response( JSON.stringify( payload ), {
149
+ status: 400,
150
+ statusText: 'Bad Request',
151
+ headers: { 'content-type': 'application/problem+json; charset=utf-8' }
152
+ } );
153
+
154
+ const result = await serializeResponse( response, { includeBody: true } );
155
+
19
156
  expect( result.body ).toEqual( payload );
20
157
  } );
21
158
 
22
- it( 'serializes text/* response via text()', async () => {
159
+ it( 'includes text body when requested', async () => {
23
160
  const bodyText = 'hello world';
24
161
  const response = new Response( bodyText, {
25
162
  status: 201,
26
163
  statusText: 'Created',
27
- headers: { 'content-type': 'text/plain; charset=utf-8' }
164
+ headers: { 'content-type': 'Text/Plain; charset=utf-8' }
28
165
  } );
29
166
 
30
- const result = await serializeFetchResponse( response );
31
- expect( result.status ).toBe( 201 );
32
- expect( result.ok ).toBe( true );
33
- expect( result.statusText ).toBe( 'Created' );
34
- expect( result.headers['content-type'] ).toContain( 'text/plain' );
167
+ const result = await serializeResponse( response, { includeBody: true } );
168
+
35
169
  expect( result.body ).toBe( bodyText );
36
170
  } );
37
171
 
38
172
  if ( typeof ReadableStream !== 'undefined' ) {
39
- it( 'serializes ReadableStream body for text/* via text()', async () => {
173
+ it( 'includes ReadableStream text body when requested', async () => {
40
174
  const encoder = new TextEncoder();
41
175
  const chunk = encoder.encode( 'streamed text' );
42
176
  const stream = new ReadableStream( {
@@ -51,16 +185,13 @@ describe( 'serializeFetchResponse', () => {
51
185
  headers: { 'content-type': 'text/plain; charset=utf-8' }
52
186
  } );
53
187
 
54
- const result = await serializeFetchResponse( response );
55
- expect( result.status ).toBe( 200 );
56
- expect( result.ok ).toBe( true );
57
- expect( result.statusText ).toBe( 'OK' );
58
- expect( result.headers['content-type'] ).toContain( 'text/plain' );
188
+ const result = await serializeResponse( response, { includeBody: true } );
189
+
59
190
  expect( result.body ).toBe( 'streamed text' );
60
191
  } );
61
192
  }
62
193
 
63
- it( 'serializes non-text/non-json response as base64 from arrayBuffer()', async () => {
194
+ it( 'includes non-text non-json response as base64 when requested', async () => {
64
195
  const bytes = Uint8Array.from( [ 0, 1, 2, 3 ] );
65
196
  const response = new Response( bytes, {
66
197
  status: 200,
@@ -68,21 +199,17 @@ describe( 'serializeFetchResponse', () => {
68
199
  headers: { 'content-type': 'application/octet-stream' }
69
200
  } );
70
201
 
71
- const result = await serializeFetchResponse( response );
72
- expect( result.status ).toBe( 200 );
73
- expect( result.ok ).toBe( true );
74
- expect( result.statusText ).toBe( 'OK' );
75
- expect( result.headers['content-type'] ).toBe( 'application/octet-stream' );
202
+ const result = await serializeResponse( response, { includeBody: true } );
203
+
76
204
  expect( result.body ).toBe( Buffer.from( bytes ).toString( 'base64' ) );
77
205
  } );
78
206
 
79
- it( 'defaults to base64 when content-type header is missing', async () => {
207
+ it( 'defaults to base64 body when content-type header is missing and body is requested', async () => {
80
208
  const bytes = Uint8Array.from( [ 0, 1, 2, 3 ] );
81
209
  const response = new Response( bytes, { status: 200 } );
82
- // No headers set; content-type resolves to ''
83
210
 
84
- const result = await serializeFetchResponse( response );
85
- expect( result.headers['content-type'] ?? '' ).toBe( '' );
211
+ const result = await serializeResponse( response, { includeBody: true } );
212
+
86
213
  expect( result.body ).toBe( Buffer.from( bytes ).toString( 'base64' ) );
87
214
  } );
88
215
  } );
@@ -4,18 +4,15 @@ export class TraceInfo {
4
4
 
5
5
  /**
6
6
  * Builds the trace information propagated through workflow memo and activity headers.
7
- * @param {object} options - Arguments to build trace information
8
- * @param {boolean} options.disableTrace - Whether trace event emission should be disabled
9
7
  * @returns {object} trace information
10
8
  */
11
- static build( { disableTrace } ) {
9
+ static build() {
12
10
  const info = inWorkflowContext() ? workflowInfo() : {};
13
11
  return {
14
12
  workflowId: info.workflowId,
15
13
  workflowType: info.workflowType,
16
14
  runId: info.runId,
17
- startTime: info.startTime?.getTime(),
18
- disableTrace
15
+ startTime: info.startTime?.getTime()
19
16
  };
20
17
  };
21
18
  }
@@ -23,24 +23,22 @@ describe( 'TraceInfo', () => {
23
23
  startTime: new Date( '2026-06-02T09:00:00.000Z' )
24
24
  } );
25
25
 
26
- expect( TraceInfo.build( { disableTrace: false } ) ).toEqual( {
26
+ expect( TraceInfo.build() ).toEqual( {
27
27
  workflowId: 'workflow-id',
28
28
  workflowType: 'workflow-type',
29
29
  runId: 'run-id',
30
- startTime: Date.parse( '2026-06-02T09:00:00.000Z' ),
31
- disableTrace: false
30
+ startTime: Date.parse( '2026-06-02T09:00:00.000Z' )
32
31
  } );
33
32
  } );
34
33
 
35
34
  it( 'builds trace info without Temporal fields outside workflow context', () => {
36
35
  inWorkflowContextMock.mockReturnValue( false );
37
36
 
38
- expect( TraceInfo.build( { disableTrace: true } ) ).toEqual( {
37
+ expect( TraceInfo.build() ).toEqual( {
39
38
  workflowId: undefined,
40
39
  workflowType: undefined,
41
40
  runId: undefined,
42
- startTime: undefined,
43
- disableTrace: true
41
+ startTime: undefined
44
42
  } );
45
43
  expect( workflowInfoMock ).not.toHaveBeenCalled();
46
44
  } );
@@ -141,7 +141,8 @@ describe( 'interface validators', () => {
141
141
  url: 'https://example.com',
142
142
  method: 'POST',
143
143
  payload: { ok: true },
144
- headers: { authorization: 'Bearer token' }
144
+ headers: { authorization: 'Bearer token' },
145
+ responseOptions: { includeHeaders: true, includeBody: true }
145
146
  } ) ).not.toThrow();
146
147
  } );
147
148
 
@@ -155,6 +156,12 @@ describe( 'interface validators', () => {
155
156
  url: 'https://example.com',
156
157
  method: 'OPTIONS'
157
158
  } ) ).toThrow( ValidationError );
159
+
160
+ expect( () => validateRequestPayload( {
161
+ url: 'https://example.com',
162
+ method: 'GET',
163
+ responseOptions: { includeHeaders: 'yes' }
164
+ } ) ).toThrow( ValidationError );
158
165
  } );
159
166
  } );
160
167
 
@@ -88,7 +88,11 @@ export const httpRequestSchema = z.object( {
88
88
  url: z.url( { protocol: /^https?$/ } ),
89
89
  method: z.enum( [ 'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE' ] ),
90
90
  payload: z.any().optional(),
91
- headers: z.record( z.string(), z.string() ).optional()
91
+ headers: z.record( z.string(), z.string() ).optional(),
92
+ responseOptions: z.strictObject( {
93
+ includeHeaders: z.boolean().optional().default( false ),
94
+ includeBody: z.boolean().optional().default( false )
95
+ } ).optional()
92
96
  } );
93
97
 
94
98
  export const workflowInvocationOptionsSchema = z.strictObject( {
@@ -143,11 +143,26 @@ describe( 'validation schemas', () => {
143
143
  url: 'https://example.com',
144
144
  method: 'POST',
145
145
  payload: { ok: true },
146
- headers: { authorization: 'Bearer token' }
146
+ headers: { authorization: 'Bearer token' },
147
+ responseOptions: { includeHeaders: true, includeBody: true }
147
148
  } ).success ).toBe( true );
148
149
  } );
149
150
 
150
- it( 'rejects invalid URL protocols, methods, and header values', () => {
151
+ it( 'defaults omitted response options to false', () => {
152
+ const result = httpRequestSchema.safeParse( {
153
+ url: 'https://example.com',
154
+ method: 'GET',
155
+ responseOptions: {}
156
+ } );
157
+
158
+ expect( result.success ).toBe( true );
159
+ expect( result.data.responseOptions ).toEqual( {
160
+ includeHeaders: false,
161
+ includeBody: false
162
+ } );
163
+ } );
164
+
165
+ it( 'rejects invalid URL protocols, methods, header values, and response options', () => {
151
166
  expect( httpRequestSchema.safeParse( { url: 'ftp://example.com', method: 'GET' } ).success ).toBe( false );
152
167
  expect( httpRequestSchema.safeParse( { url: 'https://example.com', method: 'OPTIONS' } ).success ).toBe( false );
153
168
  expect( httpRequestSchema.safeParse( {
@@ -155,6 +170,11 @@ describe( 'validation schemas', () => {
155
170
  method: 'GET',
156
171
  headers: { count: 1 }
157
172
  } ).success ).toBe( false );
173
+ expect( httpRequestSchema.safeParse( {
174
+ url: 'https://example.com',
175
+ method: 'GET',
176
+ responseOptions: { includeBody: 'yes' }
177
+ } ).success ).toBe( false );
158
178
  } );
159
179
  } );
160
180
 
@@ -3,6 +3,17 @@
3
3
  */
4
4
  export type HttpMethod = 'HEAD' | 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
5
5
 
6
+ /**
7
+ * Controls which response fields are included in the serialized HTTP response.
8
+ */
9
+ export type ResponseOptions = {
10
+ /** Include redacted response headers in the returned value. */
11
+ includeHeaders?: boolean,
12
+
13
+ /** Include the response body in the returned value. */
14
+ includeBody?: boolean
15
+ };
16
+
6
17
  /** Represents a {Response} serialized to plain object */
7
18
  export type SerializedFetchResponse = {
8
19
  /** The response url */
@@ -18,10 +29,10 @@ export type SerializedFetchResponse = {
18
29
  ok: boolean,
19
30
 
20
31
  /** Object with response headers */
21
- headers: Record<string, string>,
32
+ headers?: Record<string, string>,
22
33
 
23
34
  /** Response body, either JSON, text, or an arrayBuffer converted to base64 */
24
- body: object | string
35
+ body?: unknown
25
36
  };
26
37
 
27
38
  /**
@@ -93,6 +104,7 @@ export declare function sendPostRequestAndAwaitWebhook( params: {
93
104
  * @param params.method - The HTTP method (default: 'GET')
94
105
  * @param params.payload - Request payload (only for POST/PUT)
95
106
  * @param params.headers - Headers for the request
107
+ * @param params.responseOptions - Controls which response fields are included
96
108
  * @returns Resolves with an HTTP response serialized to a plain object
97
109
  */
98
110
  export declare function sendHttpRequest( params: {
@@ -100,4 +112,5 @@ export declare function sendHttpRequest( params: {
100
112
  method?: HttpMethod;
101
113
  payload?: object;
102
114
  headers?: Record<string, string>;
115
+ responseOptions?: ResponseOptions;
103
116
  } ): Promise<SerializedFetchResponse>;
@@ -12,10 +12,13 @@ import { validateRequestPayload } from './validations/index.js';
12
12
  * @param {string} method
13
13
  * @param {unknown} [payload]
14
14
  * @param {object} [headers]
15
+ * @param {object} [options.responseOptions] - Response options
16
+ * @param {boolean} [options.responseOptions.includeHeaders] - If the response will include the headers - Headers are always redacted (default false)
17
+ * @param {boolean} [options.responseOptions.includeBody] - If the response will include the body (default false)
15
18
  * @returns {Promise<object>} The serialized HTTP response
16
19
  */
17
- export async function sendHttpRequest( { url, method = 'GET', payload = undefined, headers = undefined } ) {
18
- validateRequestPayload( { method, url, payload, headers } );
20
+ export async function sendHttpRequest( { url, method = 'GET', payload = undefined, headers = undefined, responseOptions = {} } ) {
21
+ validateRequestPayload( { method, url, payload, headers, responseOptions } );
19
22
  const res = await proxyActivities( {
20
23
  startToCloseTimeout: '3m',
21
24
  retry: {
@@ -23,7 +26,7 @@ export async function sendHttpRequest( { url, method = 'GET', payload = undefine
23
26
  maximumAttempts: 3,
24
27
  nonRetryableErrorTypes: [ FatalError.name ]
25
28
  }
26
- } )[ACTIVITY_SEND_HTTP_REQUEST]( { method, url, payload, headers } );
29
+ } )[ACTIVITY_SEND_HTTP_REQUEST]( { method, url, payload, headers, responseOptions } );
27
30
  return res.output;
28
31
  };
29
32
 
@@ -82,7 +82,12 @@ describe( 'interface/webhook', () => {
82
82
  const res = await sendHttpRequest( args );
83
83
 
84
84
  // validated
85
- expect( validateRequestPayloadMock ).toHaveBeenCalledWith( { ...args, payload: undefined, headers: undefined } );
85
+ expect( validateRequestPayloadMock ).toHaveBeenCalledWith( {
86
+ ...args,
87
+ payload: undefined,
88
+ headers: undefined,
89
+ responseOptions: {}
90
+ } );
86
91
 
87
92
  // activity proxied with specified options
88
93
  expect( proxyActivitiesMock ).toHaveBeenCalledTimes( 1 );
@@ -95,10 +100,40 @@ describe( 'interface/webhook', () => {
95
100
  } ) );
96
101
 
97
102
  // activity invoked with the same args
98
- expect( activityFnMock ).toHaveBeenCalledWith( { ...args, payload: undefined, headers: undefined } );
103
+ expect( activityFnMock ).toHaveBeenCalledWith( {
104
+ ...args,
105
+ payload: undefined,
106
+ headers: undefined,
107
+ responseOptions: {}
108
+ } );
99
109
  expect( res ).toEqual( fakeSerializedResponse );
100
110
  } );
101
111
 
112
+ it( 'sendHttpRequest forwards response options', async () => {
113
+ const { sendHttpRequest } = await import( './webhook.js' );
114
+
115
+ activityFnMock.mockResolvedValueOnce( activityEnvelope( { ok: true } ) );
116
+
117
+ const args = {
118
+ url: 'https://example.com/api',
119
+ method: 'GET',
120
+ responseOptions: { includeHeaders: true, includeBody: true }
121
+ };
122
+
123
+ await sendHttpRequest( args );
124
+
125
+ expect( validateRequestPayloadMock ).toHaveBeenCalledWith( {
126
+ ...args,
127
+ payload: undefined,
128
+ headers: undefined
129
+ } );
130
+ expect( activityFnMock ).toHaveBeenCalledWith( {
131
+ ...args,
132
+ payload: undefined,
133
+ headers: undefined
134
+ } );
135
+ } );
136
+
102
137
  it( 'sendPostRequestAndAwaitWebhook posts wrapped payload and resolves on resume signal', async () => {
103
138
  const { sendPostRequestAndAwaitWebhook } = await import( './webhook.js' );
104
139
 
@@ -121,6 +156,7 @@ describe( 'interface/webhook', () => {
121
156
  expect( callArgs.url ).toBe( url );
122
157
  expect( callArgs.payload ).toEqual( { workflowId: 'wf-123', payload: { x: 1 } } );
123
158
  expect( callArgs.headers ).toEqual( { a: 'b' } );
159
+ expect( callArgs.responseOptions ).toEqual( {} );
124
160
 
125
161
  // Returns a promise (async function) for the eventual webhook result
126
162
  expect( typeof promise.then ).toBe( 'function' );
@@ -8,25 +8,12 @@ import { deepMerge } from '#helpers/object';
8
8
  import { defaultOptions } from './workflow_activity_options.js';
9
9
  import { createWorkflow } from '#helpers/component';
10
10
  import {
11
- ACTIVITY_WRAPPER_VERSION_FIELD,
12
11
  ACTIVITY_GET_TRACE_DESTINATIONS,
13
12
  METADATA_ACCESS_SYMBOL,
14
13
  SHARED_STEP_PREFIX,
15
14
  WORKFLOW_WRAPPER_VERSION_FIELD
16
15
  } from '#consts';
17
16
 
18
- /**
19
- * @temp
20
- * This is to keep backwards compatibility [OUT-468]
21
- */
22
- const parseActivityOutput = p => Object.hasOwn( p ?? {}, ACTIVITY_WRAPPER_VERSION_FIELD ) ? p.output : p;
23
-
24
- /**
25
- * @temp This is a TEMP fallback method to allow workflow child checks on replays without memo. [OUT-468]
26
- * This workflows for most scenarios, only does not supports recursion with the same name.
27
- */
28
- const checkChildFallback = ( { workflowType, name, aliases } ) => workflowType !== name && !aliases.includes( workflowType );
29
-
30
17
  /**
31
18
  * Create a new workflow and return a wrapper function around its fn handler
32
19
  */
@@ -54,12 +41,11 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
54
41
  return output;
55
42
  }
56
43
 
57
- const { workflowId, workflowType, memo, root } = workflowInfo();
44
+ const { workflowId, memo, root } = workflowInfo();
58
45
 
59
46
  // if the stack already includes this workflowId, means the workflow() function was called
60
47
  // from within a running workflow, meaning it is suppose to start a child workflow
61
- const isChild = Array.isArray( memo.stack ) ? memo.stack.includes( workflowId ) :
62
- checkChildFallback( { workflowType, aliases, name } );
48
+ const isChild = Array.isArray( memo.stack ) ? memo.stack.includes( workflowId ) : false;
63
49
 
64
50
  if ( isChild ) {
65
51
  const result = await executeChild( name, {
@@ -81,19 +67,23 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
81
67
  memo.stack = [ ...memo.stack ?? [], workflowId ];
82
68
  // Parent options have prevalence on nested calls, child will be overwritten
83
69
  memo.activityOptions = deepMerge( activityOptions, memo.activityOptions );
84
- // Trace info is only added in the root workflow
85
- if ( isRoot ) {
86
- memo.traceInfo = TraceInfo.build( { disableTrace } );
70
+ // Trace info is only added in the root and only when trace is not disabled
71
+ if ( isRoot && !disableTrace ) {
72
+ memo.traceInfo = TraceInfo.build();
87
73
  }
88
74
 
89
75
  const steps = proxyActivities( memo.activityOptions );
90
- const traceDest = isRoot && parseActivityOutput( await steps[ACTIVITY_GET_TRACE_DESTINATIONS]( memo.traceInfo ) );
76
+ const traceDestinations = isRoot && {
77
+ trace: {
78
+ destinations: disableTrace ? {} : ( await steps[ACTIVITY_GET_TRACE_DESTINATIONS]( memo.traceInfo ) ).output ?? {}
79
+ }
80
+ };
91
81
 
92
82
  try {
93
83
  validator.validateInput( input );
94
84
 
95
85
  // Creates an activity caller based on a prefix
96
- const createCaller = prefix => async ( t, ...args ) => parseActivityOutput( await steps[`${prefix}#${t}`]( ...args ) );
86
+ const createCaller = prefix => async ( t, ...args ) => ( await steps[`${prefix}#${t}`]( ...args ) ).output;
97
87
 
98
88
  // This are functions used by the AST to replace direct activity (step/evaluator) calls
99
89
  const dispatchers = {
@@ -107,15 +97,11 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
107
97
  const output = await fn.call( dispatchers, input, WorkflowContext.build() );
108
98
  validator.validateOutput( output );
109
99
 
110
- return {
111
- [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
112
- output,
113
- ...( traceDest && { trace: { destinations: traceDest } } )
114
- };
100
+ return { [WORKFLOW_WRAPPER_VERSION_FIELD]: 1, output, ...traceDestinations };
115
101
  } catch ( error ) {
116
- if ( isRoot && traceDest ) {
102
+ if ( traceDestinations ) {
117
103
  // Append the trace destinations so it is carried to interceptor
118
- error[METADATA_ACCESS_SYMBOL] = { trace: { destinations: traceDest } };
104
+ error[METADATA_ACCESS_SYMBOL] = traceDestinations;
119
105
  }
120
106
  throw error;
121
107
  }
@@ -356,7 +356,8 @@ describe( 'workflow()', () => {
356
356
 
357
357
  await expect( parentWorkflow( {} ) ).resolves.toEqual( {
358
358
  [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
359
- output: { child: 'ok' }
359
+ output: { child: 'ok' },
360
+ trace: { destinations: {} }
360
361
  } );
361
362
  expect( childFn ).not.toHaveBeenCalled();
362
363
  expect( executeChildMock ).toHaveBeenCalledWith( 'indirect_child_wf', expect.objectContaining( {
@@ -373,30 +374,6 @@ describe( 'workflow()', () => {
373
374
  expect( getTraceDestinations ).toHaveBeenCalledWith( info.memo.traceInfo );
374
375
  } );
375
376
 
376
- it( 'falls back to workflow type matching when replaying an old child call without memo.stack', async () => {
377
- const { workflow } = await import( './workflow.js' );
378
- const { ParentClosePolicy } = await import( '@temporalio/workflow' );
379
- setWorkflowInfo( { workflowType: 'old_parent_wf', memo: { traceInfo: { workflowId: 'root-workflow' } } } );
380
- executeChildMock.mockResolvedValueOnce( { output: { child: 'replayed' } } );
381
- const fn = vi.fn();
382
-
383
- const wf = workflow( workflowDefinition( {
384
- name: 'old_child_wf',
385
- inputSchema: z.object( { id: z.number() } ),
386
- outputSchema: z.object( { child: z.string() } ),
387
- fn
388
- } ) );
389
-
390
- await expect( wf( { id: 7 } ) ).resolves.toEqual( { child: 'replayed' } );
391
- expect( fn ).not.toHaveBeenCalled();
392
- expect( executeChildMock ).toHaveBeenCalledWith( 'old_child_wf', expect.objectContaining( {
393
- args: [ { id: 7 } ],
394
- parentClosePolicy: ParentClosePolicy.TERMINATE,
395
- memo: { traceInfo: { workflowId: 'root-workflow' } }
396
- } ) );
397
- expect( proxyActivitiesMock ).not.toHaveBeenCalled();
398
- } );
399
-
400
377
  it( 'does not fallback to child execution when the replayed workflow type matches an alias', async () => {
401
378
  const { workflow } = await import( './workflow.js' );
402
379
  setWorkflowInfo( { workflowType: 'old_root_wf', memo: {} } );
@@ -429,7 +406,7 @@ describe( 'workflow()', () => {
429
406
  } );
430
407
 
431
408
  describe( 'workflow execution path', () => {
432
- it( 'initializes root memo, gets trace destinations, validates output, and returns an envelope', async () => {
409
+ it( 'initializes root memo, skips trace destinations when trace is disabled, validates output, and returns an envelope', async () => {
433
410
  const { workflow } = await import( './workflow.js' );
434
411
  const getTraceDestinations = vi.fn().mockResolvedValue( activityOutput( { local: '/tmp/root-trace' } ) );
435
412
  const info = setWorkflowInfo( { workflowType: 'root_wf', memo: {} } );
@@ -451,23 +428,17 @@ describe( 'workflow()', () => {
451
428
  await expect( wf( {} ) ).resolves.toEqual( {
452
429
  [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
453
430
  output: { ok: true },
454
- trace: { destinations: { local: '/tmp/root-trace' } }
431
+ trace: { destinations: {} }
455
432
  } );
456
433
  expect( info.memo.stack ).toEqual( [ 'workflow-123' ] );
457
- expect( info.memo.traceInfo ).toEqual( {
458
- workflowId: 'workflow-123',
459
- workflowType: 'root_wf',
460
- runId: 'run-123',
461
- startTime: new Date( '2025-01-01T00:00:00.000Z' ).getTime(),
462
- disableTrace: true
463
- } );
434
+ expect( info.memo.traceInfo ).toBeUndefined();
464
435
  expect( info.memo.activityOptions ).toEqual( expect.objectContaining( {
465
436
  startToCloseTimeout: '5m',
466
437
  heartbeatTimeout: '5m',
467
438
  retry: expect.objectContaining( { maximumAttempts: 5 } )
468
439
  } ) );
469
440
  expect( proxyActivitiesMock ).toHaveBeenCalledWith( info.memo.activityOptions );
470
- expect( getTraceDestinations ).toHaveBeenCalledWith( info.memo.traceInfo );
441
+ expect( getTraceDestinations ).not.toHaveBeenCalled();
471
442
  } );
472
443
 
473
444
  it( 'runs non-root workflow execution without rebuilding trace info or fetching trace destinations', async () => {
@@ -514,10 +485,10 @@ describe( 'workflow()', () => {
514
485
  expect( getTraceDestinations ).not.toHaveBeenCalled();
515
486
  } );
516
487
 
517
- it( 'omits trace from the root result when getTraceDestinations returns no destinations', async () => {
488
+ it( 'returns empty trace destinations when getTraceDestinations returns no destinations', async () => {
518
489
  const { workflow } = await import( './workflow.js' );
519
490
  setWorkflowInfo( { workflowType: 'no_trace_dest_wf' } );
520
- mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( null ) ) } );
491
+ mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( {} ) ) } );
521
492
 
522
493
  const wf = workflow( workflowDefinition( {
523
494
  name: 'no_trace_dest_wf',
@@ -525,46 +496,10 @@ describe( 'workflow()', () => {
525
496
  fn: async () => ( { ok: true } )
526
497
  } ) );
527
498
 
528
- await expect( wf( {} ) ).resolves.toEqual( {
529
- [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
530
- output: { ok: true }
531
- } );
532
- } );
533
-
534
- it( 'supports old unwrapped trace destination activity results during replay', async () => {
535
- const { workflow } = await import( './workflow.js' );
536
- setWorkflowInfo( { workflowType: 'old_trace_payload_wf' } );
537
- mockActivities( {
538
- [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( { local: '/tmp/old-trace' } )
539
- } );
540
-
541
- const wf = workflow( workflowDefinition( {
542
- name: 'old_trace_payload_wf',
543
- outputSchema: z.object( { ok: z.boolean() } ),
544
- fn: async () => ( { ok: true } )
545
- } ) );
546
-
547
499
  await expect( wf( {} ) ).resolves.toEqual( {
548
500
  [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
549
501
  output: { ok: true },
550
- trace: { destinations: { local: '/tmp/old-trace' } }
551
- } );
552
- } );
553
-
554
- it( 'supports old null trace destination activity results during replay', async () => {
555
- const { workflow } = await import( './workflow.js' );
556
- setWorkflowInfo( { workflowType: 'old_null_trace_payload_wf' } );
557
- mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( null ) } );
558
-
559
- const wf = workflow( workflowDefinition( {
560
- name: 'old_null_trace_payload_wf',
561
- outputSchema: z.object( { ok: z.boolean() } ),
562
- fn: async () => ( { ok: true } )
563
- } ) );
564
-
565
- await expect( wf( {} ) ).resolves.toEqual( {
566
- [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
567
- output: { ok: true }
502
+ trace: { destinations: {} }
568
503
  } );
569
504
  } );
570
505
 
@@ -621,7 +556,8 @@ describe( 'workflow()', () => {
621
556
 
622
557
  await expect( wf( {} ) ).resolves.toEqual( {
623
558
  [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
624
- output: { stepResult: 'step-output', evalResult: 'eval-output' }
559
+ output: { stepResult: 'step-output', evalResult: 'eval-output' },
560
+ trace: { destinations: {} }
625
561
  } );
626
562
  expect( step ).toHaveBeenCalledWith( { a: 1 }, { b: 2 } );
627
563
  expect( evaluator ).toHaveBeenCalledWith( { c: 3 } );
@@ -651,39 +587,12 @@ describe( 'workflow()', () => {
651
587
 
652
588
  await expect( wf( {} ) ).resolves.toEqual( {
653
589
  [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
654
- output: { stepResult: 'shared-step-output', evalResult: 'shared-eval-output' }
590
+ output: { stepResult: 'shared-step-output', evalResult: 'shared-eval-output' },
591
+ trace: { destinations: {} }
655
592
  } );
656
593
  expect( sharedStep ).toHaveBeenCalledWith();
657
594
  expect( sharedEvaluator ).toHaveBeenCalledWith( { x: 1 } );
658
595
  } );
659
-
660
- it( 'supports old unwrapped step and evaluator activity results during replay', async () => {
661
- const { workflow } = await import( './workflow.js' );
662
- setWorkflowInfo( { workflowType: 'old_activity_payload_wf' } );
663
- const step = vi.fn().mockResolvedValue( 'legacy-step-output' );
664
- const evaluator = vi.fn().mockResolvedValue( 'legacy-eval-output' );
665
- mockActivities( {
666
- [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( null ) ),
667
- 'old_activity_payload_wf#stepA': step,
668
- 'old_activity_payload_wf#evalA': evaluator
669
- } );
670
-
671
- const wf = workflow( workflowDefinition( {
672
- name: 'old_activity_payload_wf',
673
- outputSchema: z.object( { stepResult: z.string(), evalResult: z.string() } ),
674
- async fn() {
675
- return {
676
- stepResult: await this.invokeStep( 'stepA' ),
677
- evalResult: await this.invokeEvaluator( 'evalA' )
678
- };
679
- }
680
- } ) );
681
-
682
- await expect( wf( {} ) ).resolves.toEqual( {
683
- [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
684
- output: { stepResult: 'legacy-step-output', evalResult: 'legacy-eval-output' }
685
- } );
686
- } );
687
596
  } );
688
597
 
689
598
  describe( 'error handling', () => {
@@ -722,10 +631,10 @@ describe( 'workflow()', () => {
722
631
  expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: { local: '/tmp/trace' } } } );
723
632
  } );
724
633
 
725
- it( 'rethrows root workflow errors without metadata when trace destinations are unavailable', async () => {
634
+ it( 'attaches empty trace destinations to root workflow errors when no destinations are available', async () => {
726
635
  const { workflow } = await import( './workflow.js' );
727
636
  setWorkflowInfo( { workflowType: 'root_error_no_trace_wf' } );
728
- mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( null ) ) } );
637
+ mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( {} ) ) } );
729
638
  const error = new Error( 'root failed without trace' );
730
639
 
731
640
  const wf = workflow( workflowDefinition( {
@@ -736,13 +645,13 @@ describe( 'workflow()', () => {
736
645
  } ) );
737
646
 
738
647
  await expect( wf( {} ) ).rejects.toBe( error );
739
- expect( error[METADATA_ACCESS_SYMBOL] ).toBeUndefined();
648
+ expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: {} } } );
740
649
  } );
741
650
 
742
- it( 'preserves existing error details when trace destinations are unavailable', async () => {
651
+ it( 'preserves existing error details when no trace destinations are available', async () => {
743
652
  const { workflow } = await import( './workflow.js' );
744
653
  setWorkflowInfo( { workflowType: 'root_error_existing_details_no_trace_wf' } );
745
- mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( null ) ) } );
654
+ mockActivities( { [ACTIVITY_GET_TRACE_DESTINATIONS]: vi.fn().mockResolvedValue( activityOutput( {} ) ) } );
746
655
  const error = new Error( 'root failed without trace' );
747
656
  error.details = [ { domain: { reason: 'bad-input' } } ];
748
657
 
@@ -755,7 +664,7 @@ describe( 'workflow()', () => {
755
664
 
756
665
  await expect( wf( {} ) ).rejects.toBe( error );
757
666
  expect( error.details ).toEqual( [ { domain: { reason: 'bad-input' } } ] );
758
- expect( error[METADATA_ACCESS_SYMBOL] ).toBeUndefined();
667
+ expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: {} } } );
759
668
  } );
760
669
 
761
670
  it( 'attaches trace metadata to existing root ApplicationFailure without wrapping it', async () => {
@@ -1,6 +1,6 @@
1
1
  import { FatalError } from '#errors';
2
2
  import { EnvHttpProxyAgent, fetch } from 'undici';
3
- import { serializeFetchResponse, serializeBodyAndInferContentType } from '#helpers/fetch';
3
+ import { serializeResponse, serializeBodyAndInferContentType, hydrateHeaders } from '#helpers/fetch';
4
4
  import { createChildLogger } from '#logger';
5
5
  import { getDestinations } from '#tracing';
6
6
  import { createInternalStep } from '#helpers/component';
@@ -18,17 +18,20 @@ const dispatcher = new EnvHttpProxyAgent( { allowH2: false } );
18
18
  * @param {string} options.url - The target url
19
19
  * @param {string} options.method - The HTTP method
20
20
  * @param {unknown} [options.payload] - The payload to send url
21
- * @param {object} [options.headers] - The headers for the request
21
+ * @param {object} [options.headers] - The headers for the request.
22
22
  * @param {number} [options.timeout] - The timeout for the request (default 30s)
23
+ * @param {object} [options.responseOptions] - Options regarding the response
24
+ * @param {boolean} [options.responseOptions.includeHeaders] - If the response will include the headers - Headers are always redacted (default false)
25
+ * @param {boolean} [options.responseOptions.includeBody] - If the response will include the body (default false)
23
26
  * @returns {object} The serialized HTTP response
24
27
  * @throws {FatalError}
25
28
  */
26
29
  export const sendHttpRequest = createInternalStep( {
27
30
  name: ACTIVITY_SEND_HTTP_REQUEST,
28
- handler: async ( { url, method, payload = undefined, headers = undefined, timeout = 30_000 } ) => {
31
+ handler: async ( { url, method, payload = undefined, headers = undefined, timeout = 30_000, responseOptions = {} } ) => {
29
32
  const args = {
30
33
  method,
31
- headers: new Headers( headers ?? {} ),
34
+ headers: new Headers( hydrateHeaders( headers ) ),
32
35
  signal: AbortSignal.timeout( timeout ),
33
36
  dispatcher
34
37
  };
@@ -57,7 +60,7 @@ export const sendHttpRequest = createInternalStep( {
57
60
  throw new FatalError( `${method} ${url} ${response.status}` );
58
61
  }
59
62
 
60
- return serializeFetchResponse( response );
63
+ return serializeResponse( response, responseOptions );
61
64
  }
62
65
  } );
63
66
 
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { FatalError } from '#errors';
3
3
  import { ACTIVITY_GET_TRACE_DESTINATIONS, ACTIVITY_SEND_HTTP_REQUEST } from '#consts';
4
- import { serializeBodyAndInferContentType, serializeFetchResponse } from '#helpers/fetch';
4
+ import { serializeBodyAndInferContentType, serializeResponse } from '#helpers/fetch';
5
5
  import { getTraceDestinations, sendHttpRequest } from './index.js';
6
6
 
7
7
  const getDestinationsMock = vi.hoisted( () => vi.fn() );
@@ -34,8 +34,9 @@ vi.mock( '#helpers/string', () => ( {
34
34
  } ) );
35
35
 
36
36
  vi.mock( '#helpers/fetch', () => ( {
37
+ hydrateHeaders: vi.fn( headers => headers ?? {} ),
37
38
  serializeBodyAndInferContentType: vi.fn(),
38
- serializeFetchResponse: vi.fn()
39
+ serializeResponse: vi.fn()
39
40
  } ) );
40
41
 
41
42
  const url = 'https://growthx.ai';
@@ -69,7 +70,7 @@ describe( 'internal_activities/sendHttpRequest', () => {
69
70
  beforeEach( async () => {
70
71
  fetchMock.mockReset();
71
72
  serializeBodyAndInferContentType.mockReset();
72
- serializeFetchResponse.mockReset();
73
+ serializeResponse.mockReset();
73
74
  } );
74
75
 
75
76
  it( 'succeeds and returns serialized JSON response', async () => {
@@ -87,7 +88,7 @@ describe( 'internal_activities/sendHttpRequest', () => {
87
88
  contentType: 'application/json; charset=UTF-8'
88
89
  } );
89
90
  const fakeSerialized = { sentinel: true };
90
- serializeFetchResponse.mockResolvedValueOnce( fakeSerialized );
91
+ serializeResponse.mockResolvedValueOnce( fakeSerialized );
91
92
 
92
93
  const result = await sendHttpRequest( { url, method, payload } );
93
94
 
@@ -98,11 +99,26 @@ describe( 'internal_activities/sendHttpRequest', () => {
98
99
  method,
99
100
  dispatcher: expect.any( EnvHttpProxyAgentMock )
100
101
  } ) );
101
- expect( serializeFetchResponse ).toHaveBeenCalledTimes( 1 );
102
- const respArg = serializeFetchResponse.mock.calls[0][0];
102
+ expect( serializeResponse ).toHaveBeenCalledTimes( 1 );
103
+ const respArg = serializeResponse.mock.calls[0][0];
103
104
  expect( respArg && typeof respArg.text ).toBe( 'function' );
104
105
  expect( respArg.status ).toBe( 200 );
105
106
  expect( respArg.headers.get( 'content-type' ) ).toContain( 'application/json' );
107
+ expect( serializeResponse.mock.calls[0][1] ).toEqual( {} );
108
+ expect( result ).toBe( fakeSerialized );
109
+ } );
110
+
111
+ it( 'passes response options to response serialization', async () => {
112
+ const responseOptions = { includeHeaders: true, includeBody: true };
113
+ const fakeSerialized = { sentinel: true };
114
+
115
+ fetchMock.mockResolvedValueOnce( response( { status: 200 } ) );
116
+ serializeResponse.mockResolvedValueOnce( fakeSerialized );
117
+
118
+ const result = await sendHttpRequest( { url, method, responseOptions } );
119
+
120
+ expect( serializeResponse ).toHaveBeenCalledTimes( 1 );
121
+ expect( serializeResponse.mock.calls[0][1] ).toBe( responseOptions );
106
122
  expect( result ).toBe( fakeSerialized );
107
123
  } );
108
124
 
@@ -111,7 +127,7 @@ describe( 'internal_activities/sendHttpRequest', () => {
111
127
 
112
128
  await expect( sendHttpRequest( { url, method } ) ).rejects
113
129
  .toThrow( new FatalError( 'GET https://growthx.ai 500' ) );
114
- expect( serializeFetchResponse ).not.toHaveBeenCalled();
130
+ expect( serializeResponse ).not.toHaveBeenCalled();
115
131
  expect( serializeBodyAndInferContentType ).not.toHaveBeenCalled();
116
132
  } );
117
133
 
@@ -120,7 +136,7 @@ describe( 'internal_activities/sendHttpRequest', () => {
120
136
 
121
137
  await expect( sendHttpRequest( { url, method, timeout: 250 } ) ).rejects
122
138
  .toThrow( new FatalError( 'GET https://growthx.ai The operation was aborted due to timeout' ) );
123
- expect( serializeFetchResponse ).not.toHaveBeenCalled();
139
+ expect( serializeResponse ).not.toHaveBeenCalled();
124
140
  expect( serializeBodyAndInferContentType ).not.toHaveBeenCalled();
125
141
  } );
126
142
 
@@ -131,7 +147,7 @@ describe( 'internal_activities/sendHttpRequest', () => {
131
147
 
132
148
  await expect( sendHttpRequest( { url, method } ) ).rejects
133
149
  .toThrow( new FatalError( 'GET https://growthx.ai Error: getaddrinfo ENOTFOUND nonexistent.example.test' ) );
134
- expect( serializeFetchResponse ).not.toHaveBeenCalled();
150
+ expect( serializeResponse ).not.toHaveBeenCalled();
135
151
  expect( serializeBodyAndInferContentType ).not.toHaveBeenCalled();
136
152
  } );
137
153
 
@@ -142,7 +158,7 @@ describe( 'internal_activities/sendHttpRequest', () => {
142
158
 
143
159
  await expect( sendHttpRequest( { url, method } ) ).rejects
144
160
  .toThrow( new FatalError( 'GET https://growthx.ai Error: connect ECONNREFUSED 127.0.0.1:65500' ) );
145
- expect( serializeFetchResponse ).not.toHaveBeenCalled();
161
+ expect( serializeResponse ).not.toHaveBeenCalled();
146
162
  expect( serializeBodyAndInferContentType ).not.toHaveBeenCalled();
147
163
  } );
148
164
  } );
@@ -157,12 +173,10 @@ describe( 'internal_activities/getTraceDestinations', () => {
157
173
  workflowId: 'workflow-id',
158
174
  runId: 'run-id',
159
175
  workflowType: 'workflow',
160
- startTime: Date.parse( '2026-06-02T09:00:00.000Z' ),
161
- disableTrace: false
176
+ startTime: Date.parse( '2026-06-02T09:00:00.000Z' )
162
177
  };
163
178
  const destinations = {
164
- local: '/tmp/project/logs/runs/workflow/trace.json',
165
- remote: null
179
+ local: '/tmp/project/logs/runs/workflow/trace.json'
166
180
  };
167
181
  getDestinationsMock.mockReturnValueOnce( destinations );
168
182
 
@@ -4,7 +4,6 @@ import { serializeError } from './tools/utils.js';
4
4
  import { isStringboolTrue } from '#helpers/string';
5
5
  import * as localProcessor from './processors/local/index.js';
6
6
  import * as s3Processor from './processors/s3/index.js';
7
- import { ComponentType } from '#consts';
8
7
  import { createChildLogger } from '#logger';
9
8
  import { EventAction } from './trace_consts.js';
10
9
  import { BaseAttribute } from './trace_attribute.js';
@@ -36,9 +35,11 @@ const processors = [
36
35
  * @returns {object} A trace destinations object: { [dest-name]: 'path' }
37
36
  */
38
37
  export const getDestinations = traceInfo =>
39
- processors.reduce( ( o, p ) =>
40
- Object.assign( o, { [p.name.toLowerCase()]: p.enabled && !traceInfo.disableTrace ? p.getDestination( traceInfo ) : null } )
41
- , {} );
38
+ Object.fromEntries(
39
+ processors
40
+ .filter( p => p.enabled )
41
+ .map( p => [ p.name.toLowerCase(), p.getDestination( traceInfo ) ] )
42
+ );
42
43
 
43
44
  /**
44
45
  * Starts processors based on env vars and attach them to the main bus to listen trace events
@@ -69,12 +70,9 @@ const serializeDetails = details => details instanceof Error ? serializeError( d
69
70
  * @returns {void}
70
71
  */
71
72
  export const addEventAction = ( action, { kind, name, id, parentId, details, traceInfo } ) => {
72
- // Ignores internal steps in the actual trace files, ignore trace if the flag is true
73
- if ( kind !== ComponentType.INTERNAL_STEP && !traceInfo.disableTrace ) {
74
- traceBus.emit( 'entry', {
75
- traceInfo,
76
- entry: { kind, action, name, id, parentId, timestamp: Date.now(), details: serializeDetails( details ) }
77
- } );
73
+ if ( traceInfo ) {
74
+ const entry = { kind, action, name, id, parentId, timestamp: Date.now(), details: serializeDetails( details ) };
75
+ traceBus.emit( 'entry', { entry, traceInfo } );
78
76
  }
79
77
  };
80
78
 
@@ -38,8 +38,7 @@ const traceInfo = {
38
38
  workflowId: 'w1',
39
39
  runId: 'r1',
40
40
  workflowType: 'WF',
41
- startTime: 1,
42
- disableTrace: false
41
+ startTime: 1
43
42
  };
44
43
 
45
44
  describe( 'tracing/trace_engine', () => {
@@ -88,19 +87,19 @@ describe( 'tracing/trace_engine', () => {
88
87
  expect( payload.entry.details ).toBe( 'done' );
89
88
  } );
90
89
 
91
- it( 'addEventAction() does not emit when traceInfo.disableTrace is true', async () => {
90
+ it( 'addEventAction() does not emit when traceInfo is absent', async () => {
92
91
  process.env.OUTPUT_TRACE_LOCAL_ON = '1';
93
92
  const { init, addEventAction } = await loadTraceEngine();
94
93
  await init();
95
94
 
96
95
  addEventAction( 'start', {
97
96
  kind: 'step', name: 'X', id: '1', parentId: 'p', details: {},
98
- traceInfo: { ...traceInfo, disableTrace: true }
97
+ traceInfo: undefined
99
98
  } );
100
99
  expect( localExecMock ).not.toHaveBeenCalled();
101
100
  } );
102
101
 
103
- it( 'addEventAction() does not emit when kind is INTERNAL_STEP', async () => {
102
+ it( 'addEventAction() emits when kind is INTERNAL_STEP', async () => {
104
103
  process.env.OUTPUT_TRACE_LOCAL_ON = '1';
105
104
  const { init, addEventAction } = await loadTraceEngine();
106
105
  await init();
@@ -109,7 +108,13 @@ describe( 'tracing/trace_engine', () => {
109
108
  kind: 'internal_step', name: 'Internal', id: '1', parentId: 'p', details: {},
110
109
  traceInfo
111
110
  } );
112
- expect( localExecMock ).not.toHaveBeenCalled();
111
+ expect( localExecMock ).toHaveBeenCalledTimes( 1 );
112
+ expect( localExecMock.mock.calls[0][0].entry ).toMatchObject( {
113
+ kind: 'internal_step',
114
+ name: 'Internal',
115
+ id: '1',
116
+ parentId: 'p'
117
+ } );
113
118
  } );
114
119
 
115
120
  it( 'addEventActionWithContext() uses storage when available', async () => {
@@ -185,11 +190,11 @@ describe( 'tracing/trace_engine', () => {
185
190
  expect( localExecMock ).not.toHaveBeenCalled();
186
191
  } );
187
192
 
188
- it( 'addEventActionWithContext() does not emit when storage traceInfo.disableTrace is true', async () => {
193
+ it( 'addEventActionWithContext() does not emit when storage traceInfo is absent', async () => {
189
194
  process.env.OUTPUT_TRACE_LOCAL_ON = '1';
190
195
  storageLoadMock.mockReturnValue( {
191
196
  parentId: 'ctx-p',
192
- traceInfo: { ...traceInfo, disableTrace: true }
197
+ traceInfo: undefined
193
198
  } );
194
199
  const { init, addEventActionWithContext } = await loadTraceEngine();
195
200
  await init();
@@ -209,20 +214,10 @@ describe( 'tracing/trace_engine', () => {
209
214
  } );
210
215
 
211
216
  describe( 'getDestinations()', () => {
212
- it( 'returns null for both when traces are off (env vars unset)', async () => {
217
+ it( 'returns an empty object when traces are off (env vars unset)', async () => {
213
218
  const { getDestinations } = await loadTraceEngine();
214
219
  const result = getDestinations( traceInfo );
215
- expect( result ).toEqual( { local: null, remote: null } );
216
- expect( localGetDestinationMock ).not.toHaveBeenCalled();
217
- expect( s3GetDestinationMock ).not.toHaveBeenCalled();
218
- } );
219
-
220
- it( 'returns null for both when traceInfo.disableTrace is true', async () => {
221
- process.env.OUTPUT_TRACE_LOCAL_ON = '1';
222
- process.env.OUTPUT_TRACE_REMOTE_ON = '1';
223
- const { getDestinations } = await loadTraceEngine();
224
- const result = getDestinations( { ...traceInfo, disableTrace: true } );
225
- expect( result ).toEqual( { local: null, remote: null } );
220
+ expect( result ).toEqual( {} );
226
221
  expect( localGetDestinationMock ).not.toHaveBeenCalled();
227
222
  expect( s3GetDestinationMock ).not.toHaveBeenCalled();
228
223
  } );
@@ -247,7 +242,7 @@ describe( 'tracing/trace_engine', () => {
247
242
  process.env.OUTPUT_TRACE_REMOTE_ON = '0';
248
243
  const { getDestinations } = await loadTraceEngine();
249
244
  const result = getDestinations( traceInfo );
250
- expect( result ).toEqual( { local: '/local/path.json', remote: null } );
245
+ expect( result ).toEqual( { local: '/local/path.json' } );
251
246
  expect( localGetDestinationMock ).toHaveBeenCalledWith( traceInfo );
252
247
  expect( s3GetDestinationMock ).not.toHaveBeenCalled();
253
248
  } );
@@ -257,7 +252,7 @@ describe( 'tracing/trace_engine', () => {
257
252
  process.env.OUTPUT_TRACE_REMOTE_ON = 'true';
258
253
  const { getDestinations } = await loadTraceEngine();
259
254
  const result = getDestinations( traceInfo );
260
- expect( result ).toEqual( { local: null, remote: 'https://bucket.s3.amazonaws.com/key.json' } );
255
+ expect( result ).toEqual( { remote: 'https://bucket.s3.amazonaws.com/key.json' } );
261
256
  expect( localGetDestinationMock ).not.toHaveBeenCalled();
262
257
  expect( s3GetDestinationMock ).toHaveBeenCalledWith( traceInfo );
263
258
  } );
@@ -16,8 +16,7 @@ const traceInfoMock = vi.hoisted( () => ( {
16
16
  workflowId: 'wf-1',
17
17
  runId: 'run-1',
18
18
  workflowType: 'myWorkflow',
19
- startTime: 1710000000000,
20
- disableTrace: false
19
+ startTime: 1710000000000
21
20
  } ) );
22
21
  const workflowDetailsMock = vi.hoisted( () => ( {
23
22
  workflowId: 'wf-1',