@output.ai/core 0.1.0 → 0.1.2
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/README.md +114 -30
- package/package.json +9 -6
- package/src/consts.js +3 -1
- package/src/index.d.ts +36 -4
- package/src/interface/evaluator.js +12 -8
- package/src/interface/step.js +4 -4
- package/src/interface/validations/static.js +16 -2
- package/src/interface/validations/static.spec.js +20 -0
- package/src/interface/workflow.js +28 -25
- package/src/interface/zod_integration.spec.js +6 -6
- package/src/internal_activities/index.js +1 -33
- package/src/tracing/index.d.ts +4 -4
- package/src/tracing/index.js +12 -121
- package/src/tracing/internal_interface.js +66 -0
- package/src/tracing/processors/local/index.js +50 -0
- package/src/tracing/processors/local/index.spec.js +67 -0
- package/src/tracing/processors/s3/index.js +51 -0
- package/src/tracing/processors/s3/index.spec.js +64 -0
- package/src/tracing/processors/s3/redis_client.js +19 -0
- package/src/tracing/processors/s3/redis_client.spec.js +50 -0
- package/src/tracing/processors/s3/s3_client.js +33 -0
- package/src/tracing/processors/s3/s3_client.spec.js +67 -0
- package/src/tracing/{tracer_tree.js → tools/build_trace_tree.js} +4 -11
- package/src/tracing/{tracer_tree.spec.js → tools/build_trace_tree.spec.js} +4 -20
- package/src/tracing/{utils.js → tools/utils.js} +7 -0
- package/src/tracing/trace_engine.js +63 -0
- package/src/tracing/trace_engine.spec.js +91 -0
- package/src/utils.js +37 -0
- package/src/utils.spec.js +60 -0
- package/src/worker/catalog_workflow/index.js +2 -1
- package/src/worker/catalog_workflow/index.spec.js +6 -10
- package/src/worker/configs.js +24 -0
- package/src/worker/index.js +7 -4
- package/src/worker/interceptors/activity.js +7 -14
- package/src/worker/interceptors/workflow.js +11 -3
- package/src/worker/loader.js +65 -29
- package/src/worker/loader.spec.js +32 -25
- package/src/worker/loader_tools.js +63 -0
- package/src/worker/loader_tools.spec.js +85 -0
- package/src/worker/sinks.js +8 -4
- package/src/worker/webpack_loaders/workflow_rewriter/collect_target_imports.js +38 -20
- package/src/worker/webpack_loaders/workflow_rewriter/index.mjs +5 -4
- package/src/worker/webpack_loaders/workflow_rewriter/index.spec.js +48 -0
- package/src/worker/webpack_loaders/workflow_rewriter/rewrite_fn_bodies.js +16 -20
- package/src/worker/webpack_loaders/workflow_rewriter/tools.js +23 -0
- package/src/configs.js +0 -31
- package/src/configs.spec.js +0 -331
- package/src/interface/metadata.js +0 -4
- package/src/tracing/index.private.spec.js +0 -84
- package/src/tracing/index.public.spec.js +0 -86
- package/src/worker/internal_utils.js +0 -60
- package/src/worker/internal_utils.spec.js +0 -134
- /package/src/tracing/{utils.spec.js → tools/utils.spec.js} +0 -0
|
@@ -410,12 +410,12 @@ describe( 'Zod Schema Integration Tests', () => {
|
|
|
410
410
|
inputSchema,
|
|
411
411
|
fn: async input => {
|
|
412
412
|
switch ( input.action ) {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
413
|
+
case 'create':
|
|
414
|
+
return `Creating ${input.type}: ${input.name}`;
|
|
415
|
+
case 'delete':
|
|
416
|
+
return `Deleting item ${input.id}`;
|
|
417
|
+
default:
|
|
418
|
+
throw new Error( 'Unknown action' );
|
|
419
419
|
}
|
|
420
420
|
}
|
|
421
421
|
} );
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { FatalError } from '#errors';
|
|
2
|
-
import {
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { setMetadata } from '../interface/metadata.js';
|
|
2
|
+
import { setMetadata } from '#utils';
|
|
5
3
|
import { ComponentType } from '#consts';
|
|
6
4
|
|
|
7
|
-
const callerDir = process.argv[2];
|
|
8
|
-
|
|
9
5
|
/**
|
|
10
6
|
* Send a post to a given URL
|
|
11
7
|
*
|
|
@@ -41,31 +37,3 @@ export const sendWebhook = async ( { url, workflowId, payload } ) => {
|
|
|
41
37
|
};
|
|
42
38
|
|
|
43
39
|
setMetadata( sendWebhook, { type: ComponentType.INTERNAL_STEP } );
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Read the trace file of a given execution and returns the content
|
|
47
|
-
*
|
|
48
|
-
* @param {object} options
|
|
49
|
-
* @param {string} options.traceId - The is of the trace
|
|
50
|
-
* @param {string} options.traceHelm - The helm of the trace file
|
|
51
|
-
* @returns {string[]} Each line of the trace file
|
|
52
|
-
*/
|
|
53
|
-
export const readTraceFile = async ( { traceId, traceHelm } ) => {
|
|
54
|
-
const dir = join( callerDir, 'logs', 'runs', traceHelm );
|
|
55
|
-
|
|
56
|
-
if ( !existsSync( dir ) ) {
|
|
57
|
-
console.log( '[Core.ReadTraceFile]', 'Trace folder not found', dir );
|
|
58
|
-
return [];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const fileName = readdirSync( dir ).find( f => f.endsWith( `-${traceId}.raw` ) );
|
|
62
|
-
|
|
63
|
-
if ( !fileName ) {
|
|
64
|
-
console.log( '[Core.ReadTraceFile]', 'Trace file not found', { traceId, traceHelm } );
|
|
65
|
-
return [];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return readFileSync( join( dir, fileName ), 'utf-8' ).split( '\n' );
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
setMetadata( readTraceFile, { type: ComponentType.INTERNAL_STEP, skipTrace: true } );
|
package/src/tracing/index.d.ts
CHANGED
|
@@ -17,8 +17,8 @@ export declare const Tracing: {
|
|
|
17
17
|
*
|
|
18
18
|
* @param {string} id - A unique id for the Event, must be the same across all phases: start, end, error.
|
|
19
19
|
* @param {string} kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
|
|
20
|
-
* @param {string} name - The human friendly name of the Event:
|
|
21
|
-
* @param {any} details - All details attached to this Event Phase.
|
|
20
|
+
* @param {string} name - The human friendly name of the Event: query, request, create.
|
|
21
|
+
* @param {any} details - All details attached to this Event Phase. DB queried records, HTTP response body.
|
|
22
22
|
* @returns {void}
|
|
23
23
|
*/
|
|
24
24
|
addEventStart( args: { id: string; kind: string; name: string; details: any } ): void; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
@@ -29,7 +29,7 @@ export declare const Tracing: {
|
|
|
29
29
|
* It needs to use the same id of the start phase.
|
|
30
30
|
*
|
|
31
31
|
* @param {string} id - A unique id for the Event, must be the same across all phases: start, end, error.
|
|
32
|
-
* @param {any} details - All details attached to this Event Phase.
|
|
32
|
+
* @param {any} details - All details attached to this Event Phase. DB queried records, HTTP response body.
|
|
33
33
|
* @returns {void}
|
|
34
34
|
*/
|
|
35
35
|
addEventEnd( args: { id: string; details: any } ): void; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
@@ -40,7 +40,7 @@ export declare const Tracing: {
|
|
|
40
40
|
* It needs to use the same id of the start phase.
|
|
41
41
|
*
|
|
42
42
|
* @param {string} id - A unique id for the Event, must be the same across all phases: start, end, error.
|
|
43
|
-
* @param {any} details - All details attached to this Event Phase.
|
|
43
|
+
* @param {any} details - All details attached to this Event Phase. DB queried records, HTTP response body.
|
|
44
44
|
* @returns {void}
|
|
45
45
|
*/
|
|
46
46
|
addEventError( args: { id: string; details: any } ): void; // eslint-disable-line @typescript-eslint/no-explicit-any
|
package/src/tracing/index.js
CHANGED
|
@@ -1,116 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdirSync, existsSync, readdirSync, appendFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
import { EOL } from 'os';
|
|
5
|
-
import { buildLogTree } from './tracer_tree.js';
|
|
6
|
-
import { serializeError } from './utils.js';
|
|
7
|
-
|
|
8
|
-
const callerDir = process.argv[2];
|
|
9
|
-
// It is is isolated here instead of #configs to allow this module to be exported without all other configs requirements
|
|
10
|
-
const tracingEnabled = [ '1', 'true', 'on' ].includes( process.env.TRACING_ENABLED );
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Trace nomenclature
|
|
14
|
-
*
|
|
15
|
-
* Trace - The collection of Events;
|
|
16
|
-
* Event - Any entry in the Trace file, must have the two phases START and END or ERROR;
|
|
17
|
-
* Phase - An specific part of an Event, either START or the conclusive END or ERROR;
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Adds the Trace Event Phase to the trace file
|
|
22
|
-
*
|
|
23
|
-
* @param {string} phase - The phase
|
|
24
|
-
* @param {object} options - All the trace fields
|
|
25
|
-
* @returns {void}
|
|
26
|
-
*/
|
|
27
|
-
function addEventPhase( phase, { kind, details, id, name, parentId, traceId, traceHelm } ) {
|
|
28
|
-
if ( !tracingEnabled || name === 'catalog' ) {
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const parsedDetails = details instanceof Error ? serializeError( details ) : details;
|
|
33
|
-
const timestamp = Date.now();
|
|
34
|
-
const entry = { phase, kind, details: parsedDetails, id, name, timestamp, parentId };
|
|
35
|
-
const outputDir = join( callerDir, 'logs', 'runs', traceHelm );
|
|
36
|
-
|
|
37
|
-
if ( !existsSync( outputDir ) ) {
|
|
38
|
-
mkdirSync( outputDir, { recursive: true } );
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const suffix = `-${traceId}.raw`;
|
|
42
|
-
const logFile = readdirSync( outputDir ).find( f => f.endsWith( suffix ) ) ?? `${new Date( timestamp ).toISOString()}-${suffix}`;
|
|
43
|
-
const logPath = join( outputDir, logFile );
|
|
44
|
-
|
|
45
|
-
appendFileSync( logPath, JSON.stringify( entry ) + EOL, 'utf-8' );
|
|
46
|
-
buildLogTree( logPath );
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Internal use only
|
|
51
|
-
*
|
|
52
|
-
* Adds the start phase of a new event at the default trace for the current workflow.
|
|
53
|
-
*
|
|
54
|
-
* @param {string} id - A unique id for the Event, must be the same across all phases: start, end, error.
|
|
55
|
-
* @param {string} kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
|
|
56
|
-
* @param {string} name - The human friendly name of the Event: eg: query, request, create.
|
|
57
|
-
* @param {any} details - All details attached to this Event Phase. Eg: DB queried records, HTTP response body.
|
|
58
|
-
* @param {string} parentId - The parent Event, used to build a three.
|
|
59
|
-
* @param {string} traceId - The traceId, which identifies from which trace this Event belongs.
|
|
60
|
-
* @param {string} traceHelm - The trace helm, which is a taxonomical naming of the Trace.
|
|
61
|
-
* @returns {void}
|
|
62
|
-
*/
|
|
63
|
-
export const addEventStart = options => addEventPhase( 'start', options );
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Internal use only
|
|
67
|
-
*
|
|
68
|
-
* Adds the end phase at an event at the default trace for the current workflow.
|
|
69
|
-
*
|
|
70
|
-
* It needs to use the same id of the start phase.
|
|
71
|
-
*
|
|
72
|
-
* @param {string} id - A unique id for the Event, must be the same across all phases: start, end, error.
|
|
73
|
-
* @param {string} kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
|
|
74
|
-
* @param {string} name - The human friendly name of the Event: eg: query, request, create.
|
|
75
|
-
* @param {any} details - All details attached to this Event Phase. Eg: DB queried records, HTTP response body.
|
|
76
|
-
* @param {string} parentId - The parent Event, used to build a three.
|
|
77
|
-
* @param {string} traceId - The traceId, which identifies from which trace this Event belongs.
|
|
78
|
-
* @param {string} traceHelm - The trace helm, which is a taxonomical naming of the Trace.
|
|
79
|
-
* @returns {void}
|
|
80
|
-
*/
|
|
81
|
-
export const addEventEnd = options => addEventPhase( 'end', options );
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Internal use only
|
|
85
|
-
*
|
|
86
|
-
* Adds the error phase at an event as error at the default trace for the current workflow.
|
|
87
|
-
*
|
|
88
|
-
* It needs to use the same id of the start phase.
|
|
89
|
-
*
|
|
90
|
-
* @param {string} id - A unique id for the Event, must be the same across all phases: start, end, error.
|
|
91
|
-
* @param {string} kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
|
|
92
|
-
* @param {string} name - The human friendly name of the Event: eg: query, request, create.
|
|
93
|
-
* @param {any} details - All details attached to this Event Phase. Eg: DB queried records, HTTP response body.
|
|
94
|
-
* @param {string} parentId - The parent Event, used to build a three.
|
|
95
|
-
* @param {string} traceId - The traceId, which identifies from which trace this Event belongs.
|
|
96
|
-
* @param {string} traceHelm - The trace helm, which is a taxonomical naming of the Trace.
|
|
97
|
-
* @returns {void}
|
|
98
|
-
*/
|
|
99
|
-
export const addEventError = options => addEventPhase( 'error', options );
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Adds an Event Phase reading traceId, traceHelm and parentId from the context.
|
|
103
|
-
*
|
|
104
|
-
* @param {object} options - The common trace configurations
|
|
105
|
-
*/
|
|
106
|
-
function addEventPhaseWithContext( phase, options ) {
|
|
107
|
-
const storeContent = Storage.load();
|
|
108
|
-
if ( !storeContent ) { // means this was called from a unit test
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
const { parentId, traceId, traceHelm } = storeContent;
|
|
112
|
-
addEventPhase( phase, { ...options, parentId, traceId, traceHelm } );
|
|
113
|
-
};
|
|
1
|
+
import { addEventPhaseWithContext } from './trace_engine.js';
|
|
114
2
|
|
|
115
3
|
/**
|
|
116
4
|
* The public namespace for tracing
|
|
@@ -122,10 +10,11 @@ export const Tracing = {
|
|
|
122
10
|
/**
|
|
123
11
|
* Adds the start phase of a new event at the default trace for the current workflow.
|
|
124
12
|
*
|
|
125
|
-
* @param {
|
|
126
|
-
* @param {string}
|
|
127
|
-
* @param {string}
|
|
128
|
-
* @param {
|
|
13
|
+
* @param {object} args
|
|
14
|
+
* @param {string} args.id - A unique id for the Event, must be the same across all phases: start, end, error.
|
|
15
|
+
* @param {string} args.kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
|
|
16
|
+
* @param {string} args.name - The human friendly name of the Event: query, request, create.
|
|
17
|
+
* @param {object} args.details - All details attached to this Event Phase. DB queried records, HTTP response body.
|
|
129
18
|
* @returns {void}
|
|
130
19
|
*/
|
|
131
20
|
addEventStart: ( { id, kind, name, details } ) => addEventPhaseWithContext( 'start', { kind, name, details, id } ),
|
|
@@ -135,8 +24,9 @@ export const Tracing = {
|
|
|
135
24
|
*
|
|
136
25
|
* It needs to use the same id of the start phase.
|
|
137
26
|
*
|
|
138
|
-
* @param {
|
|
139
|
-
* @param {
|
|
27
|
+
* @param {object} args
|
|
28
|
+
* @param {string} args.id - A unique id for the Event, must be the same across all phases: start, end, error.
|
|
29
|
+
* @param {object} args.details - All details attached to this Event Phase. DB queried records, HTTP response body.
|
|
140
30
|
* @returns {void}
|
|
141
31
|
*/
|
|
142
32
|
addEventEnd: ( { id, details } ) => addEventPhaseWithContext( 'end', { id, details } ),
|
|
@@ -146,8 +36,9 @@ export const Tracing = {
|
|
|
146
36
|
*
|
|
147
37
|
* It needs to use the same id of the start phase.
|
|
148
38
|
*
|
|
149
|
-
* @param {
|
|
150
|
-
* @param {
|
|
39
|
+
* @param {object} args
|
|
40
|
+
* @param {string} args.id - A unique id for the Event, must be the same across all phases: start, end, error.
|
|
41
|
+
* @param {object} args.details - All details attached to this Event Phase. DB queried records, HTTP response body.
|
|
151
42
|
* @returns {void}
|
|
152
43
|
*/
|
|
153
44
|
addEventError: ( { id, details } ) => addEventPhaseWithContext( 'error', { id, details } )
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { addEventPhase, init } from './trace_engine.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Init method, if not called, no processors are attached and trace functions are dummy
|
|
5
|
+
*/
|
|
6
|
+
export { init };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Trace nomenclature
|
|
10
|
+
*
|
|
11
|
+
* Trace - The collection of Events;
|
|
12
|
+
* Event - Any entry in the Trace file, must have the two phases START and END or ERROR;
|
|
13
|
+
* Phase - An specific part of an Event, either START or the conclusive END or ERROR;
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Internal use only
|
|
18
|
+
*
|
|
19
|
+
* Adds the start phase of a new event at the default trace for the current workflow.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} args
|
|
22
|
+
* @param {string} args.id - A unique id for the Event, must be the same across all phases: start, end, error.
|
|
23
|
+
* @param {string} args.kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
|
|
24
|
+
* @param {string} args.name - The human friendly name of the Event: query, request, create.
|
|
25
|
+
* @param {any} args.details - All details attached to this Event Phase. DB queried records, HTTP response body.
|
|
26
|
+
* @param {string} args.parentId - The parent Event, used to build a three.
|
|
27
|
+
* @param {object} args.executionContext - The original execution context from the workflow
|
|
28
|
+
* @returns {void}
|
|
29
|
+
*/
|
|
30
|
+
export const addEventStart = options => addEventPhase( 'start', options );
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Internal use only
|
|
34
|
+
*
|
|
35
|
+
* Adds the end phase at an event at the default trace for the current workflow.
|
|
36
|
+
*
|
|
37
|
+
* It needs to use the same id of the start phase.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} args
|
|
40
|
+
* @param {string} args.id - A unique id for the Event, must be the same across all phases: start, end, error.
|
|
41
|
+
* @param {string} args.kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
|
|
42
|
+
* @param {string} args.name - The human friendly name of the Event: query, request, create.
|
|
43
|
+
* @param {any} args.details - All details attached to this Event Phase. DB queried records, HTTP response body.
|
|
44
|
+
* @param {string} args.parentId - The parent Event, used to build a three.
|
|
45
|
+
* @param {object} args.executionContext - The original execution context from the workflow
|
|
46
|
+
* @returns {void}
|
|
47
|
+
*/
|
|
48
|
+
export const addEventEnd = options => addEventPhase( 'end', options );
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Internal use only
|
|
52
|
+
*
|
|
53
|
+
* Adds the error phase at an event as error at the default trace for the current workflow.
|
|
54
|
+
*
|
|
55
|
+
* It needs to use the same id of the start phase.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} args
|
|
58
|
+
* @param {string} args.id - A unique id for the Event, must be the same across all phases: start, end, error.
|
|
59
|
+
* @param {string} args.kind - The kind of Event, like HTTP, DiskWrite, DBOp, etc.
|
|
60
|
+
* @param {string} args.name - The human friendly name of the Event: query, request, create.
|
|
61
|
+
* @param {any} args.details - All details attached to this Event Phase. DB queried records, HTTP response body.
|
|
62
|
+
* @param {string} args.parentId - The parent Event, used to build a three.
|
|
63
|
+
* @param {object} args.executionContext - The original execution context from the workflow
|
|
64
|
+
* @returns {void}
|
|
65
|
+
*/
|
|
66
|
+
export const addEventError = options => addEventPhase( 'error', options );
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import buildTraceTree from '../../tools/build_trace_tree.js';
|
|
5
|
+
import { EOL } from 'node:os';
|
|
6
|
+
|
|
7
|
+
const oneWeekInMS = 1000 * 60 * 60 * 24 * 7;
|
|
8
|
+
const __dirname = dirname( fileURLToPath( import.meta.url ) );
|
|
9
|
+
const tempDir = join( __dirname, 'temp', 'traces' );
|
|
10
|
+
|
|
11
|
+
const accumulate = ( { entry, executionContext: { workflowId, startTime } } ) => {
|
|
12
|
+
const path = join( tempDir, `${startTime}_${workflowId}.trace` );
|
|
13
|
+
appendFileSync( path, JSON.stringify( entry ) + EOL, 'utf-8' );
|
|
14
|
+
return readFileSync( path, 'utf-8' ).split( EOL ).slice( 0, -1 ).map( v => JSON.parse( v ) );
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const cleanupOldTempFiles = ( threshold = Date.now() - oneWeekInMS ) =>
|
|
18
|
+
readdirSync( tempDir )
|
|
19
|
+
.filter( f => +f.split( '_' )[0] < threshold )
|
|
20
|
+
.forEach( f => rmSync( join( tempDir, f ) ) );
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Init this processor
|
|
24
|
+
*/
|
|
25
|
+
export const init = () => {
|
|
26
|
+
mkdirSync( tempDir, { recursive: true } );
|
|
27
|
+
cleanupOldTempFiles();
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Execute this processor:
|
|
32
|
+
*
|
|
33
|
+
* Persist a trace tree file to local file system, updating upon each new entry
|
|
34
|
+
*
|
|
35
|
+
* @param {object} args
|
|
36
|
+
* @param {object} entry - Trace event phase
|
|
37
|
+
* @param {object} executionContext - Execution info: workflowId, workflowName, startTime
|
|
38
|
+
* @returns
|
|
39
|
+
*/
|
|
40
|
+
export const exec = ( { entry, executionContext } ) => {
|
|
41
|
+
const { workflowId, workflowName, startTime } = executionContext;
|
|
42
|
+
const content = buildTraceTree( accumulate( { entry, executionContext } ) );
|
|
43
|
+
|
|
44
|
+
const timestamp = new Date( startTime ).toISOString().replace( /[:T.]/g, '-' );
|
|
45
|
+
const dir = join( process.argv[2], 'logs', 'runs', workflowName );
|
|
46
|
+
const path = join( dir, `${timestamp}_${workflowId}.json` );
|
|
47
|
+
|
|
48
|
+
mkdirSync( dir, { recursive: true } );
|
|
49
|
+
writeFileSync( path, JSON.stringify( content, undefined, 2 ) + EOL, 'utf-8' );
|
|
50
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// In-memory fs mock store
|
|
4
|
+
const store = { files: new Map() };
|
|
5
|
+
const mkdirSyncMock = vi.fn();
|
|
6
|
+
const writeFileSyncMock = vi.fn();
|
|
7
|
+
const appendFileSyncMock = vi.fn( ( path, data ) => {
|
|
8
|
+
const prev = store.files.get( path ) ?? '';
|
|
9
|
+
store.files.set( path, prev + data );
|
|
10
|
+
} );
|
|
11
|
+
const readFileSyncMock = vi.fn( path => store.files.get( path ) ?? '' );
|
|
12
|
+
const readdirSyncMock = vi.fn( () => [] );
|
|
13
|
+
const rmSyncMock = vi.fn();
|
|
14
|
+
|
|
15
|
+
vi.mock( 'node:fs', () => ( {
|
|
16
|
+
mkdirSync: mkdirSyncMock,
|
|
17
|
+
writeFileSync: writeFileSyncMock,
|
|
18
|
+
appendFileSync: appendFileSyncMock,
|
|
19
|
+
readFileSync: readFileSyncMock,
|
|
20
|
+
readdirSync: readdirSyncMock,
|
|
21
|
+
rmSync: rmSyncMock
|
|
22
|
+
} ) );
|
|
23
|
+
|
|
24
|
+
const buildTraceTreeMock = vi.fn( entries => ( { count: entries.length } ) );
|
|
25
|
+
vi.mock( '../../tools/build_trace_tree.js', () => ( { default: buildTraceTreeMock } ) );
|
|
26
|
+
|
|
27
|
+
describe( 'tracing/processors/local', () => {
|
|
28
|
+
beforeEach( () => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
store.files.clear();
|
|
31
|
+
process.argv[2] = '/tmp/project';
|
|
32
|
+
} );
|
|
33
|
+
|
|
34
|
+
it( 'init(): creates temp dir and cleans up old files', async () => {
|
|
35
|
+
const { init } = await import( './index.js' );
|
|
36
|
+
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
readdirSyncMock.mockReturnValue( [ `${now - ( 8 * 24 * 60 * 60 * 1000 )}_old.trace`, `${now}_new.trace` ] );
|
|
39
|
+
|
|
40
|
+
init();
|
|
41
|
+
|
|
42
|
+
expect( mkdirSyncMock ).toHaveBeenCalledWith( expect.stringMatching( /temp\/traces$/ ), { recursive: true } );
|
|
43
|
+
expect( rmSyncMock ).toHaveBeenCalledTimes( 1 );
|
|
44
|
+
} );
|
|
45
|
+
|
|
46
|
+
it( 'exec(): accumulates entries and writes aggregated tree', async () => {
|
|
47
|
+
const { exec, init } = await import( './index.js' );
|
|
48
|
+
init();
|
|
49
|
+
|
|
50
|
+
const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
|
|
51
|
+
const ctx = { executionContext: { workflowId: 'id1', workflowName: 'WF', startTime } };
|
|
52
|
+
|
|
53
|
+
exec( { ...ctx, entry: { name: 'A', phase: 'start', timestamp: startTime } } );
|
|
54
|
+
exec( { ...ctx, entry: { name: 'A', phase: 'tick', timestamp: startTime + 1 } } );
|
|
55
|
+
exec( { ...ctx, entry: { name: 'A', phase: 'end', timestamp: startTime + 2 } } );
|
|
56
|
+
|
|
57
|
+
// buildTraceTree called with 1, 2, 3 entries respectively
|
|
58
|
+
expect( buildTraceTreeMock ).toHaveBeenCalledTimes( 3 );
|
|
59
|
+
expect( buildTraceTreeMock.mock.calls.at( -1 )[0].length ).toBe( 3 );
|
|
60
|
+
|
|
61
|
+
expect( writeFileSyncMock ).toHaveBeenCalledTimes( 3 );
|
|
62
|
+
const [ writtenPath, content ] = writeFileSyncMock.mock.calls.at( -1 );
|
|
63
|
+
expect( writtenPath ).toMatch( /\/logs\/runs\/WF\// );
|
|
64
|
+
expect( JSON.parse( content.trim() ).count ).toBe( 3 );
|
|
65
|
+
} );
|
|
66
|
+
} );
|
|
67
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { upload } from './s3_client.js';
|
|
2
|
+
import { getRedisClient } from './redis_client.js';
|
|
3
|
+
import buildTraceTree from '../../tools/build_trace_tree.js';
|
|
4
|
+
import { EOL } from 'node:os';
|
|
5
|
+
|
|
6
|
+
const oneMonthInSeconds = 60 * 60 * 24 * 30;
|
|
7
|
+
|
|
8
|
+
const accumulate = async ( { entry, executionContext: { workflowName, workflowId } } ) => {
|
|
9
|
+
const key = `traces/${workflowName}/${workflowId}`;
|
|
10
|
+
const transaction = ( await getRedisClient() ).multi();
|
|
11
|
+
|
|
12
|
+
transaction.zAdd( key, [
|
|
13
|
+
{ score: entry.timestamp, value: JSON.stringify( entry ) }
|
|
14
|
+
], { NX: true } );
|
|
15
|
+
transaction.expire( key, oneMonthInSeconds, 'GT' );
|
|
16
|
+
transaction.zRange( key, 0, -1 );
|
|
17
|
+
const [ ,, zList ] = await transaction.exec();
|
|
18
|
+
return zList.map( v => JSON.parse( v ) );
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const getS3Key = ( { startTime, workflowId, workflowName } ) => {
|
|
22
|
+
const isoDate = new Date( startTime ).toISOString();
|
|
23
|
+
const [ year, month, day ] = isoDate.split( /\D/, 3 );
|
|
24
|
+
const timeStamp = isoDate.replace( /[:T.]/g, '-' );
|
|
25
|
+
return `${workflowName}/${year}/${month}/${day}/${timeStamp}_${workflowId}.json`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Init this processor
|
|
30
|
+
*/
|
|
31
|
+
export const init = async () => {
|
|
32
|
+
await getRedisClient();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Execute this processor: send a complete trace tree file to S3 when the workflow finishes
|
|
37
|
+
*
|
|
38
|
+
* @param {object} args
|
|
39
|
+
* @param {object} entry - Trace event phase
|
|
40
|
+
* @param {object} executionContext - Execution info: workflowId, workflowName, startTime
|
|
41
|
+
*/
|
|
42
|
+
export const exec = async ( { entry, executionContext } ) => {
|
|
43
|
+
const { workflowName, workflowId, startTime } = executionContext;
|
|
44
|
+
const content = buildTraceTree( await accumulate( { entry, executionContext } ) );
|
|
45
|
+
|
|
46
|
+
const isRootWorkflowEnd = !entry.parentId && entry.phase !== 'start';
|
|
47
|
+
return isRootWorkflowEnd ? upload( {
|
|
48
|
+
key: getS3Key( { workflowId, workflowName, startTime } ),
|
|
49
|
+
content: JSON.stringify( content, undefined, 2 ) + EOL
|
|
50
|
+
} ) : 0;
|
|
51
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const redisMulti = {
|
|
4
|
+
zAdd: vi.fn().mockReturnThis(),
|
|
5
|
+
expire: vi.fn().mockReturnThis(),
|
|
6
|
+
zRange: vi.fn().mockReturnThis(),
|
|
7
|
+
exec: vi.fn()
|
|
8
|
+
};
|
|
9
|
+
const getRedisClientMock = vi.fn( async () => ( { multi: () => redisMulti } ) );
|
|
10
|
+
vi.mock( './redis_client.js', () => ( { getRedisClient: getRedisClientMock } ) );
|
|
11
|
+
|
|
12
|
+
const uploadMock = vi.fn();
|
|
13
|
+
vi.mock( './s3_client.js', () => ( { upload: uploadMock } ) );
|
|
14
|
+
|
|
15
|
+
const buildTraceTreeMock = vi.fn( entries => ( { count: entries.length } ) );
|
|
16
|
+
vi.mock( '../../tools/build_trace_tree.js', () => ( { default: buildTraceTreeMock } ) );
|
|
17
|
+
|
|
18
|
+
describe( 'tracing/processors/s3', () => {
|
|
19
|
+
beforeEach( () => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
process.env.TRACE_REMOTE_S3_BUCKET = 'bkt';
|
|
22
|
+
} );
|
|
23
|
+
|
|
24
|
+
it( 'init(): ensures redis client is created', async () => {
|
|
25
|
+
const { init } = await import( './index.js' );
|
|
26
|
+
await init();
|
|
27
|
+
expect( getRedisClientMock ).toHaveBeenCalledTimes( 1 );
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
it( 'exec(): accumulates via redis, uploads only on root workflow end', async () => {
|
|
31
|
+
const { exec } = await import( './index.js' );
|
|
32
|
+
const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
|
|
33
|
+
const ctx = { executionContext: { workflowId: 'id1', workflowName: 'WF', startTime } };
|
|
34
|
+
|
|
35
|
+
// Redis will return progressively larger sorted sets
|
|
36
|
+
redisMulti.exec
|
|
37
|
+
.mockResolvedValueOnce( [ , , [ JSON.stringify( { name: 'A', phase: 'start', timestamp: startTime } ) ] ] )
|
|
38
|
+
.mockResolvedValueOnce( [ , , [
|
|
39
|
+
JSON.stringify( { name: 'A', phase: 'start', timestamp: startTime } ),
|
|
40
|
+
JSON.stringify( { name: 'A', phase: 'tick', timestamp: startTime + 1 } )
|
|
41
|
+
] ] )
|
|
42
|
+
.mockResolvedValueOnce( [ , , [
|
|
43
|
+
JSON.stringify( { name: 'A', phase: 'start', timestamp: startTime } ),
|
|
44
|
+
JSON.stringify( { name: 'A', phase: 'tick', timestamp: startTime + 1 } ),
|
|
45
|
+
JSON.stringify( { name: 'A', phase: 'end', timestamp: startTime + 2 } )
|
|
46
|
+
] ] );
|
|
47
|
+
|
|
48
|
+
await exec( { ...ctx, entry: { name: 'A', phase: 'start', timestamp: startTime, parentId: 'root' } } );
|
|
49
|
+
await exec( { ...ctx, entry: { name: 'A', phase: 'tick', timestamp: startTime + 1, parentId: 'root' } } );
|
|
50
|
+
// Root end: no parentId and not start
|
|
51
|
+
await exec( { ...ctx, entry: { name: 'A', phase: 'end', timestamp: startTime + 2 } } );
|
|
52
|
+
|
|
53
|
+
// Accumulation happened 3 times
|
|
54
|
+
expect( redisMulti.zAdd ).toHaveBeenCalledTimes( 3 );
|
|
55
|
+
expect( buildTraceTreeMock ).toHaveBeenCalledTimes( 3 );
|
|
56
|
+
|
|
57
|
+
// Only last call triggers upload
|
|
58
|
+
expect( uploadMock ).toHaveBeenCalledTimes( 1 );
|
|
59
|
+
const { key, content } = uploadMock.mock.calls[0][0];
|
|
60
|
+
expect( key ).toMatch( /^WF\/2020\/01\/02\// );
|
|
61
|
+
expect( JSON.parse( content.trim() ).count ).toBe( 3 );
|
|
62
|
+
} );
|
|
63
|
+
} );
|
|
64
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createClient } from 'redis';
|
|
2
|
+
import { throws } from '#utils';
|
|
3
|
+
|
|
4
|
+
const state = { client: null };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Return a connected Redis instance
|
|
8
|
+
* @returns {redis.RedisClientType}
|
|
9
|
+
*/
|
|
10
|
+
export async function getRedisClient() {
|
|
11
|
+
const url = process.env.REDIS_URL ?? throws( new Error( 'Missing REDIS_URL environment variable' ) );
|
|
12
|
+
if ( await state.client?.ping().catch( _ => 0 ) === 'PONG' ) {
|
|
13
|
+
return state.client;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const client = createClient( { url, socket: { keepAlive: 15000 } } );
|
|
17
|
+
await client.connect();
|
|
18
|
+
return state.client = client;
|
|
19
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock( '#utils', () => ( {
|
|
4
|
+
throws: e => {
|
|
5
|
+
throw e;
|
|
6
|
+
}
|
|
7
|
+
} ) );
|
|
8
|
+
|
|
9
|
+
const createClientImpl = vi.fn();
|
|
10
|
+
vi.mock( 'redis', () => ( { createClient: opts => createClientImpl( opts ) } ) );
|
|
11
|
+
|
|
12
|
+
async function loadModule() {
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
return import( './redis_client.js' );
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe( 'tracing/processors/s3/redis_client', () => {
|
|
18
|
+
beforeEach( () => {
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
delete process.env.REDIS_URL;
|
|
21
|
+
} );
|
|
22
|
+
|
|
23
|
+
it( 'throws if REDIS_URL is missing', async () => {
|
|
24
|
+
const { getRedisClient } = await loadModule();
|
|
25
|
+
await expect( getRedisClient() ).rejects.toThrow( 'Missing REDIS_URL' );
|
|
26
|
+
} );
|
|
27
|
+
|
|
28
|
+
it( 'creates client with url, connects once, then reuses cached when ping is PONG', async () => {
|
|
29
|
+
process.env.REDIS_URL = 'redis://localhost:6379';
|
|
30
|
+
|
|
31
|
+
const pingMock = vi.fn().mockResolvedValue( 'PONG' );
|
|
32
|
+
const connectMock = vi.fn().mockResolvedValue();
|
|
33
|
+
const created = [];
|
|
34
|
+
createClientImpl.mockImplementation( opts => {
|
|
35
|
+
created.push( opts );
|
|
36
|
+
return { connect: connectMock, ping: pingMock };
|
|
37
|
+
} );
|
|
38
|
+
|
|
39
|
+
const { getRedisClient } = await loadModule();
|
|
40
|
+
|
|
41
|
+
const c1 = await getRedisClient();
|
|
42
|
+
const c2 = await getRedisClient();
|
|
43
|
+
|
|
44
|
+
expect( created ).toHaveLength( 1 );
|
|
45
|
+
expect( connectMock ).toHaveBeenCalledTimes( 1 );
|
|
46
|
+
expect( pingMock ).toHaveBeenCalledTimes( 1 );
|
|
47
|
+
expect( c1 ).toBe( c2 );
|
|
48
|
+
expect( created[0] ).toMatchObject( { url: 'redis://localhost:6379', socket: { keepAlive: 15000 } } );
|
|
49
|
+
} );
|
|
50
|
+
} );
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
2
|
+
import { throws } from '#utils';
|
|
3
|
+
|
|
4
|
+
const state = { s3Client: null };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Return a S3 Client instance
|
|
8
|
+
* @returns {S3Client}
|
|
9
|
+
*/
|
|
10
|
+
const getS3Client = () => {
|
|
11
|
+
if ( state.s3Client ) {
|
|
12
|
+
return state.s3Client;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const region = process.env.AWS_REGION ?? throws( new Error( 'Missing AWS_REGION env var' ) );
|
|
16
|
+
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY ?? throws( new Error( 'Missing AWS_SECRET_ACCESS_KEY env var' ) );
|
|
17
|
+
const accessKeyId = process.env.AWS_ACCESS_KEY_ID ?? throws( new Error( 'Missing AWS_ACCESS_KEY_ID env var' ) );
|
|
18
|
+
|
|
19
|
+
return state.s3Client = new S3Client( { region, secretAccessKey, accessKeyId } );
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Upload given file to S3
|
|
24
|
+
* @param {object} args
|
|
25
|
+
* @param {string} key - S3 file key
|
|
26
|
+
* @param {string} content - File content
|
|
27
|
+
*/
|
|
28
|
+
export const upload = ( { key, content } ) =>
|
|
29
|
+
getS3Client().send( new PutObjectCommand( {
|
|
30
|
+
Bucket: process.env.TRACE_REMOTE_S3_BUCKET ?? throws( new Error( 'Missing TRACE_REMOTE_S3_BUCKET env var' ) ),
|
|
31
|
+
Key: key,
|
|
32
|
+
Body: content
|
|
33
|
+
} ) );
|