@outputai/core 0.4.1-dev.622e67b.0 → 0.4.1-dev.7aa9a5f.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 +5 -1
- package/src/activity_integration/tracing.d.ts +5 -10
- package/src/activity_integration/tracing.js +4 -9
- package/src/consts.js +4 -0
- package/src/interface/aggregations.js +24 -0
- package/src/interface/aggregations.spec.js +91 -0
- package/src/interface/workflow.js +35 -17
- package/src/interface/workflow.spec.js +183 -7
- package/src/tracing/processors/local/index.js +10 -4
- package/src/tracing/processors/local/index.spec.js +52 -21
- package/src/tracing/processors/s3/index.js +3 -3
- package/src/tracing/processors/s3/index.spec.js +26 -1
- package/src/tracing/processors/s3/s3_client.js +11 -3
- package/src/tracing/processors/s3/s3_client.spec.js +27 -15
- package/src/tracing/tools/build_trace_tree.js +1 -1
- package/src/tracing/tools/build_trace_tree.spec.js +49 -11
- package/src/tracing/tools/utils.js +0 -28
- package/src/tracing/tools/utils.spec.js +2 -134
- package/src/tracing/trace_attribute.d.ts +38 -0
- package/src/tracing/trace_attribute.js +80 -0
- package/src/tracing/trace_engine.js +12 -2
- package/src/worker/index.js +1 -1
- package/src/worker/index.spec.js +1 -1
- package/src/worker/interceptors/activity.js +9 -2
- package/src/worker/interceptors/activity.spec.js +16 -3
- package/src/worker/interceptors.js +2 -2
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { EOL } from 'node:os';
|
|
3
2
|
|
|
4
3
|
// In-memory fs mock store
|
|
5
4
|
const store = { files: new Map() };
|
|
@@ -12,6 +11,7 @@ const appendFileSyncMock = vi.fn( ( path, data ) => {
|
|
|
12
11
|
const readFileSyncMock = vi.fn( path => store.files.get( path ) ?? '' );
|
|
13
12
|
const readdirSyncMock = vi.fn( () => [] );
|
|
14
13
|
const rmSyncMock = vi.fn();
|
|
14
|
+
const createWriteStreamMock = vi.fn( path => ( { path } ) );
|
|
15
15
|
|
|
16
16
|
vi.mock( 'node:fs', () => ( {
|
|
17
17
|
mkdirSync: mkdirSyncMock,
|
|
@@ -19,9 +19,36 @@ vi.mock( 'node:fs', () => ( {
|
|
|
19
19
|
appendFileSync: appendFileSyncMock,
|
|
20
20
|
readFileSync: readFileSyncMock,
|
|
21
21
|
readdirSync: readdirSyncMock,
|
|
22
|
-
rmSync: rmSyncMock
|
|
22
|
+
rmSync: rmSyncMock,
|
|
23
|
+
createWriteStream: createWriteStreamMock
|
|
23
24
|
} ) );
|
|
24
25
|
|
|
26
|
+
const pipelineMock = vi.fn( async ( source, destination ) => {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
for await ( const chunk of source ) {
|
|
29
|
+
chunks.push( Buffer.isBuffer( chunk ) ? chunk : Buffer.from( chunk ) );
|
|
30
|
+
}
|
|
31
|
+
store.files.set( destination.path, Buffer.concat( chunks ).toString( 'utf8' ) );
|
|
32
|
+
} );
|
|
33
|
+
vi.mock( 'node:stream/promises', () => ( { pipeline: pipelineMock } ) );
|
|
34
|
+
|
|
35
|
+
vi.mock( 'json-stream-stringify', async () => {
|
|
36
|
+
const { Readable } = await import( 'node:stream' );
|
|
37
|
+
return {
|
|
38
|
+
JsonStreamStringify: class extends Readable {
|
|
39
|
+
constructor( body ) {
|
|
40
|
+
super();
|
|
41
|
+
this.body = body;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_read() {
|
|
45
|
+
this.push( JSON.stringify( this.body ) );
|
|
46
|
+
this.push( null );
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
} );
|
|
51
|
+
|
|
25
52
|
const buildTraceTreeMock = vi.fn( entries => ( { count: entries.length } ) );
|
|
26
53
|
vi.mock( '../../tools/build_trace_tree.js', () => ( { default: buildTraceTreeMock } ) );
|
|
27
54
|
|
|
@@ -58,17 +85,18 @@ describe( 'tracing/processors/local', () => {
|
|
|
58
85
|
const workflowId = 'id1';
|
|
59
86
|
const ctx = { executionContext: { workflowId, workflowName: 'WF', startTime } };
|
|
60
87
|
|
|
61
|
-
exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
62
|
-
exec( { ...ctx, entry: childTick( 'child-1', startTime + 1 ) } );
|
|
63
|
-
exec( { ...ctx, entry: rootEnd( workflowId, startTime + 2 ) } );
|
|
88
|
+
await exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
89
|
+
await exec( { ...ctx, entry: childTick( 'child-1', startTime + 1 ) } );
|
|
90
|
+
await exec( { ...ctx, entry: rootEnd( workflowId, startTime + 2 ) } );
|
|
64
91
|
|
|
65
92
|
expect( buildTraceTreeMock ).toHaveBeenCalledTimes( 1 );
|
|
66
93
|
expect( buildTraceTreeMock.mock.calls[0][0] ).toHaveLength( 3 );
|
|
67
94
|
|
|
68
|
-
expect(
|
|
69
|
-
|
|
95
|
+
expect( createWriteStreamMock ).toHaveBeenCalledTimes( 1 );
|
|
96
|
+
expect( pipelineMock ).toHaveBeenCalledTimes( 1 );
|
|
97
|
+
const [ writtenPath ] = createWriteStreamMock.mock.calls[0];
|
|
70
98
|
expect( writtenPath ).toMatch( /\/tmp\/project\/logs\/runs\/WF\// );
|
|
71
|
-
expect( JSON.parse(
|
|
99
|
+
expect( JSON.parse( store.files.get( writtenPath ) ).count ).toBe( 3 );
|
|
72
100
|
} );
|
|
73
101
|
|
|
74
102
|
it( 'exec(): does not build or write on non-flush entries', async () => {
|
|
@@ -79,11 +107,13 @@ describe( 'tracing/processors/local', () => {
|
|
|
79
107
|
const workflowId = 'id1';
|
|
80
108
|
const ctx = { executionContext: { workflowId, workflowName: 'WF', startTime } };
|
|
81
109
|
|
|
82
|
-
exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
83
|
-
exec( { ...ctx, entry: childTick( 'child-1', startTime + 1 ) } );
|
|
110
|
+
await exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
111
|
+
await exec( { ...ctx, entry: childTick( 'child-1', startTime + 1 ) } );
|
|
84
112
|
|
|
85
113
|
expect( buildTraceTreeMock ).not.toHaveBeenCalled();
|
|
86
114
|
expect( writeFileSyncMock ).not.toHaveBeenCalled();
|
|
115
|
+
expect( createWriteStreamMock ).not.toHaveBeenCalled();
|
|
116
|
+
expect( pipelineMock ).not.toHaveBeenCalled();
|
|
87
117
|
} );
|
|
88
118
|
|
|
89
119
|
it( 'exec(): flushes on error action before root end', async () => {
|
|
@@ -94,12 +124,13 @@ describe( 'tracing/processors/local', () => {
|
|
|
94
124
|
const workflowId = 'id1';
|
|
95
125
|
const ctx = { executionContext: { workflowId, workflowName: 'WF', startTime } };
|
|
96
126
|
|
|
97
|
-
exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
98
|
-
exec( { ...ctx, entry: { id: 'step-1', action: 'error', timestamp: startTime + 1 } } );
|
|
127
|
+
await exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
128
|
+
await exec( { ...ctx, entry: { id: 'step-1', action: 'error', timestamp: startTime + 1 } } );
|
|
99
129
|
|
|
100
130
|
expect( buildTraceTreeMock ).toHaveBeenCalledTimes( 1 );
|
|
101
131
|
expect( buildTraceTreeMock.mock.calls[0][0] ).toHaveLength( 2 );
|
|
102
|
-
expect(
|
|
132
|
+
expect( createWriteStreamMock ).toHaveBeenCalledTimes( 1 );
|
|
133
|
+
expect( pipelineMock ).toHaveBeenCalledTimes( 1 );
|
|
103
134
|
} );
|
|
104
135
|
|
|
105
136
|
it( 'getDestination(): returns absolute path under callerDir logs', async () => {
|
|
@@ -128,11 +159,11 @@ describe( 'tracing/processors/local', () => {
|
|
|
128
159
|
const workflowId = 'id1';
|
|
129
160
|
const ctx = { executionContext: { workflowId, workflowName: 'WF', startTime } };
|
|
130
161
|
|
|
131
|
-
exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
132
|
-
exec( { ...ctx, entry: rootEnd( workflowId, startTime + 1 ) } );
|
|
162
|
+
await exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
163
|
+
await exec( { ...ctx, entry: rootEnd( workflowId, startTime + 1 ) } );
|
|
133
164
|
|
|
134
|
-
expect(
|
|
135
|
-
const [ writtenPath ] =
|
|
165
|
+
expect( createWriteStreamMock ).toHaveBeenCalledTimes( 1 );
|
|
166
|
+
const [ writtenPath ] = createWriteStreamMock.mock.calls[0];
|
|
136
167
|
|
|
137
168
|
expect( writtenPath ).not.toContain( '/host/path/logs' );
|
|
138
169
|
expect( writtenPath ).toMatch( /\/tmp\/project\/logs\/runs\/WF\// );
|
|
@@ -164,15 +195,15 @@ describe( 'tracing/processors/local', () => {
|
|
|
164
195
|
const workflowName = 'test-workflow';
|
|
165
196
|
const ctx = { executionContext: { workflowId, workflowName, startTime } };
|
|
166
197
|
|
|
167
|
-
exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
168
|
-
exec( { ...ctx, entry: rootEnd( workflowId, startTime + 1 ) } );
|
|
198
|
+
await exec( { ...ctx, entry: rootStart( workflowId, startTime ) } );
|
|
199
|
+
await exec( { ...ctx, entry: rootEnd( workflowId, startTime + 1 ) } );
|
|
169
200
|
|
|
170
201
|
const destination = getDestination( { startTime, workflowId, workflowName } );
|
|
171
202
|
|
|
172
|
-
const [ writtenPath
|
|
203
|
+
const [ writtenPath ] = createWriteStreamMock.mock.calls[0];
|
|
173
204
|
expect( writtenPath ).not.toContain( '/Users/ben/project' );
|
|
174
205
|
expect( writtenPath ).toMatch( /\/tmp\/project\/logs\/runs\/test-workflow\// );
|
|
175
|
-
expect(
|
|
206
|
+
expect( JSON.parse( store.files.get( writtenPath ) ).count ).toBe( 2 );
|
|
176
207
|
|
|
177
208
|
expect( destination ).toBe( '/Users/ben/project/logs/runs/test-workflow/2020-01-02-03-04-05-678Z_workflow-id-123.json' );
|
|
178
209
|
} );
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { upload } from './s3_client.js';
|
|
2
2
|
import { getRedisClient } from './redis_client.js';
|
|
3
3
|
import buildTraceTree from '../../tools/build_trace_tree.js';
|
|
4
|
-
import { EOL } from 'node:os';
|
|
5
4
|
import { loadEnv, getVars } from './configs.js';
|
|
6
5
|
import { createChildLogger } from '#logger';
|
|
7
|
-
import {
|
|
6
|
+
import { JsonStreamStringify } from 'json-stream-stringify';
|
|
8
7
|
|
|
9
8
|
const log = createChildLogger( 'S3 Processor' );
|
|
10
9
|
|
|
@@ -100,9 +99,10 @@ export const exec = async ( { entry, executionContext } ) => {
|
|
|
100
99
|
log.warn( 'Incomplete trace file discarded', { workflowId, error: 'incomplete_trace_file' } );
|
|
101
100
|
return;
|
|
102
101
|
}
|
|
102
|
+
|
|
103
103
|
await upload( {
|
|
104
104
|
key: getS3Key( { workflowId, workflowName, startTime } ),
|
|
105
|
-
content:
|
|
105
|
+
content: new JsonStreamStringify( content )
|
|
106
106
|
} );
|
|
107
107
|
await bustEntries( cacheKey );
|
|
108
108
|
};
|
|
@@ -27,6 +27,31 @@ vi.mock( './s3_client.js', () => ( { upload: uploadMock } ) );
|
|
|
27
27
|
const buildTraceTreeMock = vi.fn( entries => ( { count: entries.length } ) );
|
|
28
28
|
vi.mock( '../../tools/build_trace_tree.js', () => ( { default: buildTraceTreeMock } ) );
|
|
29
29
|
|
|
30
|
+
vi.mock( 'json-stream-stringify', async () => {
|
|
31
|
+
const { Readable } = await import( 'node:stream' );
|
|
32
|
+
return {
|
|
33
|
+
JsonStreamStringify: class extends Readable {
|
|
34
|
+
constructor( body ) {
|
|
35
|
+
super();
|
|
36
|
+
this.body = body;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_read() {
|
|
40
|
+
this.push( JSON.stringify( this.body ) );
|
|
41
|
+
this.push( null );
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
const streamToString = async stream => {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
for await ( const chunk of stream ) {
|
|
50
|
+
chunks.push( Buffer.isBuffer( chunk ) ? chunk : Buffer.from( chunk ) );
|
|
51
|
+
}
|
|
52
|
+
return Buffer.concat( chunks ).toString( 'utf8' );
|
|
53
|
+
};
|
|
54
|
+
|
|
30
55
|
describe( 'tracing/processors/s3', () => {
|
|
31
56
|
beforeEach( () => {
|
|
32
57
|
vi.useFakeTimers();
|
|
@@ -74,7 +99,7 @@ describe( 'tracing/processors/s3', () => {
|
|
|
74
99
|
expect( uploadMock ).toHaveBeenCalledTimes( 1 );
|
|
75
100
|
const { key, content } = uploadMock.mock.calls[0][0];
|
|
76
101
|
expect( key ).toMatch( /^WF\/2020\/01\/02\// );
|
|
77
|
-
expect( JSON.parse( content
|
|
102
|
+
expect( JSON.parse( await streamToString( content ) ).count ).toBe( 3 );
|
|
78
103
|
expect( delMock ).toHaveBeenCalledTimes( 1 );
|
|
79
104
|
expect( delMock ).toHaveBeenCalledWith( 'traces/WF/id1' );
|
|
80
105
|
} );
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { S3Client
|
|
1
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
2
|
+
import { Upload } from '@aws-sdk/lib-storage';
|
|
2
3
|
import { getVars } from './configs.js';
|
|
3
4
|
|
|
4
5
|
const state = { s3Client: null };
|
|
@@ -21,7 +22,14 @@ const getS3Client = () => {
|
|
|
21
22
|
* Upload given file to S3
|
|
22
23
|
* @param {object} args
|
|
23
24
|
* @param {string} key - S3 file key
|
|
24
|
-
* @param {string} content - File content
|
|
25
|
+
* @param {string|import('node:stream').Readable} content - File content
|
|
25
26
|
*/
|
|
26
27
|
export const upload = ( { key, content } ) =>
|
|
27
|
-
|
|
28
|
+
new Upload( {
|
|
29
|
+
client: getS3Client(),
|
|
30
|
+
params: {
|
|
31
|
+
Bucket: getVars().remoteS3Bucket,
|
|
32
|
+
Key: key,
|
|
33
|
+
Body: content
|
|
34
|
+
}
|
|
35
|
+
} ).done();
|
|
@@ -9,24 +9,27 @@ vi.mock( '#utils', () => ( {
|
|
|
9
9
|
const getVarsMock = vi.fn();
|
|
10
10
|
vi.mock( './configs', () => ( { getVars: () => getVarsMock() } ) );
|
|
11
11
|
|
|
12
|
-
const sendMock = vi.fn();
|
|
13
12
|
const ctorState = { args: null };
|
|
14
13
|
class S3ClientMock {
|
|
15
14
|
constructor( args ) {
|
|
16
15
|
ctorState.args = args;
|
|
17
|
-
} send = sendMock;
|
|
18
|
-
}
|
|
19
|
-
class PutObjectCommandMock {
|
|
20
|
-
constructor( input ) {
|
|
21
|
-
this.input = input;
|
|
22
16
|
}
|
|
23
17
|
}
|
|
24
|
-
|
|
25
18
|
vi.mock( '@aws-sdk/client-s3', () => ( {
|
|
26
|
-
S3Client: S3ClientMock
|
|
27
|
-
PutObjectCommand: PutObjectCommandMock
|
|
19
|
+
S3Client: S3ClientMock
|
|
28
20
|
} ) );
|
|
29
21
|
|
|
22
|
+
const uploadDoneMock = vi.fn();
|
|
23
|
+
const uploadCtorState = { args: [] };
|
|
24
|
+
class UploadMock {
|
|
25
|
+
constructor( args ) {
|
|
26
|
+
uploadCtorState.args.push( args );
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
done = uploadDoneMock;
|
|
30
|
+
}
|
|
31
|
+
vi.mock( '@aws-sdk/lib-storage', () => ( { Upload: UploadMock } ) );
|
|
32
|
+
|
|
30
33
|
async function loadModule() {
|
|
31
34
|
vi.resetModules();
|
|
32
35
|
return import( './s3_client.js' );
|
|
@@ -35,6 +38,9 @@ async function loadModule() {
|
|
|
35
38
|
describe( 'tracing/processors/s3/s3_client', () => {
|
|
36
39
|
beforeEach( () => {
|
|
37
40
|
vi.clearAllMocks();
|
|
41
|
+
ctorState.args = null;
|
|
42
|
+
uploadCtorState.args = [];
|
|
43
|
+
uploadDoneMock.mockResolvedValue( undefined );
|
|
38
44
|
getVarsMock.mockReturnValue( {
|
|
39
45
|
awsRegion: 'us-east-1',
|
|
40
46
|
awsAccessKeyId: 'id',
|
|
@@ -48,15 +54,21 @@ describe( 'tracing/processors/s3/s3_client', () => {
|
|
|
48
54
|
|
|
49
55
|
await upload( { key: 'wf/key.json', content: '{"a":1}' } );
|
|
50
56
|
|
|
51
|
-
expect( ctorState.args ).toEqual( {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
expect(
|
|
57
|
+
expect( ctorState.args ).toEqual( {
|
|
58
|
+
region: 'us-east-1',
|
|
59
|
+
credentials: { secretAccessKey: 'sek', accessKeyId: 'id' }
|
|
60
|
+
} );
|
|
61
|
+
expect( uploadCtorState.args ).toHaveLength( 1 );
|
|
62
|
+
expect( uploadCtorState.args[0] ).toEqual( {
|
|
63
|
+
client: expect.any( S3ClientMock ),
|
|
64
|
+
params: { Bucket: 'bucket', Key: 'wf/key.json', Body: '{"a":1}' }
|
|
65
|
+
} );
|
|
66
|
+
expect( uploadDoneMock ).toHaveBeenCalledTimes( 1 );
|
|
56
67
|
|
|
57
68
|
// subsequent upload uses cached client
|
|
58
69
|
await upload( { key: 'wf/key2.json', content: '{}' } );
|
|
59
|
-
expect(
|
|
70
|
+
expect( uploadCtorState.args ).toHaveLength( 2 );
|
|
71
|
+
expect( uploadDoneMock ).toHaveBeenCalledTimes( 2 );
|
|
60
72
|
} );
|
|
61
73
|
} );
|
|
62
74
|
|
|
@@ -62,7 +62,7 @@ export default entries => {
|
|
|
62
62
|
if ( action === EventAction.START ) {
|
|
63
63
|
Object.assign( node, { input: details, startedAt: timestamp, kind, name } );
|
|
64
64
|
} else if ( action === EventAction.ADD_ATTR ) {
|
|
65
|
-
node.attributes[details.
|
|
65
|
+
node.attributes[details.type] = details;
|
|
66
66
|
} else if ( action === EventAction.END ) {
|
|
67
67
|
Object.assign( node, { output: details, endedAt: timestamp } );
|
|
68
68
|
} else if ( action === EventAction.ERROR ) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { EventAction } from '../trace_consts.js';
|
|
3
|
+
import { Attribute } from '#trace_attribute';
|
|
3
4
|
import buildTraceTree from './build_trace_tree.js';
|
|
4
5
|
|
|
5
6
|
describe( 'build_trace_tree', () => {
|
|
@@ -26,34 +27,66 @@ this can indicate it timed out or was interrupted.>>' );
|
|
|
26
27
|
expect( buildTraceTree( entries ) ).toBeNull();
|
|
27
28
|
} );
|
|
28
29
|
|
|
29
|
-
it( 'add_attr action
|
|
30
|
+
it( 'add_attr action stores attribute details by type on node.attributes', () => {
|
|
31
|
+
const requestCount = {
|
|
32
|
+
type: Attribute.HTTPRequestCount.TYPE,
|
|
33
|
+
url: 'https://api.example.test',
|
|
34
|
+
requestId: 'req-1'
|
|
35
|
+
};
|
|
36
|
+
const requestCost = {
|
|
37
|
+
type: Attribute.HTTPRequestCost.TYPE,
|
|
38
|
+
url: 'https://api.example.test',
|
|
39
|
+
requestId: 'req-1',
|
|
40
|
+
total: 0.2
|
|
41
|
+
};
|
|
30
42
|
const entries = [
|
|
31
43
|
{ kind: 'workflow', id: 'wf', parentId: undefined, action: EventAction.START, name: 'wf', details: {}, timestamp: 100 },
|
|
32
44
|
{ kind: 'step', id: 's', parentId: 'wf', action: EventAction.START, name: 'step', details: {}, timestamp: 200 },
|
|
33
|
-
{ id: 's', action: EventAction.ADD_ATTR, details:
|
|
34
|
-
{ id: 's', action: EventAction.ADD_ATTR, details:
|
|
45
|
+
{ id: 's', action: EventAction.ADD_ATTR, details: requestCount, timestamp: 250 },
|
|
46
|
+
{ id: 's', action: EventAction.ADD_ATTR, details: requestCost, timestamp: 260 },
|
|
35
47
|
{ id: 'wf', action: EventAction.END, details: {}, timestamp: 300 }
|
|
36
48
|
];
|
|
37
49
|
const result = buildTraceTree( entries );
|
|
38
50
|
expect( result ).not.toBeNull();
|
|
39
|
-
expect( result.children[0].attributes ).toEqual( {
|
|
51
|
+
expect( result.children[0].attributes ).toEqual( {
|
|
52
|
+
[Attribute.HTTPRequestCount.TYPE]: requestCount,
|
|
53
|
+
[Attribute.HTTPRequestCost.TYPE]: requestCost
|
|
54
|
+
} );
|
|
40
55
|
} );
|
|
41
56
|
|
|
42
|
-
it( 'add_attr action overwrites prior value for the same attribute
|
|
57
|
+
it( 'add_attr action overwrites prior value for the same attribute type', () => {
|
|
58
|
+
const firstCost = {
|
|
59
|
+
type: Attribute.HTTPRequestCost.TYPE,
|
|
60
|
+
url: 'https://api.example.test',
|
|
61
|
+
requestId: 'req-1',
|
|
62
|
+
total: 1
|
|
63
|
+
};
|
|
64
|
+
const secondCost = {
|
|
65
|
+
type: Attribute.HTTPRequestCost.TYPE,
|
|
66
|
+
url: 'https://api.example.test',
|
|
67
|
+
requestId: 'req-1',
|
|
68
|
+
total: 2
|
|
69
|
+
};
|
|
43
70
|
const entries = [
|
|
44
71
|
{ kind: 'workflow', id: 'wf', parentId: undefined, action: EventAction.START, name: 'wf', details: {}, timestamp: 1 },
|
|
45
|
-
{ id: 'wf', action: EventAction.ADD_ATTR, details:
|
|
46
|
-
{ id: 'wf', action: EventAction.ADD_ATTR, details:
|
|
72
|
+
{ id: 'wf', action: EventAction.ADD_ATTR, details: firstCost, timestamp: 2 },
|
|
73
|
+
{ id: 'wf', action: EventAction.ADD_ATTR, details: secondCost, timestamp: 3 },
|
|
47
74
|
{ id: 'wf', action: EventAction.END, details: {}, timestamp: 4 }
|
|
48
75
|
];
|
|
49
76
|
const result = buildTraceTree( entries );
|
|
50
|
-
expect( result.attributes ).toEqual( {
|
|
77
|
+
expect( result.attributes ).toEqual( { [Attribute.HTTPRequestCost.TYPE]: secondCost } );
|
|
51
78
|
} );
|
|
52
79
|
|
|
53
80
|
it( 'add_attr does not attach nodes as children (only start does)', () => {
|
|
54
81
|
const entries = [
|
|
55
82
|
{ kind: 'workflow', id: 'wf', parentId: undefined, action: EventAction.START, name: 'wf', details: {}, timestamp: 1 },
|
|
56
|
-
{
|
|
83
|
+
{
|
|
84
|
+
id: 'orphan',
|
|
85
|
+
parentId: 'wf',
|
|
86
|
+
action: EventAction.ADD_ATTR,
|
|
87
|
+
details: { type: Attribute.HTTPRequestCount.TYPE, url: 'https://api.example.test', requestId: 'req-1' },
|
|
88
|
+
timestamp: 2
|
|
89
|
+
},
|
|
57
90
|
{ id: 'wf', action: EventAction.END, details: {}, timestamp: 3 }
|
|
58
91
|
];
|
|
59
92
|
const result = buildTraceTree( entries );
|
|
@@ -75,6 +108,11 @@ this can indicate it timed out or was interrupted.>>' );
|
|
|
75
108
|
} );
|
|
76
109
|
|
|
77
110
|
it( 'builds a tree from workflow/step/IO entries with grouping and sorting', () => {
|
|
111
|
+
const stepAttribute = {
|
|
112
|
+
type: Attribute.HTTPRequestCount.TYPE,
|
|
113
|
+
url: 'https://api.example.test/step-1',
|
|
114
|
+
requestId: 'req-step-1'
|
|
115
|
+
};
|
|
78
116
|
const entries = [
|
|
79
117
|
// workflow start
|
|
80
118
|
{ kind: 'workflow', action: EventAction.START, name: 'wf', id: 'wf', parentId: undefined, details: { a: 1 }, timestamp: 1000 },
|
|
@@ -83,7 +121,7 @@ this can indicate it timed out or was interrupted.>>' );
|
|
|
83
121
|
{ id: 'eval', action: EventAction.END, details: { z: 1 }, timestamp: 1600 },
|
|
84
122
|
// step1 start
|
|
85
123
|
{ 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:
|
|
124
|
+
{ id: 's1', action: EventAction.ADD_ATTR, details: stepAttribute, timestamp: 2050 },
|
|
87
125
|
// IO under step1
|
|
88
126
|
{ kind: 'IO', action: EventAction.START, name: 'test-1', id: 'io1', parentId: 's1', details: { y: 2 }, timestamp: 2300 },
|
|
89
127
|
// step2 start
|
|
@@ -132,7 +170,7 @@ this can indicate it timed out or was interrupted.>>' );
|
|
|
132
170
|
endedAt: 2800,
|
|
133
171
|
input: { x: 1 },
|
|
134
172
|
output: { done: true },
|
|
135
|
-
attributes: {
|
|
173
|
+
attributes: { [Attribute.HTTPRequestCount.TYPE]: stepAttribute },
|
|
136
174
|
children: [
|
|
137
175
|
{
|
|
138
176
|
id: 'io1',
|
|
@@ -19,31 +19,3 @@ export const serializeError = error =>
|
|
|
19
19
|
message: error.message,
|
|
20
20
|
stack: error.stack
|
|
21
21
|
};
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Tries to stringify an object to an indented JSON string.
|
|
25
|
-
* If its byte size is bigger than threshold returns a plain JSON string without formatting.
|
|
26
|
-
*
|
|
27
|
-
* @param {object|array} content
|
|
28
|
-
* @param {*} [threshold] - The max allowed size to try to stringify with formatting (in bytes). Default is 50mb
|
|
29
|
-
* @returns {string} String representation of the object
|
|
30
|
-
*/
|
|
31
|
-
export const safeFormatJSON = ( content, threshold = 50 * 1024 * 1024 /* 50mb */ ) => {
|
|
32
|
-
const plainString = JSON.stringify( content );
|
|
33
|
-
const plainStringSize = Buffer.byteLength( plainString, 'utf8' );
|
|
34
|
-
|
|
35
|
-
if ( plainStringSize > threshold ) {
|
|
36
|
-
return plainString;
|
|
37
|
-
}
|
|
38
|
-
try {
|
|
39
|
-
return JSON.stringify( content, undefined, 2 );
|
|
40
|
-
} catch ( error ) {
|
|
41
|
-
// Only handles this specific error because other common parsing errors like:
|
|
42
|
-
// "TypeError: cyclic object value" and "RangeError: Maximum call stack size exceeded"
|
|
43
|
-
// would have been thrown on the first parsing.
|
|
44
|
-
if ( error instanceof RangeError && error.message === 'Invalid string length' ) {
|
|
45
|
-
return plainString;
|
|
46
|
-
}
|
|
47
|
-
throw error;
|
|
48
|
-
}
|
|
49
|
-
};
|
|
@@ -1,15 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
const isPrettyStringifyCall = args => args.length >= 3 && args[2] === 2;
|
|
5
|
-
|
|
6
|
-
/** @param {number} targetBytes UTF-8 size of compact JSON.stringify( { a: "<xs>" } ) */
|
|
7
|
-
const objectWithCompactByteLength = targetBytes => {
|
|
8
|
-
const sample = { a: '' };
|
|
9
|
-
const overhead = Buffer.byteLength( JSON.stringify( sample ), 'utf8' );
|
|
10
|
-
const repeat = Math.max( 0, targetBytes - overhead );
|
|
11
|
-
return { a: 'x'.repeat( repeat ) };
|
|
12
|
-
};
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { serializeError } from './utils.js';
|
|
13
3
|
|
|
14
4
|
describe( 'tracing/utils', () => {
|
|
15
5
|
it( 'serializeError unwraps causes and keeps message/stack', () => {
|
|
@@ -21,126 +11,4 @@ describe( 'tracing/utils', () => {
|
|
|
21
11
|
expect( out.message ).toBe( 'inner' );
|
|
22
12
|
expect( typeof out.stack ).toBe( 'string' );
|
|
23
13
|
} );
|
|
24
|
-
|
|
25
|
-
describe( 'safeFormatJSON', () => {
|
|
26
|
-
it( 'formats small objects with indentation when under threshold', () => {
|
|
27
|
-
const content = { a: 1, b: [ 2, 3 ] };
|
|
28
|
-
const out = safeFormatJSON( content, 10_000 );
|
|
29
|
-
|
|
30
|
-
expect( out ).toContain( '\n' );
|
|
31
|
-
expect( out ).toMatch( /^\{\n/ );
|
|
32
|
-
expect( JSON.parse( out ) ).toEqual( content );
|
|
33
|
-
} );
|
|
34
|
-
|
|
35
|
-
it( 'formats small arrays with indentation when under threshold', () => {
|
|
36
|
-
const content = [ 1, { nested: true } ];
|
|
37
|
-
const out = safeFormatJSON( content, 10_000 );
|
|
38
|
-
|
|
39
|
-
expect( out ).toContain( '\n' );
|
|
40
|
-
expect( out.trimStart() ).toMatch( /^\[/ );
|
|
41
|
-
expect( JSON.parse( out ) ).toEqual( content );
|
|
42
|
-
} );
|
|
43
|
-
|
|
44
|
-
it( 'returns compact JSON when compact UTF-8 size is strictly greater than threshold', () => {
|
|
45
|
-
const content = objectWithCompactByteLength( 40 );
|
|
46
|
-
const compact = JSON.stringify( content );
|
|
47
|
-
expect( Buffer.byteLength( compact, 'utf8' ) ).toBe( 40 );
|
|
48
|
-
|
|
49
|
-
const out = safeFormatJSON( content, 39 );
|
|
50
|
-
expect( out ).toBe( compact );
|
|
51
|
-
expect( out ).not.toContain( '\n ' );
|
|
52
|
-
expect( JSON.parse( out ) ).toEqual( content );
|
|
53
|
-
} );
|
|
54
|
-
|
|
55
|
-
it( 'uses pretty JSON when compact UTF-8 size equals threshold', () => {
|
|
56
|
-
const content = objectWithCompactByteLength( 40 );
|
|
57
|
-
const compact = JSON.stringify( content );
|
|
58
|
-
expect( Buffer.byteLength( compact, 'utf8' ) ).toBe( 40 );
|
|
59
|
-
|
|
60
|
-
const out = safeFormatJSON( content, 40 );
|
|
61
|
-
expect( out ).not.toBe( compact );
|
|
62
|
-
expect( out ).toContain( '\n' );
|
|
63
|
-
expect( JSON.parse( out ) ).toEqual( content );
|
|
64
|
-
} );
|
|
65
|
-
|
|
66
|
-
it( 'uses UTF-8 byte length for threshold, not JavaScript string length', () => {
|
|
67
|
-
const content = { label: 'éclair' };
|
|
68
|
-
const compact = JSON.stringify( content );
|
|
69
|
-
expect( compact.length ).toBeLessThan( Buffer.byteLength( compact, 'utf8' ) );
|
|
70
|
-
|
|
71
|
-
const bytes = Buffer.byteLength( compact, 'utf8' );
|
|
72
|
-
const outCompact = safeFormatJSON( content, bytes - 1 );
|
|
73
|
-
expect( outCompact ).toBe( compact );
|
|
74
|
-
|
|
75
|
-
const outPretty = safeFormatJSON( content, bytes + 100 );
|
|
76
|
-
expect( outPretty ).toContain( '\n' );
|
|
77
|
-
expect( JSON.parse( outPretty ) ).toEqual( content );
|
|
78
|
-
} );
|
|
79
|
-
|
|
80
|
-
it( 'round-trips empty object and primitives for both branches', () => {
|
|
81
|
-
const tiny = {};
|
|
82
|
-
const pretty = safeFormatJSON( tiny, 100 );
|
|
83
|
-
expect( JSON.parse( pretty ) ).toEqual( tiny );
|
|
84
|
-
|
|
85
|
-
const forcedCompact = safeFormatJSON( tiny, 0 );
|
|
86
|
-
expect( JSON.parse( forcedCompact ) ).toEqual( tiny );
|
|
87
|
-
} );
|
|
88
|
-
|
|
89
|
-
it( 'returns compact JSON when pretty stringify throws Invalid string length', () => {
|
|
90
|
-
const content = { a: 1 };
|
|
91
|
-
const compact = JSON.stringify( content );
|
|
92
|
-
const origStringify = JSON.stringify.bind( JSON );
|
|
93
|
-
|
|
94
|
-
const spy = vi.spyOn( JSON, 'stringify' ).mockImplementation( ( ...args ) => {
|
|
95
|
-
if ( isPrettyStringifyCall( args ) ) {
|
|
96
|
-
throw new RangeError( 'Invalid string length' );
|
|
97
|
-
}
|
|
98
|
-
return origStringify( ...args );
|
|
99
|
-
} );
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
const out = safeFormatJSON( content, 10_000 );
|
|
103
|
-
expect( out ).toBe( compact );
|
|
104
|
-
expect( JSON.parse( out ) ).toEqual( content );
|
|
105
|
-
} finally {
|
|
106
|
-
spy.mockRestore();
|
|
107
|
-
}
|
|
108
|
-
} );
|
|
109
|
-
|
|
110
|
-
it( 'rethrows RangeError when message is not Invalid string length', () => {
|
|
111
|
-
const content = { a: 1 };
|
|
112
|
-
const origStringify = JSON.stringify.bind( JSON );
|
|
113
|
-
|
|
114
|
-
const spy = vi.spyOn( JSON, 'stringify' ).mockImplementation( ( ...args ) => {
|
|
115
|
-
if ( isPrettyStringifyCall( args ) ) {
|
|
116
|
-
throw new RangeError( 'not the string length error' );
|
|
117
|
-
}
|
|
118
|
-
return origStringify( ...args );
|
|
119
|
-
} );
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
expect( () => safeFormatJSON( content, 10_000 ) ).toThrow( RangeError );
|
|
123
|
-
} finally {
|
|
124
|
-
spy.mockRestore();
|
|
125
|
-
}
|
|
126
|
-
} );
|
|
127
|
-
|
|
128
|
-
it( 'rethrows non-RangeError from pretty stringify', () => {
|
|
129
|
-
const content = { a: 1 };
|
|
130
|
-
const origStringify = JSON.stringify.bind( JSON );
|
|
131
|
-
|
|
132
|
-
const spy = vi.spyOn( JSON, 'stringify' ).mockImplementation( ( ...args ) => {
|
|
133
|
-
if ( isPrettyStringifyCall( args ) ) {
|
|
134
|
-
throw new TypeError( 'cyclic structure' );
|
|
135
|
-
}
|
|
136
|
-
return origStringify( ...args );
|
|
137
|
-
} );
|
|
138
|
-
|
|
139
|
-
try {
|
|
140
|
-
expect( () => safeFormatJSON( content, 10_000 ) ).toThrow( TypeError );
|
|
141
|
-
} finally {
|
|
142
|
-
spy.mockRestore();
|
|
143
|
-
}
|
|
144
|
-
} );
|
|
145
|
-
} );
|
|
146
14
|
} );
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare namespace Attribute {
|
|
2
|
+
export interface Usage {
|
|
3
|
+
type: string;
|
|
4
|
+
ppm: number;
|
|
5
|
+
amount: number;
|
|
6
|
+
total: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class HTTPRequestCount {
|
|
10
|
+
static TYPE: 'http:request:count';
|
|
11
|
+
type: typeof HTTPRequestCount.TYPE;
|
|
12
|
+
url: string;
|
|
13
|
+
requestId: string;
|
|
14
|
+
constructor( url: string, requestId: string );
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class HTTPRequestCost {
|
|
18
|
+
static TYPE: 'http:request:cost';
|
|
19
|
+
type: typeof HTTPRequestCost.TYPE;
|
|
20
|
+
url: string;
|
|
21
|
+
requestId: string;
|
|
22
|
+
total: number;
|
|
23
|
+
constructor( url: string, requestId: string, total: number );
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class LLMUsage {
|
|
27
|
+
static TYPE: 'llm:usage';
|
|
28
|
+
type: typeof LLMUsage.TYPE;
|
|
29
|
+
modelId: string;
|
|
30
|
+
usage: Usage[];
|
|
31
|
+
constructor( modelId: string );
|
|
32
|
+
addUsage( usage: { type: string; ppm: number; amount: number } ): void;
|
|
33
|
+
readonly total: number;
|
|
34
|
+
readonly tokensUsed: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Instance = HTTPRequestCount | HTTPRequestCost | LLMUsage;
|
|
38
|
+
}
|