@outputai/core 0.2.1-next.fd72d95.0 → 0.3.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 +7 -7
- package/src/activity_integration/tracing.d.ts +30 -17
- package/src/activity_integration/tracing.js +33 -17
- package/src/hooks/index.d.ts +66 -3
- package/src/hooks/index.js +40 -24
- package/src/hooks/index.spec.js +176 -0
- 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
|
@@ -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
|
+
};
|