@outputai/core 0.2.1-next.7fd86e7.0 → 0.2.1-next.bc8ccee.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/package.json
CHANGED
|
@@ -1,32 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Creates a new event.
|
|
3
3
|
*
|
|
4
|
-
* @param args
|
|
5
|
-
* @param args.id - A unique id for the Event
|
|
4
|
+
* @param args
|
|
5
|
+
* @param args.id - A unique id for the Event.
|
|
6
6
|
* @param args.kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
|
|
7
|
-
* @param args.name - The human
|
|
8
|
-
* @param args.details - Arbitrary
|
|
7
|
+
* @param args.name - The human-friendly name of the Event: query, request, create.
|
|
8
|
+
* @param args.details - Arbitrary data to add to this event, it will be used as the "input" field.
|
|
9
9
|
*/
|
|
10
10
|
export declare function addEventStart( args: { id: string; kind: string; name: string; details: unknown } ): void;
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
13
|
+
* Concludes an event.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* @param args -
|
|
18
|
-
* @param args.id - Identifier matching the event's start phase.
|
|
19
|
-
* @param args.details - Arbitrary metadata associated with this phase (e.g., results, response body).
|
|
15
|
+
* @param args
|
|
16
|
+
* @param args.id - The id of the event to conclude.
|
|
17
|
+
* @param args.details - Arbitrary data to add to this event, it will be used as the "output" field.
|
|
20
18
|
*/
|
|
21
19
|
export declare function addEventEnd( args: { id: string; details: unknown } ): void;
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Use the same id as the start phase to correlate phases.
|
|
22
|
+
* Concludes an event with an error.
|
|
27
23
|
*
|
|
28
|
-
* @param args
|
|
29
|
-
* @param args.id -
|
|
30
|
-
* @param args.details - Arbitrary
|
|
24
|
+
* @param args
|
|
25
|
+
* @param args.id - The id of the event to conclude.
|
|
26
|
+
* @param args.details - Arbitrary data to add to this event, it will be used as the "error" field.
|
|
31
27
|
*/
|
|
32
28
|
export declare function addEventError( args: { id: string; details: unknown } ): void;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Adds an attribute to an event.
|
|
32
|
+
*
|
|
33
|
+
* @param args
|
|
34
|
+
* @param args.eventId - The id of the event to attach the attribute to.
|
|
35
|
+
* @param args.name - The attribute name
|
|
36
|
+
* @param args.value - The attribute value
|
|
37
|
+
*/
|
|
38
|
+
export declare function addEventAttribute( args: { eventId: string; name: string, value: unknown } ): void;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Known attributes.
|
|
42
|
+
*/
|
|
43
|
+
export declare const Attribute: {
|
|
44
|
+
COST: 'cost';
|
|
45
|
+
};
|
|
@@ -1,37 +1,53 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { addEventActionWithContext, EventAction } from '#tracing';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Creates a new event.
|
|
5
5
|
*
|
|
6
6
|
* @param {object} args
|
|
7
|
-
* @param {string} args.id - A unique id for the Event
|
|
7
|
+
* @param {string} args.id - A unique id for the Event.
|
|
8
8
|
* @param {string} args.kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
|
|
9
|
-
* @param {string} args.name - The human
|
|
10
|
-
* @param {object} args.details -
|
|
9
|
+
* @param {string} args.name - The human-friendly name of the Event: query, request, create.
|
|
10
|
+
* @param {object} args.details - Arbitrary data to add to this event, it will be used as the "input" field.
|
|
11
11
|
* @returns {void}
|
|
12
12
|
*/
|
|
13
|
-
export const addEventStart = ( { id, kind, name, details } ) =>
|
|
13
|
+
export const addEventStart = ( { id, kind, name, details } ) =>
|
|
14
|
+
addEventActionWithContext( EventAction.START, { kind, name, details, id } );
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* It needs to use the same id of the start phase.
|
|
17
|
+
* Concludes an event.
|
|
19
18
|
*
|
|
20
19
|
* @param {object} args
|
|
21
|
-
* @param {string} args.id -
|
|
22
|
-
* @param {object} args.details -
|
|
20
|
+
* @param {string} args.id - The id of the event to conclude.
|
|
21
|
+
* @param {object} args.details - Arbitrary data to add to this event, it will be used as the "output" field.
|
|
23
22
|
* @returns {void}
|
|
24
23
|
*/
|
|
25
|
-
export const addEventEnd = ( { id, details } ) =>
|
|
24
|
+
export const addEventEnd = ( { id, details } ) => addEventActionWithContext( EventAction.END, { id, details } );
|
|
26
25
|
|
|
27
26
|
/**
|
|
28
|
-
*
|
|
27
|
+
* Concludes an event with an error.
|
|
29
28
|
*
|
|
30
|
-
*
|
|
29
|
+
* @param {object} args
|
|
30
|
+
* @param {string} args.id - The id of the event to conclude.
|
|
31
|
+
* @param {object} args.details - Arbitrary data to add to this event, it will be used as the "error" field.
|
|
32
|
+
* @returns {void}
|
|
33
|
+
*/
|
|
34
|
+
export const addEventError = ( { id, details } ) => addEventActionWithContext( EventAction.ERROR, { id, details } );
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Adds an attribute to an event.
|
|
31
38
|
*
|
|
32
39
|
* @param {object} args
|
|
33
|
-
* @param {string} args.
|
|
34
|
-
* @param {
|
|
40
|
+
* @param {string} args.eventId - The id of the event to attach the attribute to.
|
|
41
|
+
* @param {string} args.name - The attribute name
|
|
42
|
+
* @param {unknown} args.value - The attribute value
|
|
35
43
|
* @returns {void}
|
|
36
44
|
*/
|
|
37
|
-
export const
|
|
45
|
+
export const addEventAttribute = ( { eventId, name, value } ) =>
|
|
46
|
+
addEventActionWithContext( EventAction.ADD_ATTR, { id: eventId, details: { name, value } } );
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Known attributes
|
|
50
|
+
*/
|
|
51
|
+
export const Attribute = {
|
|
52
|
+
COST: 'cost'
|
|
53
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { addEventAction, addEventActionWithContext, init, getDestinations } from './trace_engine.js';
|
|
2
|
+
import { EventAction } from './trace_consts.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Init method, if not called, no processors are attached and trace functions are dummy
|
|
@@ -6,66 +7,71 @@ import { addEventPhase, addEventPhaseWithContext, init, getDestinations } from '
|
|
|
6
7
|
export { init, getDestinations };
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* Internal use only - adds
|
|
10
|
+
* Internal use only - adds an action with AsyncLocalStorage context resolution
|
|
10
11
|
*/
|
|
11
|
-
export {
|
|
12
|
+
export { addEventActionWithContext };
|
|
13
|
+
|
|
14
|
+
export { EventAction };
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Trace nomenclature
|
|
15
18
|
*
|
|
16
19
|
* Trace - The collection of Events;
|
|
17
|
-
* Event - Any entry in the Trace file
|
|
18
|
-
*
|
|
20
|
+
* Event - Any entry in the Trace file must have both START and END/ERROR actions, plus any number of ADD_ATTR actions;
|
|
21
|
+
* Action - A specific part of an Event: START, END, ERROR, or ADD_ATTR;
|
|
19
22
|
*/
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* Internal use only
|
|
23
26
|
*
|
|
24
|
-
*
|
|
27
|
+
* Creates a new event and appends it to the trace.
|
|
25
28
|
*
|
|
26
29
|
* @param {object} args
|
|
27
|
-
* @param {string} args.id - A unique id for the Event
|
|
30
|
+
* @param {string} args.id - A unique id for the Event.
|
|
28
31
|
* @param {string} args.kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
|
|
29
|
-
* @param {string} args.name - The human
|
|
30
|
-
* @param {any} args.details -
|
|
31
|
-
* @param {string} args.parentId - The parent Event, used to build a
|
|
32
|
+
* @param {string} args.name - The human-friendly name of the Event: query, request, create.
|
|
33
|
+
* @param {any} args.details - Arbitrary data to add to this event, it will be used as the "input" field.
|
|
34
|
+
* @param {string} args.parentId - The parent Event, used to build a tree.
|
|
32
35
|
* @param {object} args.executionContext - The original execution context from the workflow
|
|
33
36
|
* @returns {void}
|
|
34
37
|
*/
|
|
35
|
-
export const addEventStart = options =>
|
|
38
|
+
export const addEventStart = options => addEventAction( EventAction.START, options );
|
|
36
39
|
|
|
37
40
|
/**
|
|
38
41
|
* Internal use only
|
|
39
42
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* It needs to use the same id of the start phase.
|
|
43
|
+
* Concludes an event, matching by its id.
|
|
43
44
|
*
|
|
44
45
|
* @param {object} args
|
|
45
|
-
* @param {string} args.id -
|
|
46
|
-
* @param {
|
|
47
|
-
* @param {string} args.name - The human friendly name of the Event: query, request, create.
|
|
48
|
-
* @param {any} args.details - All details attached to this Event Phase. DB queried records, HTTP response body.
|
|
49
|
-
* @param {string} args.parentId - The parent Event, used to build a three.
|
|
46
|
+
* @param {string} args.id - The id of the event to conclude.
|
|
47
|
+
* @param {any} args.details - Arbitrary data to add to the event; it is used as the "output" field.
|
|
50
48
|
* @param {object} args.executionContext - The original execution context from the workflow
|
|
51
49
|
* @returns {void}
|
|
52
50
|
*/
|
|
53
|
-
export const addEventEnd = options =>
|
|
51
|
+
export const addEventEnd = options => addEventAction( EventAction.END, options );
|
|
54
52
|
|
|
55
53
|
/**
|
|
56
54
|
* Internal use only
|
|
57
55
|
*
|
|
58
|
-
*
|
|
56
|
+
* Concludes an event with an error, matching by its id.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} args
|
|
59
|
+
* @param {string} args.id - The id of the event to conclude.
|
|
60
|
+
* @param {any} args.details - Arbitrary data to add to the event; it is used as the "error" field.
|
|
61
|
+
* @param {object} args.executionContext - The original execution context from the workflow
|
|
62
|
+
* @returns {void}
|
|
63
|
+
*/
|
|
64
|
+
export const addEventError = options => addEventAction( EventAction.ERROR, options );
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Internal use only
|
|
59
68
|
*
|
|
60
|
-
*
|
|
69
|
+
* Adds an attribute to an event using its id.
|
|
61
70
|
*
|
|
62
71
|
* @param {object} args
|
|
63
|
-
* @param {string} args.id -
|
|
64
|
-
* @param {
|
|
65
|
-
* @param {string} args.name - The human friendly name of the Event: query, request, create.
|
|
66
|
-
* @param {any} args.details - All details attached to this Event Phase. DB queried records, HTTP response body.
|
|
67
|
-
* @param {string} args.parentId - The parent Event, used to build a three.
|
|
72
|
+
* @param {string} args.id - The id of the event to attach the attribute to.
|
|
73
|
+
* @param {object} args.details - The attribute to add to this event, must be in `{ name: string, value: any }` format.
|
|
68
74
|
* @param {object} args.executionContext - The original execution context from the workflow
|
|
69
75
|
* @returns {void}
|
|
70
76
|
*/
|
|
71
|
-
export const
|
|
77
|
+
export const addEventAttribute = options => addEventAction( EventAction.ADD_ATTR, options );
|
|
@@ -2,6 +2,7 @@ import { appendFileSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFile
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import buildTraceTree from '../../tools/build_trace_tree.js';
|
|
5
|
+
import { safeFormatJSON } from '../../tools/utils.js';
|
|
5
6
|
import { EOL } from 'node:os';
|
|
6
7
|
|
|
7
8
|
const __dirname = dirname( fileURLToPath( import.meta.url ) );
|
|
@@ -13,12 +14,32 @@ const callerDir = process.argv[2];
|
|
|
13
14
|
|
|
14
15
|
const tempTraceFilesDir = join( __dirname, 'temp', 'traces' );
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
17
|
+
/**
|
|
18
|
+
* Builds the temp file path to accumulate trace entries
|
|
19
|
+
*
|
|
20
|
+
* @param {object} executionContext - The execution context around a given trace entry
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
const createTempFilePath = ( { workflowId, startTime } ) => join( tempTraceFilesDir, `${startTime}_${workflowId}.trace` );
|
|
21
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Adds a trace entry to the accumulation file.
|
|
27
|
+
* @param {object} entry - The trace entry
|
|
28
|
+
* @param {string} path - Accumulation file path
|
|
29
|
+
*/
|
|
30
|
+
const addEntry = ( entry, path ) => appendFileSync( path, JSON.stringify( entry ) + EOL, 'utf-8' );
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reads the accumulation file and returns all the entries, each serialized to JSON
|
|
34
|
+
* @param {string} path - Accumulation file path
|
|
35
|
+
* @returns {object[]} Trace entries
|
|
36
|
+
*/
|
|
37
|
+
const getEntries = path => readFileSync( path, 'utf-8' ).split( EOL ).slice( 0, -1 ).map( v => JSON.parse( v ) );
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Deletes old accumulation files
|
|
41
|
+
* @param {number} [threshold] Timestamp in ms epoch. All files below this date are considered old
|
|
42
|
+
*/
|
|
22
43
|
const cleanupOldTempFiles = ( threshold = Date.now() - tempFilesTTL ) =>
|
|
23
44
|
readdirSync( tempTraceFilesDir )
|
|
24
45
|
.filter( f => +f.split( '_' )[0] < threshold )
|
|
@@ -79,21 +100,33 @@ export const init = () => {
|
|
|
79
100
|
/**
|
|
80
101
|
* Execute this processor:
|
|
81
102
|
*
|
|
82
|
-
*
|
|
103
|
+
* Appends each trace entry to a temp file.
|
|
104
|
+
*
|
|
105
|
+
* When the root workflow ends or the entry is an error action, build the trace tree and write the JSON file.
|
|
83
106
|
*
|
|
84
107
|
* @param {object} args
|
|
85
|
-
* @param {object} entry -
|
|
86
|
-
* @param {object} executionContext - Execution info: workflowId, workflowName, startTime
|
|
108
|
+
* @param {object} args.entry - The trace entry to append.
|
|
109
|
+
* @param {object} args.executionContext - Execution info: workflowId, workflowName, startTime
|
|
87
110
|
* @returns {void}
|
|
88
111
|
*/
|
|
89
112
|
export const exec = ( { entry, executionContext } ) => {
|
|
90
113
|
const { workflowId, workflowName, startTime } = executionContext;
|
|
91
|
-
const
|
|
114
|
+
const tempFilePath = createTempFilePath( executionContext );
|
|
115
|
+
addEntry( entry, tempFilePath );
|
|
116
|
+
|
|
117
|
+
const isRootWorkflowEnd = entry.id === workflowId && entry.action !== 'start';
|
|
118
|
+
const isError = entry.action === 'error';
|
|
119
|
+
|
|
120
|
+
if ( !isRootWorkflowEnd && !isError ) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const content = buildTraceTree( getEntries( tempFilePath ) );
|
|
92
125
|
const dir = resolveIOPath( workflowName );
|
|
93
126
|
const path = join( dir, buildTraceFilename( { startTime, workflowId } ) );
|
|
94
127
|
|
|
95
128
|
mkdirSync( dir, { recursive: true } );
|
|
96
|
-
writeFileSync( path,
|
|
129
|
+
writeFileSync( path, safeFormatJSON( content ) + EOL, 'utf-8' );
|
|
97
130
|
};
|
|
98
131
|
|
|
99
132
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { EOL } from 'node:os';
|
|
2
3
|
|
|
3
4
|
// In-memory fs mock store
|
|
4
5
|
const store = { files: new Map() };
|
|
@@ -24,12 +25,17 @@ vi.mock( 'node:fs', () => ( {
|
|
|
24
25
|
const buildTraceTreeMock = vi.fn( entries => ( { count: entries.length } ) );
|
|
25
26
|
vi.mock( '../../tools/build_trace_tree.js', () => ( { default: buildTraceTreeMock } ) );
|
|
26
27
|
|
|
28
|
+
/** Flush happens when the root id matches workflowId and action is not 'start', or when action is 'error'. */
|
|
29
|
+
const rootStart = ( workflowId, ts ) => ( { id: workflowId, action: 'start', timestamp: ts } );
|
|
30
|
+
const rootEnd = ( workflowId, ts ) => ( { id: workflowId, action: 'end', timestamp: ts } );
|
|
31
|
+
const childTick = ( id, ts ) => ( { id, action: 'tick', timestamp: ts } );
|
|
32
|
+
|
|
27
33
|
describe( 'tracing/processors/local', () => {
|
|
28
34
|
beforeEach( () => {
|
|
29
35
|
vi.clearAllMocks();
|
|
30
36
|
store.files.clear();
|
|
31
37
|
process.argv[2] = '/tmp/project';
|
|
32
|
-
delete process.env.OUTPUT_TRACE_HOST_PATH;
|
|
38
|
+
delete process.env.OUTPUT_TRACE_HOST_PATH;
|
|
33
39
|
} );
|
|
34
40
|
|
|
35
41
|
it( 'init(): creates temp dir and cleans up old files', async () => {
|
|
@@ -40,34 +46,63 @@ describe( 'tracing/processors/local', () => {
|
|
|
40
46
|
|
|
41
47
|
init();
|
|
42
48
|
|
|
43
|
-
// Should create temp dir relative to module location using __dirname
|
|
44
49
|
expect( mkdirSyncMock ).toHaveBeenCalledWith( expect.stringMatching( /temp\/traces$/ ), { recursive: true } );
|
|
45
50
|
expect( rmSyncMock ).toHaveBeenCalledTimes( 1 );
|
|
46
51
|
} );
|
|
47
52
|
|
|
48
|
-
it( 'exec():
|
|
53
|
+
it( 'exec(): appends each entry and writes aggregated tree once on root workflow end', async () => {
|
|
49
54
|
const { exec, init } = await import( './index.js' );
|
|
50
55
|
init();
|
|
51
56
|
|
|
52
57
|
const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
|
|
53
|
-
const
|
|
58
|
+
const workflowId = 'id1';
|
|
59
|
+
const ctx = { executionContext: { workflowId, workflowName: 'WF', startTime } };
|
|
54
60
|
|
|
55
|
-
exec( { ...ctx, entry:
|
|
56
|
-
exec( { ...ctx, entry:
|
|
57
|
-
exec( { ...ctx, entry:
|
|
61
|
+
exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
62
|
+
exec( { ...ctx, entry: childTick( 'child-1', startTime + 1 ) } );
|
|
63
|
+
exec( { ...ctx, entry: rootEnd( workflowId, startTime + 2 ) } );
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
expect( buildTraceTreeMock ).
|
|
61
|
-
expect( buildTraceTreeMock.mock.calls.at( -1 )[0].length ).toBe( 3 );
|
|
65
|
+
expect( buildTraceTreeMock ).toHaveBeenCalledTimes( 1 );
|
|
66
|
+
expect( buildTraceTreeMock.mock.calls[0][0] ).toHaveLength( 3 );
|
|
62
67
|
|
|
63
|
-
expect( writeFileSyncMock ).toHaveBeenCalledTimes(
|
|
64
|
-
const [ writtenPath, content ] = writeFileSyncMock.mock.calls
|
|
65
|
-
|
|
66
|
-
expect( writtenPath ).toMatch( /\/runs\/WF\// );
|
|
68
|
+
expect( writeFileSyncMock ).toHaveBeenCalledTimes( 1 );
|
|
69
|
+
const [ writtenPath, content ] = writeFileSyncMock.mock.calls[0];
|
|
70
|
+
expect( writtenPath ).toMatch( /\/tmp\/project\/logs\/runs\/WF\// );
|
|
67
71
|
expect( JSON.parse( content.trim() ).count ).toBe( 3 );
|
|
68
72
|
} );
|
|
69
73
|
|
|
70
|
-
it( '
|
|
74
|
+
it( 'exec(): does not build or write on non-flush entries', async () => {
|
|
75
|
+
const { exec, init } = await import( './index.js' );
|
|
76
|
+
init();
|
|
77
|
+
|
|
78
|
+
const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
|
|
79
|
+
const workflowId = 'id1';
|
|
80
|
+
const ctx = { executionContext: { workflowId, workflowName: 'WF', startTime } };
|
|
81
|
+
|
|
82
|
+
exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
83
|
+
exec( { ...ctx, entry: childTick( 'child-1', startTime + 1 ) } );
|
|
84
|
+
|
|
85
|
+
expect( buildTraceTreeMock ).not.toHaveBeenCalled();
|
|
86
|
+
expect( writeFileSyncMock ).not.toHaveBeenCalled();
|
|
87
|
+
} );
|
|
88
|
+
|
|
89
|
+
it( 'exec(): flushes on error action before root end', async () => {
|
|
90
|
+
const { exec, init } = await import( './index.js' );
|
|
91
|
+
init();
|
|
92
|
+
|
|
93
|
+
const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
|
|
94
|
+
const workflowId = 'id1';
|
|
95
|
+
const ctx = { executionContext: { workflowId, workflowName: 'WF', startTime } };
|
|
96
|
+
|
|
97
|
+
exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
98
|
+
exec( { ...ctx, entry: { id: 'step-1', action: 'error', timestamp: startTime + 1 } } );
|
|
99
|
+
|
|
100
|
+
expect( buildTraceTreeMock ).toHaveBeenCalledTimes( 1 );
|
|
101
|
+
expect( buildTraceTreeMock.mock.calls[0][0] ).toHaveLength( 2 );
|
|
102
|
+
expect( writeFileSyncMock ).toHaveBeenCalledTimes( 1 );
|
|
103
|
+
} );
|
|
104
|
+
|
|
105
|
+
it( 'getDestination(): returns absolute path under callerDir logs', async () => {
|
|
71
106
|
const { getDestination } = await import( './index.js' );
|
|
72
107
|
|
|
73
108
|
const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
|
|
@@ -76,36 +111,36 @@ describe( 'tracing/processors/local', () => {
|
|
|
76
111
|
|
|
77
112
|
const destination = getDestination( { startTime, workflowId, workflowName } );
|
|
78
113
|
|
|
79
|
-
|
|
80
|
-
expect( destination ).
|
|
81
|
-
|
|
114
|
+
expect( destination ).toMatch( /^\/|^[A-Z]:\\/i );
|
|
115
|
+
expect( destination ).toBe(
|
|
116
|
+
'/tmp/project/logs/runs/test-workflow/2020-01-02-03-04-05-678Z_workflow-id-123.json'
|
|
117
|
+
);
|
|
82
118
|
} );
|
|
83
119
|
|
|
84
|
-
it( 'exec(): writes
|
|
120
|
+
it( 'exec(): writes under process.argv[2] logs even when OUTPUT_TRACE_HOST_PATH is set', async () => {
|
|
85
121
|
const { exec, init } = await import( './index.js' );
|
|
86
122
|
|
|
87
|
-
// Set OUTPUT_TRACE_HOST_PATH to simulate Docker environment
|
|
88
123
|
process.env.OUTPUT_TRACE_HOST_PATH = '/host/path/logs';
|
|
89
124
|
|
|
90
125
|
init();
|
|
91
126
|
|
|
92
127
|
const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
|
|
93
|
-
const
|
|
128
|
+
const workflowId = 'id1';
|
|
129
|
+
const ctx = { executionContext: { workflowId, workflowName: 'WF', startTime } };
|
|
94
130
|
|
|
95
|
-
exec( { ...ctx, entry:
|
|
131
|
+
exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
132
|
+
exec( { ...ctx, entry: rootEnd( workflowId, startTime + 1 ) } );
|
|
96
133
|
|
|
97
134
|
expect( writeFileSyncMock ).toHaveBeenCalledTimes( 1 );
|
|
98
|
-
const [ writtenPath ] = writeFileSyncMock.mock.calls
|
|
135
|
+
const [ writtenPath ] = writeFileSyncMock.mock.calls[0];
|
|
99
136
|
|
|
100
|
-
// Should write to process.cwd()/logs, NOT to OUTPUT_TRACE_HOST_PATH
|
|
101
137
|
expect( writtenPath ).not.toContain( '/host/path/logs' );
|
|
102
|
-
expect( writtenPath ).toMatch(
|
|
138
|
+
expect( writtenPath ).toMatch( /\/tmp\/project\/logs\/runs\/WF\// );
|
|
103
139
|
} );
|
|
104
140
|
|
|
105
141
|
it( 'getDestination(): returns OUTPUT_TRACE_HOST_PATH when set', async () => {
|
|
106
142
|
const { getDestination } = await import( './index.js' );
|
|
107
143
|
|
|
108
|
-
// Set OUTPUT_TRACE_HOST_PATH to simulate Docker environment
|
|
109
144
|
process.env.OUTPUT_TRACE_HOST_PATH = '/host/path/logs';
|
|
110
145
|
|
|
111
146
|
const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
|
|
@@ -114,14 +149,12 @@ describe( 'tracing/processors/local', () => {
|
|
|
114
149
|
|
|
115
150
|
const destination = getDestination( { startTime, workflowId, workflowName } );
|
|
116
151
|
|
|
117
|
-
// Should return OUTPUT_TRACE_HOST_PATH-based path for reporting
|
|
118
152
|
expect( destination ).toBe( '/host/path/logs/runs/test-workflow/2020-01-02-03-04-05-678Z_workflow-id-123.json' );
|
|
119
153
|
} );
|
|
120
154
|
|
|
121
155
|
it( 'separation of write and report paths works correctly', async () => {
|
|
122
156
|
const { exec, getDestination, init } = await import( './index.js' );
|
|
123
157
|
|
|
124
|
-
// Set OUTPUT_TRACE_HOST_PATH to simulate Docker environment
|
|
125
158
|
process.env.OUTPUT_TRACE_HOST_PATH = '/Users/ben/project/logs';
|
|
126
159
|
|
|
127
160
|
init();
|
|
@@ -131,19 +164,16 @@ describe( 'tracing/processors/local', () => {
|
|
|
131
164
|
const workflowName = 'test-workflow';
|
|
132
165
|
const ctx = { executionContext: { workflowId, workflowName, startTime } };
|
|
133
166
|
|
|
134
|
-
|
|
135
|
-
exec( { ...ctx, entry:
|
|
167
|
+
exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
168
|
+
exec( { ...ctx, entry: rootEnd( workflowId, startTime + 1 ) } );
|
|
136
169
|
|
|
137
|
-
// Get destination for reporting
|
|
138
170
|
const destination = getDestination( { startTime, workflowId, workflowName } );
|
|
139
171
|
|
|
140
|
-
|
|
141
|
-
const [ writtenPath ] = writeFileSyncMock.mock.calls.at( -1 );
|
|
172
|
+
const [ writtenPath, payload ] = writeFileSyncMock.mock.calls[0];
|
|
142
173
|
expect( writtenPath ).not.toContain( '/Users/ben/project' );
|
|
143
|
-
expect( writtenPath ).toMatch(
|
|
174
|
+
expect( writtenPath ).toMatch( /\/tmp\/project\/logs\/runs\/test-workflow\// );
|
|
175
|
+
expect( payload.endsWith( EOL ) ).toBe( true );
|
|
144
176
|
|
|
145
|
-
// Verify report path uses OUTPUT_TRACE_HOST_PATH
|
|
146
177
|
expect( destination ).toBe( '/Users/ben/project/logs/runs/test-workflow/2020-01-02-03-04-05-678Z_workflow-id-123.json' );
|
|
147
178
|
} );
|
|
148
179
|
} );
|
|
149
|
-
|
|
@@ -4,6 +4,7 @@ import buildTraceTree from '../../tools/build_trace_tree.js';
|
|
|
4
4
|
import { EOL } from 'node:os';
|
|
5
5
|
import { loadEnv, getVars } from './configs.js';
|
|
6
6
|
import { createChildLogger } from '#logger';
|
|
7
|
+
import { safeFormatJSON } from '../../tools/utils.js';
|
|
7
8
|
|
|
8
9
|
const log = createChildLogger( 'S3 Processor' );
|
|
9
10
|
|
|
@@ -66,11 +67,15 @@ export const init = async () => {
|
|
|
66
67
|
};
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
|
-
* Execute this processor:
|
|
70
|
+
* Execute this processor:
|
|
71
|
+
*
|
|
72
|
+
* Appends each trace entry to Redis.
|
|
73
|
+
*
|
|
74
|
+
* When the root workflow ends or the entry is an error action, it builds the trace tree and uploads it to S3.
|
|
70
75
|
*
|
|
71
76
|
* @param {object} args
|
|
72
|
-
* @param {object} entry -
|
|
73
|
-
* @param {object} executionContext - Execution info: workflowId, workflowName, startTime
|
|
77
|
+
* @param {object} args.entry - The trace entry to append
|
|
78
|
+
* @param {object} args.executionContext - Execution info: workflowId, workflowName, startTime
|
|
74
79
|
*/
|
|
75
80
|
export const exec = async ( { entry, executionContext } ) => {
|
|
76
81
|
const { workflowName, workflowId, startTime } = executionContext;
|
|
@@ -78,7 +83,7 @@ export const exec = async ( { entry, executionContext } ) => {
|
|
|
78
83
|
|
|
79
84
|
await addEntry( entry, cacheKey );
|
|
80
85
|
|
|
81
|
-
const isRootWorkflowEnd = entry.id === workflowId && entry.
|
|
86
|
+
const isRootWorkflowEnd = entry.id === workflowId && entry.action !== 'start';
|
|
82
87
|
if ( !isRootWorkflowEnd ) {
|
|
83
88
|
return;
|
|
84
89
|
}
|
|
@@ -97,7 +102,7 @@ export const exec = async ( { entry, executionContext } ) => {
|
|
|
97
102
|
}
|
|
98
103
|
await upload( {
|
|
99
104
|
key: getS3Key( { workflowId, workflowName, startTime } ),
|
|
100
|
-
content:
|
|
105
|
+
content: safeFormatJSON( content ) + EOL
|
|
101
106
|
} );
|
|
102
107
|
await bustEntries( cacheKey );
|
|
103
108
|
};
|
|
@@ -52,9 +52,9 @@ describe( 'tracing/processors/s3', () => {
|
|
|
52
52
|
|
|
53
53
|
redisMulti.exec.mockResolvedValue( [] );
|
|
54
54
|
|
|
55
|
-
const workflowStart = { id: 'id1', name: 'WF', kind: 'workflow',
|
|
56
|
-
const activityStart = { id: 'act-1', name: 'DoSomething', kind: 'step', parentId: 'id1',
|
|
57
|
-
const workflowEnd = { id: 'id1',
|
|
55
|
+
const workflowStart = { id: 'id1', name: 'WF', kind: 'workflow', action: 'start', details: {}, timestamp: startTime };
|
|
56
|
+
const activityStart = { id: 'act-1', name: 'DoSomething', kind: 'step', parentId: 'id1', action: 'start', details: {}, timestamp: startTime + 1 };
|
|
57
|
+
const workflowEnd = { id: 'id1', action: 'end', details: { ok: true }, timestamp: startTime + 2 };
|
|
58
58
|
zRangeMock.mockResolvedValue( [
|
|
59
59
|
JSON.stringify( workflowStart ),
|
|
60
60
|
JSON.stringify( activityStart ),
|
|
@@ -97,7 +97,7 @@ describe( 'tracing/processors/s3', () => {
|
|
|
97
97
|
|
|
98
98
|
redisMulti.exec.mockResolvedValue( [] );
|
|
99
99
|
const workflowStart = {
|
|
100
|
-
kind: 'workflow', id: 'id1', name: 'WF', parentId: undefined,
|
|
100
|
+
kind: 'workflow', id: 'id1', name: 'WF', parentId: undefined, action: 'start', details: {}, timestamp: startTime
|
|
101
101
|
};
|
|
102
102
|
zRangeMock.mockResolvedValue( [ JSON.stringify( workflowStart ) ] );
|
|
103
103
|
|
|
@@ -113,8 +113,8 @@ describe( 'tracing/processors/s3', () => {
|
|
|
113
113
|
const ctx = { executionContext: { workflowId: 'id1', workflowName: 'WF', startTime } };
|
|
114
114
|
|
|
115
115
|
redisMulti.exec.mockResolvedValue( [] );
|
|
116
|
-
const workflowStart = { id: 'id1', name: 'WF', kind: 'workflow',
|
|
117
|
-
const stepEndNoParent = { id: 'step-1',
|
|
116
|
+
const workflowStart = { id: 'id1', name: 'WF', kind: 'workflow', action: 'start', details: {}, timestamp: startTime };
|
|
117
|
+
const stepEndNoParent = { id: 'step-1', action: 'end', details: { done: true }, timestamp: startTime + 1 };
|
|
118
118
|
zRangeMock.mockResolvedValue( [
|
|
119
119
|
JSON.stringify( workflowStart ),
|
|
120
120
|
JSON.stringify( stepEndNoParent )
|
|
@@ -136,7 +136,7 @@ describe( 'tracing/processors/s3', () => {
|
|
|
136
136
|
|
|
137
137
|
redisMulti.exec.mockResolvedValue( [] );
|
|
138
138
|
const workflowEnd = {
|
|
139
|
-
kind: 'workflow', id: 'id1', name: 'WF', parentId: undefined,
|
|
139
|
+
kind: 'workflow', id: 'id1', name: 'WF', parentId: undefined, action: 'end', details: {}, timestamp: startTime
|
|
140
140
|
};
|
|
141
141
|
zRangeMock.mockResolvedValue( [ JSON.stringify( workflowEnd ) ] );
|
|
142
142
|
buildTraceTreeMock.mockReturnValueOnce( null );
|
|
@@ -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
|
|