@outputai/core 0.2.1-next.380dff4.0 → 0.2.1-next.7bd2d4e.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/activity_integration/tracing.d.ts +30 -17
- package/src/activity_integration/tracing.js +33 -17
- package/src/tracing/internal_interface.js +34 -28
- package/src/tracing/processors/local/index.js +43 -10
- package/src/tracing/processors/local/index.spec.js +66 -36
- package/src/tracing/processors/s3/index.js +10 -5
- package/src/tracing/processors/s3/index.spec.js +7 -7
- package/src/tracing/tools/build_trace_tree.js +17 -12
- package/src/tracing/tools/build_trace_tree.spec.js +63 -20
- package/src/tracing/tools/utils.js +28 -0
- package/src/tracing/tools/utils.spec.js +134 -2
- package/src/tracing/trace_consts.js +9 -0
- package/src/tracing/trace_engine.js +10 -10
- package/src/tracing/trace_engine.spec.js +23 -23
- package/src/worker/configs.js +9 -1
- package/src/worker/index.js +8 -2
- package/src/worker/index.spec.js +7 -1
- package/src/worker/proxy.js +32 -0
- package/src/worker/proxy.spec.js +71 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventAction } from '../trace_consts.js';
|
|
1
2
|
/**
|
|
2
3
|
* @typedef {object} NodeEntry
|
|
3
4
|
* @property {string} id
|
|
@@ -27,19 +28,21 @@ const createEntry = id => ( {
|
|
|
27
28
|
input: undefined,
|
|
28
29
|
output: undefined,
|
|
29
30
|
error: undefined,
|
|
30
|
-
children: []
|
|
31
|
+
children: [],
|
|
32
|
+
attributes: {}
|
|
31
33
|
} );
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
*
|
|
36
|
+
* Builds a tree of nodes from a list of entries.
|
|
35
37
|
*
|
|
36
38
|
* Each node will have: id, name, kind, children, input, output or error, startedAt, endedAt.
|
|
37
39
|
*
|
|
38
|
-
* Entries with same id
|
|
39
|
-
* - The details of the
|
|
40
|
-
* - The details of the
|
|
41
|
-
* - The details of the
|
|
42
|
-
* -
|
|
40
|
+
* Entries with the same id are combined according to their actions.
|
|
41
|
+
* - The details of the START action become input, and timestamp becomes startedAt;
|
|
42
|
+
* - The details of the END action become output, timestamp becomes endedAt;
|
|
43
|
+
* - The details of the ERROR action become error, timestamp becomes endedAt;
|
|
44
|
+
* - The details of the ADD_ATTR action are attached to `.attributes`;
|
|
45
|
+
* - Only the START action's `kind` and `name` fields are used;
|
|
43
46
|
*
|
|
44
47
|
*
|
|
45
48
|
* Children are added according to the parentId of each entry.
|
|
@@ -53,18 +56,20 @@ export default entries => {
|
|
|
53
56
|
const ensureNode = id => nodes.get( id ) ?? nodes.set( id, createEntry( id ) ).get( id );
|
|
54
57
|
|
|
55
58
|
for ( const entry of entries ) {
|
|
56
|
-
const { kind, id, name, parentId, details,
|
|
59
|
+
const { kind, id, name, parentId, details, action, timestamp } = entry;
|
|
57
60
|
const node = ensureNode( id );
|
|
58
61
|
|
|
59
|
-
if (
|
|
62
|
+
if ( action === EventAction.START ) {
|
|
60
63
|
Object.assign( node, { input: details, startedAt: timestamp, kind, name } );
|
|
61
|
-
} else if (
|
|
64
|
+
} else if ( action === EventAction.ADD_ATTR ) {
|
|
65
|
+
node.attributes[details.name] = details.value;
|
|
66
|
+
} else if ( action === EventAction.END ) {
|
|
62
67
|
Object.assign( node, { output: details, endedAt: timestamp } );
|
|
63
|
-
} else if (
|
|
68
|
+
} else if ( action === EventAction.ERROR ) {
|
|
64
69
|
Object.assign( node, { error: details, endedAt: timestamp } );
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
if ( parentId &&
|
|
72
|
+
if ( parentId && action === EventAction.START ) {
|
|
68
73
|
const parent = ensureNode( parentId );
|
|
69
74
|
parent.children.push( node );
|
|
70
75
|
parent.children.sort( ( a, b ) => a.startedAt - b.startedAt );
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { EventAction } from '../trace_consts.js';
|
|
2
3
|
import buildTraceTree from './build_trace_tree.js';
|
|
3
4
|
|
|
4
5
|
describe( 'build_trace_tree', () => {
|
|
@@ -6,9 +7,9 @@ describe( 'build_trace_tree', () => {
|
|
|
6
7
|
expect( buildTraceTree( [] ) ).toBeNull();
|
|
7
8
|
} );
|
|
8
9
|
|
|
9
|
-
it( 'sets root output with a fixed message when workflow has no end/error
|
|
10
|
+
it( 'sets root output with a fixed message when workflow has no end/error action yet', () => {
|
|
10
11
|
const entries = [
|
|
11
|
-
{ kind: 'workflow', id: 'wf', parentId: undefined,
|
|
12
|
+
{ kind: 'workflow', id: 'wf', parentId: undefined, action: EventAction.START, name: 'wf', details: {}, timestamp: 1000 }
|
|
12
13
|
];
|
|
13
14
|
const result = buildTraceTree( entries );
|
|
14
15
|
expect( result ).not.toBeNull();
|
|
@@ -19,17 +20,52 @@ this can indicate it timed out or was interrupted.>>' );
|
|
|
19
20
|
|
|
20
21
|
it( 'returns null when there is no root (all entries have parentId)', () => {
|
|
21
22
|
const entries = [
|
|
22
|
-
{ id: 'a', parentId: 'x',
|
|
23
|
-
{ id: 'b', parentId: 'a',
|
|
23
|
+
{ id: 'a', parentId: 'x', action: EventAction.START, name: 'a', timestamp: 1 },
|
|
24
|
+
{ id: 'b', parentId: 'a', action: EventAction.START, name: 'b', timestamp: 2 }
|
|
24
25
|
];
|
|
25
26
|
expect( buildTraceTree( entries ) ).toBeNull();
|
|
26
27
|
} );
|
|
27
28
|
|
|
28
|
-
it( '
|
|
29
|
+
it( 'add_attr action merges details.name and details.value into node.attributes', () => {
|
|
29
30
|
const entries = [
|
|
30
|
-
{ kind: '
|
|
31
|
-
{ kind: 'step', id: 's', parentId: '
|
|
32
|
-
{ id: 's',
|
|
31
|
+
{ kind: 'workflow', id: 'wf', parentId: undefined, action: EventAction.START, name: 'wf', details: {}, timestamp: 100 },
|
|
32
|
+
{ kind: 'step', id: 's', parentId: 'wf', action: EventAction.START, name: 'step', details: {}, timestamp: 200 },
|
|
33
|
+
{ id: 's', action: EventAction.ADD_ATTR, details: { name: 'latency_ms', value: 42 }, timestamp: 250 },
|
|
34
|
+
{ id: 's', action: EventAction.ADD_ATTR, details: { name: 'retries', value: 1 }, timestamp: 260 },
|
|
35
|
+
{ id: 'wf', action: EventAction.END, details: {}, timestamp: 300 }
|
|
36
|
+
];
|
|
37
|
+
const result = buildTraceTree( entries );
|
|
38
|
+
expect( result ).not.toBeNull();
|
|
39
|
+
expect( result.children[0].attributes ).toEqual( { latency_ms: 42, retries: 1 } );
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
it( 'add_attr action overwrites prior value for the same attribute name', () => {
|
|
43
|
+
const entries = [
|
|
44
|
+
{ kind: 'workflow', id: 'wf', parentId: undefined, action: EventAction.START, name: 'wf', details: {}, timestamp: 1 },
|
|
45
|
+
{ id: 'wf', action: EventAction.ADD_ATTR, details: { name: 'x', value: 1 }, timestamp: 2 },
|
|
46
|
+
{ id: 'wf', action: EventAction.ADD_ATTR, details: { name: 'x', value: 2 }, timestamp: 3 },
|
|
47
|
+
{ id: 'wf', action: EventAction.END, details: {}, timestamp: 4 }
|
|
48
|
+
];
|
|
49
|
+
const result = buildTraceTree( entries );
|
|
50
|
+
expect( result.attributes ).toEqual( { x: 2 } );
|
|
51
|
+
} );
|
|
52
|
+
|
|
53
|
+
it( 'add_attr does not attach nodes as children (only start does)', () => {
|
|
54
|
+
const entries = [
|
|
55
|
+
{ kind: 'workflow', id: 'wf', parentId: undefined, action: EventAction.START, name: 'wf', details: {}, timestamp: 1 },
|
|
56
|
+
{ id: 'orphan', parentId: 'wf', action: EventAction.ADD_ATTR, details: { name: 'k', value: 'v' }, timestamp: 2 },
|
|
57
|
+
{ id: 'wf', action: EventAction.END, details: {}, timestamp: 3 }
|
|
58
|
+
];
|
|
59
|
+
const result = buildTraceTree( entries );
|
|
60
|
+
expect( result.children ).toHaveLength( 0 );
|
|
61
|
+
expect( result.attributes ).toEqual( {} );
|
|
62
|
+
} );
|
|
63
|
+
|
|
64
|
+
it( 'error action sets error and endedAt on node', () => {
|
|
65
|
+
const entries = [
|
|
66
|
+
{ kind: 'wf', id: 'r', parentId: undefined, action: EventAction.START, name: 'root', details: {}, timestamp: 100 },
|
|
67
|
+
{ kind: 'step', id: 's', parentId: 'r', action: EventAction.START, name: 'step', details: {}, timestamp: 200 },
|
|
68
|
+
{ id: 's', action: EventAction.ERROR, details: { message: 'failed' }, timestamp: 300 }
|
|
33
69
|
];
|
|
34
70
|
const result = buildTraceTree( entries );
|
|
35
71
|
expect( result ).not.toBeNull();
|
|
@@ -41,27 +77,28 @@ this can indicate it timed out or was interrupted.>>' );
|
|
|
41
77
|
it( 'builds a tree from workflow/step/IO entries with grouping and sorting', () => {
|
|
42
78
|
const entries = [
|
|
43
79
|
// workflow start
|
|
44
|
-
{ kind: 'workflow',
|
|
80
|
+
{ kind: 'workflow', action: EventAction.START, name: 'wf', id: 'wf', parentId: undefined, details: { a: 1 }, timestamp: 1000 },
|
|
45
81
|
// evaluator start/stop
|
|
46
|
-
{ kind: 'evaluator',
|
|
47
|
-
{ id: 'eval',
|
|
82
|
+
{ kind: 'evaluator', action: EventAction.START, name: 'eval', id: 'eval', parentId: 'wf', details: { z: 0 }, timestamp: 1500 },
|
|
83
|
+
{ id: 'eval', action: EventAction.END, details: { z: 1 }, timestamp: 1600 },
|
|
48
84
|
// step1 start
|
|
49
|
-
{ kind: 'step',
|
|
85
|
+
{ kind: 'step', action: EventAction.START, name: 'step-1', id: 's1', parentId: 'wf', details: { x: 1 }, timestamp: 2000 },
|
|
86
|
+
{ id: 's1', action: EventAction.ADD_ATTR, details: { name: 'step_tag', value: 'alpha' }, timestamp: 2050 },
|
|
50
87
|
// IO under step1
|
|
51
|
-
{ kind: 'IO',
|
|
88
|
+
{ kind: 'IO', action: EventAction.START, name: 'test-1', id: 'io1', parentId: 's1', details: { y: 2 }, timestamp: 2300 },
|
|
52
89
|
// step2 start
|
|
53
|
-
{ kind: 'step',
|
|
90
|
+
{ kind: 'step', action: EventAction.START, name: 'step-2', id: 's2', parentId: 'wf', details: { x: 2 }, timestamp: 2400 },
|
|
54
91
|
// IO under step2
|
|
55
|
-
{ kind: 'IO',
|
|
56
|
-
{ id: 'io2',
|
|
92
|
+
{ kind: 'IO', action: EventAction.START, name: 'test-2', id: 'io2', parentId: 's2', details: { y: 3 }, timestamp: 2500 },
|
|
93
|
+
{ id: 'io2', action: EventAction.END, details: { y: 4 }, timestamp: 2600 },
|
|
57
94
|
// IO under step1 ends
|
|
58
|
-
{ id: 'io1',
|
|
95
|
+
{ id: 'io1', action: EventAction.END, details: { y: 5 }, timestamp: 2700 },
|
|
59
96
|
// step1 end
|
|
60
|
-
{ id: 's1',
|
|
97
|
+
{ id: 's1', action: EventAction.END, details: { done: true }, timestamp: 2800 },
|
|
61
98
|
// step2 end
|
|
62
|
-
{ id: 's2',
|
|
99
|
+
{ id: 's2', action: EventAction.END, details: { done: true }, timestamp: 2900 },
|
|
63
100
|
// workflow end
|
|
64
|
-
{ id: 'wf',
|
|
101
|
+
{ id: 'wf', action: EventAction.END, details: { ok: true }, timestamp: 3000 }
|
|
65
102
|
];
|
|
66
103
|
|
|
67
104
|
const result = buildTraceTree( entries );
|
|
@@ -74,6 +111,7 @@ this can indicate it timed out or was interrupted.>>' );
|
|
|
74
111
|
endedAt: 3000,
|
|
75
112
|
input: { a: 1 },
|
|
76
113
|
output: { ok: true },
|
|
114
|
+
attributes: {},
|
|
77
115
|
children: [
|
|
78
116
|
{
|
|
79
117
|
id: 'eval',
|
|
@@ -83,6 +121,7 @@ this can indicate it timed out or was interrupted.>>' );
|
|
|
83
121
|
endedAt: 1600,
|
|
84
122
|
input: { z: 0 },
|
|
85
123
|
output: { z: 1 },
|
|
124
|
+
attributes: {},
|
|
86
125
|
children: []
|
|
87
126
|
},
|
|
88
127
|
{
|
|
@@ -93,6 +132,7 @@ this can indicate it timed out or was interrupted.>>' );
|
|
|
93
132
|
endedAt: 2800,
|
|
94
133
|
input: { x: 1 },
|
|
95
134
|
output: { done: true },
|
|
135
|
+
attributes: { step_tag: 'alpha' },
|
|
96
136
|
children: [
|
|
97
137
|
{
|
|
98
138
|
id: 'io1',
|
|
@@ -102,6 +142,7 @@ this can indicate it timed out or was interrupted.>>' );
|
|
|
102
142
|
endedAt: 2700,
|
|
103
143
|
input: { y: 2 },
|
|
104
144
|
output: { y: 5 },
|
|
145
|
+
attributes: {},
|
|
105
146
|
children: []
|
|
106
147
|
}
|
|
107
148
|
]
|
|
@@ -114,6 +155,7 @@ this can indicate it timed out or was interrupted.>>' );
|
|
|
114
155
|
endedAt: 2900,
|
|
115
156
|
input: { x: 2 },
|
|
116
157
|
output: { done: true },
|
|
158
|
+
attributes: {},
|
|
117
159
|
children: [
|
|
118
160
|
{
|
|
119
161
|
id: 'io2',
|
|
@@ -123,6 +165,7 @@ this can indicate it timed out or was interrupted.>>' );
|
|
|
123
165
|
endedAt: 2600,
|
|
124
166
|
input: { y: 3 },
|
|
125
167
|
output: { y: 4 },
|
|
168
|
+
attributes: {},
|
|
126
169
|
children: []
|
|
127
170
|
}
|
|
128
171
|
]
|
|
@@ -19,3 +19,31 @@ export const serializeError = error =>
|
|
|
19
19
|
message: error.message,
|
|
20
20
|
stack: error.stack
|
|
21
21
|
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tries to stringify an object to an indented JSON string.
|
|
25
|
+
* If its byte size is bigger than threshold returns a plain JSON string without formatting.
|
|
26
|
+
*
|
|
27
|
+
* @param {object|array} content
|
|
28
|
+
* @param {*} [threshold] - The max allowed size to try to stringify with formatting (in bytes). Default is 50mb
|
|
29
|
+
* @returns {string} String representation of the object
|
|
30
|
+
*/
|
|
31
|
+
export const safeFormatJSON = ( content, threshold = 50 * 1024 * 1024 /* 50mb */ ) => {
|
|
32
|
+
const plainString = JSON.stringify( content );
|
|
33
|
+
const plainStringSize = Buffer.byteLength( plainString, 'utf8' );
|
|
34
|
+
|
|
35
|
+
if ( plainStringSize > threshold ) {
|
|
36
|
+
return plainString;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify( content, undefined, 2 );
|
|
40
|
+
} catch ( error ) {
|
|
41
|
+
// Only handles this specific error because other common parsing errors like:
|
|
42
|
+
// "TypeError: cyclic object value" and "RangeError: Maximum call stack size exceeded"
|
|
43
|
+
// would have been thrown on the first parsing.
|
|
44
|
+
if ( error instanceof RangeError && error.message === 'Invalid string length' ) {
|
|
45
|
+
return plainString;
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { serializeError } from './utils.js';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { safeFormatJSON, serializeError } from './utils.js';
|
|
3
|
+
|
|
4
|
+
const isPrettyStringifyCall = args => args.length >= 3 && args[2] === 2;
|
|
5
|
+
|
|
6
|
+
/** @param {number} targetBytes UTF-8 size of compact JSON.stringify( { a: "<xs>" } ) */
|
|
7
|
+
const objectWithCompactByteLength = targetBytes => {
|
|
8
|
+
const sample = { a: '' };
|
|
9
|
+
const overhead = Buffer.byteLength( JSON.stringify( sample ), 'utf8' );
|
|
10
|
+
const repeat = Math.max( 0, targetBytes - overhead );
|
|
11
|
+
return { a: 'x'.repeat( repeat ) };
|
|
12
|
+
};
|
|
3
13
|
|
|
4
14
|
describe( 'tracing/utils', () => {
|
|
5
15
|
it( 'serializeError unwraps causes and keeps message/stack', () => {
|
|
@@ -11,4 +21,126 @@ describe( 'tracing/utils', () => {
|
|
|
11
21
|
expect( out.message ).toBe( 'inner' );
|
|
12
22
|
expect( typeof out.stack ).toBe( 'string' );
|
|
13
23
|
} );
|
|
24
|
+
|
|
25
|
+
describe( 'safeFormatJSON', () => {
|
|
26
|
+
it( 'formats small objects with indentation when under threshold', () => {
|
|
27
|
+
const content = { a: 1, b: [ 2, 3 ] };
|
|
28
|
+
const out = safeFormatJSON( content, 10_000 );
|
|
29
|
+
|
|
30
|
+
expect( out ).toContain( '\n' );
|
|
31
|
+
expect( out ).toMatch( /^\{\n/ );
|
|
32
|
+
expect( JSON.parse( out ) ).toEqual( content );
|
|
33
|
+
} );
|
|
34
|
+
|
|
35
|
+
it( 'formats small arrays with indentation when under threshold', () => {
|
|
36
|
+
const content = [ 1, { nested: true } ];
|
|
37
|
+
const out = safeFormatJSON( content, 10_000 );
|
|
38
|
+
|
|
39
|
+
expect( out ).toContain( '\n' );
|
|
40
|
+
expect( out.trimStart() ).toMatch( /^\[/ );
|
|
41
|
+
expect( JSON.parse( out ) ).toEqual( content );
|
|
42
|
+
} );
|
|
43
|
+
|
|
44
|
+
it( 'returns compact JSON when compact UTF-8 size is strictly greater than threshold', () => {
|
|
45
|
+
const content = objectWithCompactByteLength( 40 );
|
|
46
|
+
const compact = JSON.stringify( content );
|
|
47
|
+
expect( Buffer.byteLength( compact, 'utf8' ) ).toBe( 40 );
|
|
48
|
+
|
|
49
|
+
const out = safeFormatJSON( content, 39 );
|
|
50
|
+
expect( out ).toBe( compact );
|
|
51
|
+
expect( out ).not.toContain( '\n ' );
|
|
52
|
+
expect( JSON.parse( out ) ).toEqual( content );
|
|
53
|
+
} );
|
|
54
|
+
|
|
55
|
+
it( 'uses pretty JSON when compact UTF-8 size equals threshold', () => {
|
|
56
|
+
const content = objectWithCompactByteLength( 40 );
|
|
57
|
+
const compact = JSON.stringify( content );
|
|
58
|
+
expect( Buffer.byteLength( compact, 'utf8' ) ).toBe( 40 );
|
|
59
|
+
|
|
60
|
+
const out = safeFormatJSON( content, 40 );
|
|
61
|
+
expect( out ).not.toBe( compact );
|
|
62
|
+
expect( out ).toContain( '\n' );
|
|
63
|
+
expect( JSON.parse( out ) ).toEqual( content );
|
|
64
|
+
} );
|
|
65
|
+
|
|
66
|
+
it( 'uses UTF-8 byte length for threshold, not JavaScript string length', () => {
|
|
67
|
+
const content = { label: 'éclair' };
|
|
68
|
+
const compact = JSON.stringify( content );
|
|
69
|
+
expect( compact.length ).toBeLessThan( Buffer.byteLength( compact, 'utf8' ) );
|
|
70
|
+
|
|
71
|
+
const bytes = Buffer.byteLength( compact, 'utf8' );
|
|
72
|
+
const outCompact = safeFormatJSON( content, bytes - 1 );
|
|
73
|
+
expect( outCompact ).toBe( compact );
|
|
74
|
+
|
|
75
|
+
const outPretty = safeFormatJSON( content, bytes + 100 );
|
|
76
|
+
expect( outPretty ).toContain( '\n' );
|
|
77
|
+
expect( JSON.parse( outPretty ) ).toEqual( content );
|
|
78
|
+
} );
|
|
79
|
+
|
|
80
|
+
it( 'round-trips empty object and primitives for both branches', () => {
|
|
81
|
+
const tiny = {};
|
|
82
|
+
const pretty = safeFormatJSON( tiny, 100 );
|
|
83
|
+
expect( JSON.parse( pretty ) ).toEqual( tiny );
|
|
84
|
+
|
|
85
|
+
const forcedCompact = safeFormatJSON( tiny, 0 );
|
|
86
|
+
expect( JSON.parse( forcedCompact ) ).toEqual( tiny );
|
|
87
|
+
} );
|
|
88
|
+
|
|
89
|
+
it( 'returns compact JSON when pretty stringify throws Invalid string length', () => {
|
|
90
|
+
const content = { a: 1 };
|
|
91
|
+
const compact = JSON.stringify( content );
|
|
92
|
+
const origStringify = JSON.stringify.bind( JSON );
|
|
93
|
+
|
|
94
|
+
const spy = vi.spyOn( JSON, 'stringify' ).mockImplementation( ( ...args ) => {
|
|
95
|
+
if ( isPrettyStringifyCall( args ) ) {
|
|
96
|
+
throw new RangeError( 'Invalid string length' );
|
|
97
|
+
}
|
|
98
|
+
return origStringify( ...args );
|
|
99
|
+
} );
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const out = safeFormatJSON( content, 10_000 );
|
|
103
|
+
expect( out ).toBe( compact );
|
|
104
|
+
expect( JSON.parse( out ) ).toEqual( content );
|
|
105
|
+
} finally {
|
|
106
|
+
spy.mockRestore();
|
|
107
|
+
}
|
|
108
|
+
} );
|
|
109
|
+
|
|
110
|
+
it( 'rethrows RangeError when message is not Invalid string length', () => {
|
|
111
|
+
const content = { a: 1 };
|
|
112
|
+
const origStringify = JSON.stringify.bind( JSON );
|
|
113
|
+
|
|
114
|
+
const spy = vi.spyOn( JSON, 'stringify' ).mockImplementation( ( ...args ) => {
|
|
115
|
+
if ( isPrettyStringifyCall( args ) ) {
|
|
116
|
+
throw new RangeError( 'not the string length error' );
|
|
117
|
+
}
|
|
118
|
+
return origStringify( ...args );
|
|
119
|
+
} );
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
expect( () => safeFormatJSON( content, 10_000 ) ).toThrow( RangeError );
|
|
123
|
+
} finally {
|
|
124
|
+
spy.mockRestore();
|
|
125
|
+
}
|
|
126
|
+
} );
|
|
127
|
+
|
|
128
|
+
it( 'rethrows non-RangeError from pretty stringify', () => {
|
|
129
|
+
const content = { a: 1 };
|
|
130
|
+
const origStringify = JSON.stringify.bind( JSON );
|
|
131
|
+
|
|
132
|
+
const spy = vi.spyOn( JSON, 'stringify' ).mockImplementation( ( ...args ) => {
|
|
133
|
+
if ( isPrettyStringifyCall( args ) ) {
|
|
134
|
+
throw new TypeError( 'cyclic structure' );
|
|
135
|
+
}
|
|
136
|
+
return origStringify( ...args );
|
|
137
|
+
} );
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
expect( () => safeFormatJSON( content, 10_000 ) ).toThrow( TypeError );
|
|
141
|
+
} finally {
|
|
142
|
+
spy.mockRestore();
|
|
143
|
+
}
|
|
144
|
+
} );
|
|
145
|
+
} );
|
|
14
146
|
} );
|
|
@@ -64,34 +64,34 @@ export const init = async () => {
|
|
|
64
64
|
const serializeDetails = details => details instanceof Error ? serializeError( details ) : details;
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
*
|
|
67
|
+
* Emits an event action to the event bus.
|
|
68
68
|
*
|
|
69
|
-
* @param {string}
|
|
69
|
+
* @param {string} action - The action
|
|
70
70
|
* @param {object} fields - All the trace fields
|
|
71
71
|
* @returns {void}
|
|
72
72
|
*/
|
|
73
|
-
export const
|
|
73
|
+
export const addEventAction = ( action, { kind, name, id, parentId, details, executionContext } ) => {
|
|
74
74
|
// Ignores internal steps in the actual trace files, ignore trace if the flag is true
|
|
75
75
|
if ( kind !== ComponentType.INTERNAL_STEP && !executionContext.disableTrace ) {
|
|
76
76
|
traceBus.emit( 'entry', {
|
|
77
77
|
executionContext,
|
|
78
|
-
entry: { kind,
|
|
78
|
+
entry: { kind, action, name, id, parentId, timestamp: Date.now(), details: serializeDetails( details ) }
|
|
79
79
|
} );
|
|
80
80
|
}
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
*
|
|
84
|
+
* Attaches contextual information to an event action before calling the method to emit it to the bus.
|
|
85
85
|
*
|
|
86
|
-
* This function
|
|
87
|
-
* so it is safe to
|
|
86
|
+
* This function has no effect if called outside a Temporal Workflow/Activity environment,
|
|
87
|
+
* so it is safe to use in unit tests or dependencies that might be used elsewhere.
|
|
88
88
|
*
|
|
89
89
|
* @param {object} options - The common trace configurations
|
|
90
90
|
*/
|
|
91
|
-
export function
|
|
91
|
+
export function addEventActionWithContext( action, options ) {
|
|
92
92
|
const storeContent = Storage.load();
|
|
93
|
-
if ( storeContent ) { // If there is no storageContext this was not called from
|
|
93
|
+
if ( storeContent ) { // If there is no storageContext this was not called from a Temporal environment
|
|
94
94
|
const { parentId, executionContext } = storeContent;
|
|
95
|
-
|
|
95
|
+
addEventAction( action, { ...options, parentId, executionContext } );
|
|
96
96
|
}
|
|
97
97
|
};
|
|
@@ -39,7 +39,7 @@ describe( 'tracing/trace_engine', () => {
|
|
|
39
39
|
it( 'init() starts only enabled processors and attaches listeners', async () => {
|
|
40
40
|
process.env.OUTPUT_TRACE_LOCAL_ON = '1';
|
|
41
41
|
process.env.OUTPUT_TRACE_REMOTE_ON = '0';
|
|
42
|
-
const { init,
|
|
42
|
+
const { init, addEventAction } = await loadTraceEngine();
|
|
43
43
|
|
|
44
44
|
await init();
|
|
45
45
|
|
|
@@ -47,96 +47,96 @@ describe( 'tracing/trace_engine', () => {
|
|
|
47
47
|
expect( s3InitMock ).not.toHaveBeenCalled();
|
|
48
48
|
|
|
49
49
|
const executionContext = { disableTrace: false };
|
|
50
|
-
|
|
50
|
+
addEventAction( 'start', {
|
|
51
51
|
kind: 'step', name: 'N', id: '1', parentId: 'p', details: { ok: true }, executionContext
|
|
52
52
|
} );
|
|
53
53
|
expect( localExecMock ).toHaveBeenCalledTimes( 1 );
|
|
54
54
|
const payload = localExecMock.mock.calls[0][0];
|
|
55
55
|
expect( payload.entry.name ).toBe( 'N' );
|
|
56
56
|
expect( payload.entry.kind ).toBe( 'step' );
|
|
57
|
-
expect( payload.entry.
|
|
57
|
+
expect( payload.entry.action ).toBe( 'start' );
|
|
58
58
|
expect( payload.entry.details ).toEqual( { ok: true } );
|
|
59
59
|
expect( payload.executionContext ).toBe( executionContext );
|
|
60
60
|
} );
|
|
61
61
|
|
|
62
|
-
it( '
|
|
62
|
+
it( 'addEventAction() emits an entry consumed by processors', async () => {
|
|
63
63
|
process.env.OUTPUT_TRACE_LOCAL_ON = 'on';
|
|
64
|
-
const { init,
|
|
64
|
+
const { init, addEventAction } = await loadTraceEngine();
|
|
65
65
|
await init();
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
addEventAction( 'end', {
|
|
68
68
|
kind: 'workflow', name: 'W', id: '2', parentId: 'p2', details: 'done',
|
|
69
69
|
executionContext: { disableTrace: false }
|
|
70
70
|
} );
|
|
71
71
|
expect( localExecMock ).toHaveBeenCalledTimes( 1 );
|
|
72
72
|
const payload = localExecMock.mock.calls[0][0];
|
|
73
73
|
expect( payload.entry.name ).toBe( 'W' );
|
|
74
|
-
expect( payload.entry.
|
|
74
|
+
expect( payload.entry.action ).toBe( 'end' );
|
|
75
75
|
expect( payload.entry.details ).toBe( 'done' );
|
|
76
76
|
} );
|
|
77
77
|
|
|
78
|
-
it( '
|
|
78
|
+
it( 'addEventAction() does not emit when executionContext.disableTrace is true', async () => {
|
|
79
79
|
process.env.OUTPUT_TRACE_LOCAL_ON = '1';
|
|
80
|
-
const { init,
|
|
80
|
+
const { init, addEventAction } = await loadTraceEngine();
|
|
81
81
|
await init();
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
addEventAction( 'start', {
|
|
84
84
|
kind: 'step', name: 'X', id: '1', parentId: 'p', details: {},
|
|
85
85
|
executionContext: { disableTrace: true }
|
|
86
86
|
} );
|
|
87
87
|
expect( localExecMock ).not.toHaveBeenCalled();
|
|
88
88
|
} );
|
|
89
89
|
|
|
90
|
-
it( '
|
|
90
|
+
it( 'addEventAction() does not emit when kind is INTERNAL_STEP', async () => {
|
|
91
91
|
process.env.OUTPUT_TRACE_LOCAL_ON = '1';
|
|
92
|
-
const { init,
|
|
92
|
+
const { init, addEventAction } = await loadTraceEngine();
|
|
93
93
|
await init();
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
addEventAction( 'start', {
|
|
96
96
|
kind: 'internal_step', name: 'Internal', id: '1', parentId: 'p', details: {},
|
|
97
97
|
executionContext: { disableTrace: false }
|
|
98
98
|
} );
|
|
99
99
|
expect( localExecMock ).not.toHaveBeenCalled();
|
|
100
100
|
} );
|
|
101
101
|
|
|
102
|
-
it( '
|
|
102
|
+
it( 'addEventActionWithContext() uses storage when available', async () => {
|
|
103
103
|
process.env.OUTPUT_TRACE_LOCAL_ON = 'true';
|
|
104
104
|
storageLoadMock.mockReturnValue( {
|
|
105
105
|
parentId: 'ctx-p',
|
|
106
106
|
executionContext: { runId: 'r1', disableTrace: false }
|
|
107
107
|
} );
|
|
108
|
-
const { init,
|
|
108
|
+
const { init, addEventActionWithContext } = await loadTraceEngine();
|
|
109
109
|
await init();
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
addEventActionWithContext( 'tick', { kind: 'step', name: 'S', id: '3', details: 1 } );
|
|
112
112
|
expect( localExecMock ).toHaveBeenCalledTimes( 1 );
|
|
113
113
|
const payload = localExecMock.mock.calls[0][0];
|
|
114
114
|
expect( payload.executionContext ).toEqual( { runId: 'r1', disableTrace: false } );
|
|
115
115
|
expect( payload.entry.parentId ).toBe( 'ctx-p' );
|
|
116
116
|
expect( payload.entry.name ).toBe( 'S' );
|
|
117
|
-
expect( payload.entry.
|
|
117
|
+
expect( payload.entry.action ).toBe( 'tick' );
|
|
118
118
|
} );
|
|
119
119
|
|
|
120
|
-
it( '
|
|
120
|
+
it( 'addEventActionWithContext() does not emit when storage executionContext.disableTrace is true', async () => {
|
|
121
121
|
process.env.OUTPUT_TRACE_LOCAL_ON = '1';
|
|
122
122
|
storageLoadMock.mockReturnValue( {
|
|
123
123
|
parentId: 'ctx-p',
|
|
124
124
|
executionContext: { runId: 'r1', disableTrace: true }
|
|
125
125
|
} );
|
|
126
|
-
const { init,
|
|
126
|
+
const { init, addEventActionWithContext } = await loadTraceEngine();
|
|
127
127
|
await init();
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
addEventActionWithContext( 'tick', { kind: 'step', name: 'S', id: '3', details: 1 } );
|
|
130
130
|
expect( localExecMock ).not.toHaveBeenCalled();
|
|
131
131
|
} );
|
|
132
132
|
|
|
133
|
-
it( '
|
|
133
|
+
it( 'addEventActionWithContext() is a no-op when storage is absent', async () => {
|
|
134
134
|
process.env.OUTPUT_TRACE_LOCAL_ON = '1';
|
|
135
135
|
storageLoadMock.mockReturnValue( undefined );
|
|
136
|
-
const { init,
|
|
136
|
+
const { init, addEventActionWithContext } = await loadTraceEngine();
|
|
137
137
|
await init();
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
addEventActionWithContext( 'noop', { kind: 'step', name: 'X', id: '4', details: null } );
|
|
140
140
|
expect( localExecMock ).not.toHaveBeenCalled();
|
|
141
141
|
} );
|
|
142
142
|
|
package/src/worker/configs.js
CHANGED
|
@@ -26,7 +26,14 @@ const envVarSchema = z.object( {
|
|
|
26
26
|
// Whether to send activity heartbeats (enabled by default)
|
|
27
27
|
OUTPUT_ACTIVITY_HEARTBEAT_ENABLED: z.transform( v => v === undefined ? true : isStringboolTrue( v ) ),
|
|
28
28
|
// Time to allow for hooks to flush before shutdown
|
|
29
|
-
OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 3000 ) )
|
|
29
|
+
OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 3000 ) ),
|
|
30
|
+
// HTTP CONNECT proxy for Temporal gRPC connections (e.g. "proxy-host:8080").
|
|
31
|
+
// Must be a bare host:port — no scheme (Temporal's native HTTP CONNECT
|
|
32
|
+
// option is not a URL).
|
|
33
|
+
TEMPORAL_GRPC_PROXY: z.string().optional().refine(
|
|
34
|
+
v => !v || !v.includes( '://' ),
|
|
35
|
+
'TEMPORAL_GRPC_PROXY must be host:port without a scheme (e.g. "proxy:8080", not "http://proxy:8080")'
|
|
36
|
+
)
|
|
30
37
|
} );
|
|
31
38
|
|
|
32
39
|
const { data: envVars, error } = envVarSchema.safeParse( process.env );
|
|
@@ -47,3 +54,4 @@ export const catalogId = envVars.OUTPUT_CATALOG_ID;
|
|
|
47
54
|
export const activityHeartbeatIntervalMs = envVars.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS;
|
|
48
55
|
export const activityHeartbeatEnabled = envVars.OUTPUT_ACTIVITY_HEARTBEAT_ENABLED;
|
|
49
56
|
export const processFailureShutdownDelay = envVars.OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY;
|
|
57
|
+
export const grpcProxy = envVars.TEMPORAL_GRPC_PROXY;
|
package/src/worker/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { initInterceptors } from './interceptors.js';
|
|
|
9
9
|
import { createChildLogger } from '#logger';
|
|
10
10
|
import { registerShutdown } from './shutdown.js';
|
|
11
11
|
import { startCatalog } from './start_catalog.js';
|
|
12
|
+
import { bootstrapFetchProxy } from './proxy.js';
|
|
12
13
|
import { messageBus } from '#bus';
|
|
13
14
|
import './log_hooks.js';
|
|
14
15
|
import { BusEventType } from '#consts';
|
|
@@ -24,6 +25,7 @@ const callerDir = process.argv[2];
|
|
|
24
25
|
apiKey,
|
|
25
26
|
namespace,
|
|
26
27
|
taskQueue,
|
|
28
|
+
grpcProxy,
|
|
27
29
|
maxConcurrentWorkflowTaskExecutions,
|
|
28
30
|
maxConcurrentActivityTaskExecutions,
|
|
29
31
|
maxCachedWorkflows,
|
|
@@ -41,6 +43,7 @@ const callerDir = process.argv[2];
|
|
|
41
43
|
const activities = await loadActivities( callerDir, workflows );
|
|
42
44
|
|
|
43
45
|
messageBus.emit( BusEventType.WORKER_BEFORE_START );
|
|
46
|
+
bootstrapFetchProxy();
|
|
44
47
|
|
|
45
48
|
log.info( 'Creating worker entry point...' );
|
|
46
49
|
const workflowsPath = createWorkflowsEntryPoint( workflows );
|
|
@@ -52,8 +55,11 @@ const callerDir = process.argv[2];
|
|
|
52
55
|
const catalog = createCatalog( { workflows, activities } );
|
|
53
56
|
|
|
54
57
|
log.info( 'Connecting Temporal...' );
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
const proxy = grpcProxy ? { type: 'http-connect', targetHost: grpcProxy } : undefined;
|
|
59
|
+
if ( proxy ) {
|
|
60
|
+
log.info( 'Using gRPC proxy', { targetHost: grpcProxy } );
|
|
61
|
+
}
|
|
62
|
+
const connection = await NativeConnection.connect( { address, tls: Boolean( apiKey ), apiKey, proxy } );
|
|
57
63
|
|
|
58
64
|
log.info( 'Creating worker...' );
|
|
59
65
|
const worker = await Worker.create( {
|