@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 +1 -1
- package/src/helpers/fetch.js +88 -23
- package/src/helpers/fetch.spec.js +159 -32
- package/src/helpers/trace_info.js +2 -5
- package/src/helpers/trace_info.spec.js +4 -6
- package/src/interface/validations/index.spec.js +8 -1
- package/src/interface/validations/schemas.js +5 -1
- package/src/interface/validations/schemas.spec.js +22 -2
- package/src/interface/webhook.d.ts +15 -2
- package/src/interface/webhook.js +6 -3
- package/src/interface/webhook.spec.js +38 -2
- package/src/interface/workflow.js +14 -28
- package/src/interface/workflow.spec.js +19 -110
- package/src/internal_activities/index.js +8 -5
- package/src/internal_activities/index.spec.js +28 -14
- package/src/tracing/trace_engine.js +8 -10
- package/src/tracing/trace_engine.spec.js +17 -22
- package/src/worker/interceptors/activity.spec.js +1 -2
package/package.json
CHANGED
package/src/helpers/fetch.js
CHANGED
|
@@ -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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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,
|
|
3
|
+
import { hydrateHeaders, redactHeaders, serializeBodyAndInferContentType, serializeResponse } from './fetch.js';
|
|
4
4
|
|
|
5
|
-
describe( '
|
|
6
|
-
it( '
|
|
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
|
|
15
|
-
|
|
16
|
-
expect( result.
|
|
17
|
-
|
|
18
|
-
|
|
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( '
|
|
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': '
|
|
164
|
+
headers: { 'content-type': 'Text/Plain; charset=utf-8' }
|
|
28
165
|
} );
|
|
29
166
|
|
|
30
|
-
const result = await
|
|
31
|
-
|
|
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( '
|
|
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
|
|
55
|
-
|
|
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( '
|
|
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
|
|
72
|
-
|
|
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
|
|
85
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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( '
|
|
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
|
|
32
|
+
headers?: Record<string, string>,
|
|
22
33
|
|
|
23
34
|
/** Response body, either JSON, text, or an arrayBuffer converted to base64 */
|
|
24
|
-
body
|
|
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>;
|
package/src/interface/webhook.js
CHANGED
|
@@ -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( {
|
|
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( {
|
|
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,
|
|
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
|
|
85
|
-
if ( isRoot ) {
|
|
86
|
-
memo.traceInfo = TraceInfo.build(
|
|
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
|
|
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 ) =>
|
|
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 (
|
|
102
|
+
if ( traceDestinations ) {
|
|
117
103
|
// Append the trace destinations so it is carried to interceptor
|
|
118
|
-
error[METADATA_ACCESS_SYMBOL] =
|
|
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,
|
|
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: {
|
|
431
|
+
trace: { destinations: {} }
|
|
455
432
|
} );
|
|
456
433
|
expect( info.memo.stack ).toEqual( [ 'workflow-123' ] );
|
|
457
|
-
expect( info.memo.traceInfo ).
|
|
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 ).
|
|
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( '
|
|
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(
|
|
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: {
|
|
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( '
|
|
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(
|
|
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] ).
|
|
648
|
+
expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( { trace: { destinations: {} } } );
|
|
740
649
|
} );
|
|
741
650
|
|
|
742
|
-
it( 'preserves existing error details when trace destinations are
|
|
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(
|
|
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] ).
|
|
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 {
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
102
|
-
const respArg =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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:
|
|
97
|
+
traceInfo: undefined
|
|
99
98
|
} );
|
|
100
99
|
expect( localExecMock ).not.toHaveBeenCalled();
|
|
101
100
|
} );
|
|
102
101
|
|
|
103
|
-
it( 'addEventAction()
|
|
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 ).
|
|
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
|
|
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:
|
|
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
|
|
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( {
|
|
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'
|
|
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( {
|
|
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',
|