@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.
@@ -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
- const accumulate = ( { entry, executionContext: { workflowId, startTime } } ) => {
17
- const path = join( tempTraceFilesDir, `${startTime}_${workflowId}.trace` );
18
- appendFileSync( path, JSON.stringify( entry ) + EOL, 'utf-8' );
19
- return readFileSync( path, 'utf-8' ).split( EOL ).slice( 0, -1 ).map( v => JSON.parse( v ) );
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
- * Persist a trace tree file to local file system, updating upon each new entry
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 - Trace event phase
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 content = buildTraceTree( accumulate( { entry, executionContext } ) );
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, JSON.stringify( content, undefined, 2 ) + EOL, 'utf-8' );
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; // Clear OUTPUT_TRACE_HOST_PATH for clean tests
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(): accumulates entries and writes aggregated tree', async () => {
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 ctx = { executionContext: { workflowId: 'id1', workflowName: 'WF', startTime } };
58
+ const workflowId = 'id1';
59
+ const ctx = { executionContext: { workflowId, workflowName: 'WF', startTime } };
54
60
 
55
- exec( { ...ctx, entry: { name: 'A', phase: 'start', timestamp: startTime } } );
56
- exec( { ...ctx, entry: { name: 'A', phase: 'tick', timestamp: startTime + 1 } } );
57
- exec( { ...ctx, entry: { name: 'A', phase: 'end', timestamp: startTime + 2 } } );
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
- // buildTraceTree called with 1, 2, 3 entries respectively
60
- expect( buildTraceTreeMock ).toHaveBeenCalledTimes( 3 );
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( 3 );
64
- const [ writtenPath, content ] = writeFileSyncMock.mock.calls.at( -1 );
65
- // Changed: Now uses process.cwd() + '/logs' fallback when OUTPUT_TRACE_HOST_PATH not set
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( 'getDestination(): returns absolute path', async () => {
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
- // Should return an absolute path
80
- expect( destination ).toMatch( /^\/|^[A-Z]:\\/i ); // Starting with / or Windows drive letter
81
- expect( destination ).toContain( '/logs/runs/test-workflow/2020-01-02-03-04-05-678Z_workflow-id-123.json' );
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 to container path regardless of OUTPUT_TRACE_HOST_PATH', async () => {
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 ctx = { executionContext: { workflowId: 'id1', workflowName: 'WF', startTime } };
128
+ const workflowId = 'id1';
129
+ const ctx = { executionContext: { workflowId, workflowName: 'WF', startTime } };
94
130
 
95
- exec( { ...ctx, entry: { name: 'A', phase: 'start', timestamp: startTime } } );
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.at( -1 );
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( /logs\/runs\/WF\// );
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
- // Execute to write file
135
- exec( { ...ctx, entry: { name: 'A', phase: 'start', timestamp: startTime } } );
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
- // Verify write path is local
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( /logs\/runs\/test-workflow\// );
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: send a complete trace tree file to S3 when the workflow finishes
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 - Trace event phase
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.phase !== 'start';
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: JSON.stringify( content, undefined, 2 ) + EOL
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', phase: 'start', details: {}, timestamp: startTime };
56
- const activityStart = { id: 'act-1', name: 'DoSomething', kind: 'step', parentId: 'id1', phase: 'start', details: {}, timestamp: startTime + 1 };
57
- const workflowEnd = { id: 'id1', phase: 'end', details: { ok: true }, timestamp: startTime + 2 };
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, phase: 'start', details: {}, timestamp: startTime
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', phase: 'start', details: {}, timestamp: startTime };
117
- const stepEndNoParent = { id: 'step-1', phase: 'end', details: { done: true }, timestamp: startTime + 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, phase: 'end', details: {}, timestamp: startTime
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
- * Build a tree of nodes from a list of entries
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 will be combined according to their phase (start, end OR error).
39
- * - The details of the start phase becomes input, timestamp becomes startedAt;
40
- * - The details of the end phase become output, timestamp becomes endedAt;
41
- * - The details of the error phase become error, timestamp becomes endedAt;
42
- * - Only start phase's kind and name are used;
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, phase, timestamp } = entry;
59
+ const { kind, id, name, parentId, details, action, timestamp } = entry;
57
60
  const node = ensureNode( id );
58
61
 
59
- if ( phase === 'start' ) {
62
+ if ( action === EventAction.START ) {
60
63
  Object.assign( node, { input: details, startedAt: timestamp, kind, name } );
61
- } else if ( phase === 'end' ) {
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 ( phase === 'error' ) {
68
+ } else if ( action === EventAction.ERROR ) {
64
69
  Object.assign( node, { error: details, endedAt: timestamp } );
65
70
  }
66
71
 
67
- if ( parentId && phase === 'start' ) {
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 phase yet', () => {
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, phase: 'start', name: 'wf', details: {}, timestamp: 1000 }
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', phase: 'start', name: 'a', timestamp: 1 },
23
- { id: 'b', parentId: 'a', phase: 'start', name: 'b', timestamp: 2 }
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( 'error phase sets error and endedAt on node', () => {
29
+ it( 'add_attr action merges details.name and details.value into node.attributes', () => {
29
30
  const entries = [
30
- { kind: 'wf', id: 'r', parentId: undefined, phase: 'start', name: 'root', details: {}, timestamp: 100 },
31
- { kind: 'step', id: 's', parentId: 'r', phase: 'start', name: 'step', details: {}, timestamp: 200 },
32
- { id: 's', phase: 'error', details: { message: 'failed' }, timestamp: 300 }
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', phase: 'start', name: 'wf', id: 'wf', parentId: undefined, details: { a: 1 }, timestamp: 1000 },
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', phase: 'start', name: 'eval', id: 'eval', parentId: 'wf', details: { z: 0 }, timestamp: 1500 },
47
- { id: 'eval', phase: 'end', details: { z: 1 }, timestamp: 1600 },
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', phase: 'start', name: 'step-1', id: 's1', parentId: 'wf', details: { x: 1 }, timestamp: 2000 },
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', phase: 'start', name: 'test-1', id: 'io1', parentId: 's1', details: { y: 2 }, timestamp: 2300 },
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', phase: 'start', name: 'step-2', id: 's2', parentId: 'wf', details: { x: 2 }, timestamp: 2400 },
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', phase: 'start', name: 'test-2', id: 'io2', parentId: 's2', details: { y: 3 }, timestamp: 2500 },
56
- { id: 'io2', phase: 'end', details: { y: 4 }, timestamp: 2600 },
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', phase: 'end', details: { y: 5 }, timestamp: 2700 },
95
+ { id: 'io1', action: EventAction.END, details: { y: 5 }, timestamp: 2700 },
59
96
  // step1 end
60
- { id: 's1', phase: 'end', details: { done: true }, timestamp: 2800 },
97
+ { id: 's1', action: EventAction.END, details: { done: true }, timestamp: 2800 },
61
98
  // step2 end
62
- { id: 's2', phase: 'end', details: { done: true }, timestamp: 2900 },
99
+ { id: 's2', action: EventAction.END, details: { done: true }, timestamp: 2900 },
63
100
  // workflow end
64
- { id: 'wf', phase: 'end', details: { ok: true }, timestamp: 3000 }
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
+ };