@outputai/core 0.4.1-dev.7b85c96.0 → 0.4.1-dev.ae4bd16.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 +3 -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/hooks/index.d.ts +2 -2
- 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/tools/build_trace_tree.js +1 -1
- package/src/tracing/tools/build_trace_tree.spec.js +49 -11
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/core",
|
|
3
|
-
"version": "0.4.1-dev.
|
|
3
|
+
"version": "0.4.1-dev.ae4bd16.0",
|
|
4
4
|
"description": "The core module of the output framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"@temporalio/common": "1.17.0",
|
|
44
44
|
"@temporalio/worker": "1.17.0",
|
|
45
45
|
"@temporalio/workflow": "1.17.0",
|
|
46
|
+
"decimal.js": "10.6.0",
|
|
46
47
|
"json-stream-stringify": "3.1.6",
|
|
47
48
|
"redis": "5.12.1",
|
|
48
49
|
"stacktrace-parser": "0.1.11",
|
|
@@ -66,6 +67,7 @@
|
|
|
66
67
|
"#logger": "./src/logger.js",
|
|
67
68
|
"#utils": "./src/utils/index.js",
|
|
68
69
|
"#tracing": "./src/tracing/internal_interface.js",
|
|
70
|
+
"#trace_attribute": "./src/tracing/trace_attribute.js",
|
|
69
71
|
"#async_storage": "./src/async_storage.js",
|
|
70
72
|
"#internal_activities": "./src/internal_activities/index.js"
|
|
71
73
|
},
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { Attribute } from '#trace_attribute';
|
|
2
|
+
|
|
3
|
+
export { Attribute } from '#trace_attribute';
|
|
1
4
|
/**
|
|
2
5
|
* Creates a new event.
|
|
3
6
|
*
|
|
@@ -32,14 +35,6 @@ export declare function addEventError( args: { id: string; details: unknown } ):
|
|
|
32
35
|
*
|
|
33
36
|
* @param args
|
|
34
37
|
* @param args.eventId - The id of the event to attach the attribute to.
|
|
35
|
-
* @param args.
|
|
36
|
-
* @param args.value - The attribute value
|
|
37
|
-
*/
|
|
38
|
-
export declare function addEventAttribute( args: { eventId: string; name: string, value: unknown } ): void;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Known attributes.
|
|
38
|
+
* @param args.attribute - The attribute to attach to the event.
|
|
42
39
|
*/
|
|
43
|
-
export declare
|
|
44
|
-
COST: 'cost';
|
|
45
|
-
};
|
|
40
|
+
export declare function addEventAttribute( args: { eventId: string; attribute: Attribute.Instance } ): void;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { addEventActionWithContext, EventAction } from '#tracing';
|
|
2
2
|
|
|
3
|
+
export { Attribute } from '#trace_attribute';
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Creates a new event.
|
|
5
7
|
*
|
|
@@ -42,12 +44,5 @@ export const addEventError = ( { id, details } ) => addEventActionWithContext( E
|
|
|
42
44
|
* @param {unknown} args.value - The attribute value
|
|
43
45
|
* @returns {void}
|
|
44
46
|
*/
|
|
45
|
-
export const addEventAttribute = ( { eventId,
|
|
46
|
-
addEventActionWithContext( EventAction.ADD_ATTR, { id: eventId, details:
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Known attributes
|
|
50
|
-
*/
|
|
51
|
-
export const Attribute = {
|
|
52
|
-
COST: 'cost'
|
|
53
|
-
};
|
|
47
|
+
export const addEventAttribute = ( { eventId, attribute } ) =>
|
|
48
|
+
addEventActionWithContext( EventAction.ADD_ATTR, { id: eventId, details: attribute } );
|
package/src/consts.js
CHANGED
package/src/hooks/index.d.ts
CHANGED
|
@@ -123,8 +123,8 @@ export interface HttpRequestHookPayload {
|
|
|
123
123
|
status?: number;
|
|
124
124
|
/** Elapsed time from request issuance to response (or failure), in milliseconds. */
|
|
125
125
|
durationMs: number;
|
|
126
|
-
/** Outcome bucket: `success` (2xx-3xx), `
|
|
127
|
-
outcome: 'success' | '
|
|
126
|
+
/** Outcome bucket: `success` (2xx-3xx), `error` (status >= 400), `failure` (DNS / timeout / abort). */
|
|
127
|
+
outcome: 'success' | 'error' | 'failure';
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Attribute } from '#trace_attribute';
|
|
2
|
+
import Decimal from 'decimal.js';
|
|
3
|
+
|
|
4
|
+
export const aggregateAttributes = attributes => ( {
|
|
5
|
+
cost: {
|
|
6
|
+
total: attributes
|
|
7
|
+
.filter( a => [ Attribute.HTTPRequestCost.TYPE, Attribute.LLMUsage.TYPE ].includes( a.type ) )
|
|
8
|
+
.reduce( ( sum, a ) => sum.add( a.total ), Decimal( 0 ) ).toNumber()
|
|
9
|
+
},
|
|
10
|
+
tokens: {
|
|
11
|
+
total: attributes
|
|
12
|
+
.filter( a => Attribute.LLMUsage.TYPE === a.type )
|
|
13
|
+
.reduce( ( sum, a ) => sum.add( a.tokensUsed ), Decimal( 0 ) ).toNumber(),
|
|
14
|
+
...Object.entries( attributes
|
|
15
|
+
.filter( a => Attribute.LLMUsage.TYPE === a.type )
|
|
16
|
+
.flatMap( a => a.usage )
|
|
17
|
+
.reduce( ( obj, a ) => Object.assign( obj, { [a.type]: ( obj[a.type] ?? Decimal( 0 ) ).add( a.amount ) } ), {} ) )
|
|
18
|
+
.reduce( ( obj, [ k, v ] ) => Object.assign( obj, { [k]: v.toNumber() } ), {} ) // convert all values to number
|
|
19
|
+
|
|
20
|
+
},
|
|
21
|
+
httpRequests: {
|
|
22
|
+
total: attributes.filter( a => Attribute.HTTPRequestCount.TYPE === a.type ).length
|
|
23
|
+
}
|
|
24
|
+
} );
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Attribute } from '#trace_attribute';
|
|
3
|
+
import { aggregateAttributes } from './aggregations.js';
|
|
4
|
+
|
|
5
|
+
describe( 'aggregateAttributes', () => {
|
|
6
|
+
it( 'returns zeroed aggregations when there are no attributes', () => {
|
|
7
|
+
expect( aggregateAttributes( [] ) ).toEqual( {
|
|
8
|
+
cost: { total: 0 },
|
|
9
|
+
tokens: { total: 0 },
|
|
10
|
+
httpRequests: { total: 0 }
|
|
11
|
+
} );
|
|
12
|
+
} );
|
|
13
|
+
|
|
14
|
+
it( 'aggregates costs, token usage, and HTTP request count by attribute type', () => {
|
|
15
|
+
const attributes = [
|
|
16
|
+
{
|
|
17
|
+
type: Attribute.HTTPRequestCount.TYPE,
|
|
18
|
+
url: 'https://api.example.test/a',
|
|
19
|
+
requestId: 'req-1'
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
type: Attribute.HTTPRequestCount.TYPE,
|
|
23
|
+
url: 'https://api.example.test/b',
|
|
24
|
+
requestId: 'req-2'
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
type: Attribute.HTTPRequestCost.TYPE,
|
|
28
|
+
url: 'https://api.example.test/a',
|
|
29
|
+
requestId: 'req-1',
|
|
30
|
+
total: 0.2
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: Attribute.LLMUsage.TYPE,
|
|
34
|
+
modelId: 'gpt-4o',
|
|
35
|
+
total: 0.3,
|
|
36
|
+
tokensUsed: 120,
|
|
37
|
+
usage: [
|
|
38
|
+
{ type: 'input', ppm: 1, amount: 100, total: 0.1 },
|
|
39
|
+
{ type: 'output', ppm: 2, amount: 20, total: 0.2 }
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: Attribute.LLMUsage.TYPE,
|
|
44
|
+
modelId: 'gpt-4o-mini',
|
|
45
|
+
total: 0.05,
|
|
46
|
+
tokensUsed: 30,
|
|
47
|
+
usage: [
|
|
48
|
+
{ type: 'input', ppm: 1, amount: 25, total: 0.025 },
|
|
49
|
+
{ type: 'reasoning', ppm: 5, amount: 5, total: 0.025 }
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
type: 'unrelated',
|
|
54
|
+
total: 100,
|
|
55
|
+
tokensUsed: 100
|
|
56
|
+
}
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
expect( aggregateAttributes( attributes ) ).toEqual( {
|
|
60
|
+
cost: { total: 0.55 },
|
|
61
|
+
tokens: {
|
|
62
|
+
total: 150,
|
|
63
|
+
input: 125,
|
|
64
|
+
output: 20,
|
|
65
|
+
reasoning: 5
|
|
66
|
+
},
|
|
67
|
+
httpRequests: { total: 2 }
|
|
68
|
+
} );
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
it( 'uses LLMUsage.tokensUsed for total tokens instead of summing usage amounts', () => {
|
|
72
|
+
const attributes = [
|
|
73
|
+
{
|
|
74
|
+
type: Attribute.LLMUsage.TYPE,
|
|
75
|
+
modelId: 'provider-model',
|
|
76
|
+
total: 0.1,
|
|
77
|
+
tokensUsed: 42,
|
|
78
|
+
usage: [
|
|
79
|
+
{ type: 'input', ppm: 1, amount: 10, total: 0.01 },
|
|
80
|
+
{ type: 'output', ppm: 1, amount: 5, total: 0.005 }
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
expect( aggregateAttributes( attributes ).tokens ).toEqual( {
|
|
86
|
+
total: 42,
|
|
87
|
+
input: 10,
|
|
88
|
+
output: 5
|
|
89
|
+
} );
|
|
90
|
+
} );
|
|
91
|
+
} );
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// THIS RUNS IN THE TEMPORAL'S SANDBOX ENVIRONMENT
|
|
2
2
|
import { proxyActivities, inWorkflowContext, executeChild, workflowInfo, uuid4, ParentClosePolicy, continueAsNew } from '@temporalio/workflow';
|
|
3
|
+
import { defineSignal, setHandler } from '@temporalio/workflow';
|
|
3
4
|
import { validateWorkflow } from './validations/static.js';
|
|
4
5
|
import { validateWithSchema } from './validations/runtime.js';
|
|
5
|
-
import { SHARED_STEP_PREFIX, ACTIVITY_GET_TRACE_DESTINATIONS, METADATA_ACCESS_SYMBOL } from '#consts';
|
|
6
|
+
import { SHARED_STEP_PREFIX, ACTIVITY_GET_TRACE_DESTINATIONS, METADATA_ACCESS_SYMBOL, Signal } from '#consts';
|
|
6
7
|
import { deepMerge, setMetadata, toUrlSafeBase64 } from '#utils';
|
|
7
8
|
import { FatalError, ValidationError } from '#errors';
|
|
8
9
|
import { Context } from './workflow_context.js';
|
|
10
|
+
import { aggregateAttributes } from './aggregations.js';
|
|
9
11
|
|
|
10
12
|
const defaultOptions = {
|
|
11
13
|
activityOptions: {
|
|
@@ -22,6 +24,9 @@ const defaultOptions = {
|
|
|
22
24
|
disableTrace: false
|
|
23
25
|
};
|
|
24
26
|
|
|
27
|
+
export const extractErrorDetail = ( e, key ) =>
|
|
28
|
+
e ? ( e.details?.find?.( d => d[key] )?.[key] ?? extractErrorDetail( e.cause, key ) ) : null;
|
|
29
|
+
|
|
25
30
|
export function workflow( { name, description, inputSchema, outputSchema, fn, options = {}, aliases = [] } ) {
|
|
26
31
|
validateWorkflow( { name, description, inputSchema, outputSchema, fn, options, aliases } );
|
|
27
32
|
|
|
@@ -75,7 +80,9 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
|
|
|
75
80
|
|
|
76
81
|
// Run the internal activity to retrieve the workflow trace destinations (only for root workflows, not nested)
|
|
77
82
|
const traceDestinations = isRoot ? ( await steps[ACTIVITY_GET_TRACE_DESTINATIONS]( executionContext ) ) : null;
|
|
78
|
-
|
|
83
|
+
|
|
84
|
+
const attributes = [];
|
|
85
|
+
setHandler( defineSignal( Signal.ADD_ATTRIBUTE ), e => attributes.push( e ) );
|
|
79
86
|
|
|
80
87
|
try {
|
|
81
88
|
// validation comes after setting memo to have that info already set for interceptor even if validations fail
|
|
@@ -97,17 +104,25 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
|
|
|
97
104
|
* @param {import('@temporalio/workflow').ActivityOptions} extra.options
|
|
98
105
|
* @returns {Promise<unknown>}
|
|
99
106
|
*/
|
|
100
|
-
startWorkflow: async ( childName, input, extra = {} ) =>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
startWorkflow: async ( childName, input, extra = {} ) => {
|
|
108
|
+
try {
|
|
109
|
+
const result = await executeChild( childName, {
|
|
110
|
+
args: input ? [ input ] : [],
|
|
111
|
+
workflowId: `${workflowId}-${toUrlSafeBase64( uuid4() )}`,
|
|
112
|
+
parentClosePolicy: ParentClosePolicy[extra?.detached ? 'ABANDON' : 'TERMINATE'],
|
|
113
|
+
memo: {
|
|
114
|
+
executionContext,
|
|
115
|
+
parentId: workflowId,
|
|
116
|
+
...( extra?.options?.activityOptions && { activityOptions: deepMerge( activityOptions, extra.options.activityOptions ) } )
|
|
117
|
+
}
|
|
118
|
+
} );
|
|
119
|
+
attributes.push( ...( result.attributes ?? [] ) );
|
|
120
|
+
return result.output;
|
|
121
|
+
} catch ( error ) {
|
|
122
|
+
attributes.push( ...( extractErrorDetail( error, 'attributes' ) ?? [] ) );
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
111
126
|
};
|
|
112
127
|
|
|
113
128
|
const output = await fn.call( dispatchers, input, context );
|
|
@@ -116,14 +131,17 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
|
|
|
116
131
|
|
|
117
132
|
if ( isRoot ) {
|
|
118
133
|
// Append the trace info to the result of the workflow
|
|
119
|
-
return { output,
|
|
134
|
+
return { output, trace: { destinations: traceDestinations }, attributes, aggregations: aggregateAttributes( attributes ) };
|
|
120
135
|
}
|
|
121
136
|
|
|
122
|
-
return output;
|
|
137
|
+
return { output, attributes };
|
|
123
138
|
} catch ( e ) {
|
|
124
|
-
// Append the
|
|
139
|
+
// Append the extra info as metadata of the error, so it can be read by the interceptor.
|
|
140
|
+
e[METADATA_ACCESS_SYMBOL] = { ...( e[METADATA_ACCESS_SYMBOL] ?? {} ), attributes };
|
|
141
|
+
// if it is roo also add trace/aggregations
|
|
125
142
|
if ( isRoot ) {
|
|
126
|
-
e[METADATA_ACCESS_SYMBOL] = {
|
|
143
|
+
e[METADATA_ACCESS_SYMBOL].trace = { destinations: traceDestinations };
|
|
144
|
+
e[METADATA_ACCESS_SYMBOL].aggregations = aggregateAttributes( attributes );
|
|
127
145
|
}
|
|
128
146
|
throw e;
|
|
129
147
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { Signal } from '#consts';
|
|
1
2
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
|
|
4
5
|
const inWorkflowContextMock = vi.hoisted( () => vi.fn( () => true ) );
|
|
6
|
+
const defineSignalMock = vi.hoisted( () => vi.fn( name => name ) );
|
|
7
|
+
const setHandlerMock = vi.hoisted( () => vi.fn() );
|
|
5
8
|
const traceDestinationsStepMock = vi.fn().mockResolvedValue( { local: '/tmp/trace' } );
|
|
6
9
|
const executeChildMock = vi.fn().mockResolvedValue( undefined );
|
|
7
10
|
const continueAsNewMock = vi.fn().mockResolvedValue( undefined );
|
|
@@ -41,7 +44,16 @@ vi.mock( '@temporalio/workflow', () => ( {
|
|
|
41
44
|
workflowInfo: workflowInfoMock,
|
|
42
45
|
uuid4: () => '550e8400e29b41d4a716446655440000',
|
|
43
46
|
ParentClosePolicy: { TERMINATE: 'TERMINATE', ABANDON: 'ABANDON' },
|
|
44
|
-
|
|
47
|
+
ChildWorkflowFailure: class ChildWorkflowFailure extends Error {
|
|
48
|
+
constructor( message, cause ) {
|
|
49
|
+
super( message );
|
|
50
|
+
this.name = 'ChildWorkflowFailure';
|
|
51
|
+
this.cause = cause;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
continueAsNew: continueAsNewMock,
|
|
55
|
+
defineSignal: ( ...args ) => defineSignalMock( ...args ),
|
|
56
|
+
setHandler: ( ...args ) => setHandlerMock( ...args )
|
|
45
57
|
} ) );
|
|
46
58
|
|
|
47
59
|
vi.mock( '#consts', async importOriginal => {
|
|
@@ -53,10 +65,17 @@ vi.mock( '#consts', async importOriginal => {
|
|
|
53
65
|
};
|
|
54
66
|
} );
|
|
55
67
|
|
|
68
|
+
const emptyAggregations = {
|
|
69
|
+
cost: { total: 0 },
|
|
70
|
+
tokens: { total: 0 },
|
|
71
|
+
httpRequests: { total: 0 }
|
|
72
|
+
};
|
|
73
|
+
|
|
56
74
|
describe( 'workflow()', () => {
|
|
57
75
|
beforeEach( () => {
|
|
58
76
|
vi.clearAllMocks();
|
|
59
77
|
inWorkflowContextMock.mockReturnValue( true );
|
|
78
|
+
defineSignalMock.mockImplementation( name => name );
|
|
60
79
|
workflowInfoMock.mockReturnValue( { ...workflowInfoReturn } );
|
|
61
80
|
workflowInfoReturn.memo = {};
|
|
62
81
|
proxyActivitiesMock.mockImplementation( () => {
|
|
@@ -217,7 +236,7 @@ describe( 'workflow()', () => {
|
|
|
217
236
|
} );
|
|
218
237
|
|
|
219
238
|
describe( 'root workflow (in workflow context)', () => {
|
|
220
|
-
it( 'calls getTraceDestinations, returns
|
|
239
|
+
it( 'calls getTraceDestinations, returns root trace data and assigns executionContext to memo', async () => {
|
|
221
240
|
const { workflow } = await import( './workflow.js' );
|
|
222
241
|
|
|
223
242
|
const wf = workflow( {
|
|
@@ -232,7 +251,9 @@ describe( 'workflow()', () => {
|
|
|
232
251
|
expect( traceDestinationsStepMock ).toHaveBeenCalledTimes( 1 );
|
|
233
252
|
expect( result ).toEqual( {
|
|
234
253
|
output: { v: 42 },
|
|
235
|
-
trace: { destinations: { local: '/tmp/trace' } }
|
|
254
|
+
trace: { destinations: { local: '/tmp/trace' } },
|
|
255
|
+
attributes: [],
|
|
256
|
+
aggregations: emptyAggregations
|
|
236
257
|
} );
|
|
237
258
|
const memo = workflowInfoMock().memo;
|
|
238
259
|
expect( memo.executionContext ).toEqual( {
|
|
@@ -243,6 +264,68 @@ describe( 'workflow()', () => {
|
|
|
243
264
|
} );
|
|
244
265
|
} );
|
|
245
266
|
|
|
267
|
+
it( 'collects attribute signals and returns aggregated attributes', async () => {
|
|
268
|
+
const { workflow } = await import( './workflow.js' );
|
|
269
|
+
const { Attribute } = await import( '#trace_attribute' );
|
|
270
|
+
const handlers = { addAttribute: () => {} };
|
|
271
|
+
setHandlerMock.mockImplementation( ( signalName, handler ) => {
|
|
272
|
+
if ( signalName === Signal.ADD_ATTRIBUTE ) {
|
|
273
|
+
handlers.addAttribute = handler;
|
|
274
|
+
}
|
|
275
|
+
} );
|
|
276
|
+
|
|
277
|
+
const httpRequest = {
|
|
278
|
+
type: Attribute.HTTPRequestCount.TYPE,
|
|
279
|
+
url: 'https://api.example.test/items',
|
|
280
|
+
requestId: 'req-1'
|
|
281
|
+
};
|
|
282
|
+
const httpCost = {
|
|
283
|
+
type: Attribute.HTTPRequestCost.TYPE,
|
|
284
|
+
url: 'https://api.example.test/items',
|
|
285
|
+
requestId: 'req-1',
|
|
286
|
+
total: 2.5
|
|
287
|
+
};
|
|
288
|
+
const llmUsage = {
|
|
289
|
+
type: Attribute.LLMUsage.TYPE,
|
|
290
|
+
modelId: 'gpt-4o',
|
|
291
|
+
total: 0.25,
|
|
292
|
+
usage: [
|
|
293
|
+
{ type: 'input', ppm: 5, amount: 20_000, total: 0.1 },
|
|
294
|
+
{ type: 'output', ppm: 30, amount: 5_000, total: 0.15 }
|
|
295
|
+
],
|
|
296
|
+
tokensUsed: 25_000
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const wf = workflow( {
|
|
300
|
+
name: 'attr_wf',
|
|
301
|
+
description: 'Attributes',
|
|
302
|
+
inputSchema: z.object( {} ),
|
|
303
|
+
outputSchema: z.object( { ok: z.boolean() } ),
|
|
304
|
+
fn: async () => {
|
|
305
|
+
handlers.addAttribute( httpRequest );
|
|
306
|
+
handlers.addAttribute( httpCost );
|
|
307
|
+
handlers.addAttribute( llmUsage );
|
|
308
|
+
return { ok: true };
|
|
309
|
+
}
|
|
310
|
+
} );
|
|
311
|
+
|
|
312
|
+
const result = await wf( {} );
|
|
313
|
+
expect( result ).toEqual( {
|
|
314
|
+
output: { ok: true },
|
|
315
|
+
trace: { destinations: { local: '/tmp/trace' } },
|
|
316
|
+
attributes: [ httpRequest, httpCost, llmUsage ],
|
|
317
|
+
aggregations: {
|
|
318
|
+
cost: { total: 2.75 },
|
|
319
|
+
tokens: {
|
|
320
|
+
total: 25_000,
|
|
321
|
+
input: 20_000,
|
|
322
|
+
output: 5_000
|
|
323
|
+
},
|
|
324
|
+
httpRequests: { total: 1 }
|
|
325
|
+
}
|
|
326
|
+
} );
|
|
327
|
+
} );
|
|
328
|
+
|
|
246
329
|
it( 'sets executionContext.disableTrace when options.disableTrace is true', async () => {
|
|
247
330
|
const { workflow } = await import( './workflow.js' );
|
|
248
331
|
|
|
@@ -261,7 +344,7 @@ describe( 'workflow()', () => {
|
|
|
261
344
|
} );
|
|
262
345
|
|
|
263
346
|
describe( 'child workflow (memo.executionContext already set)', () => {
|
|
264
|
-
it( 'does not call getTraceDestinations and returns
|
|
347
|
+
it( 'does not call getTraceDestinations and returns an internal output envelope', async () => {
|
|
265
348
|
workflowInfoMock.mockReturnValue( {
|
|
266
349
|
...workflowInfoReturn,
|
|
267
350
|
memo: { executionContext: { workflowId: 'parent-1', workflowName: 'parent_wf' } }
|
|
@@ -278,7 +361,7 @@ describe( 'workflow()', () => {
|
|
|
278
361
|
|
|
279
362
|
const result = await wf( {} );
|
|
280
363
|
expect( traceDestinationsStepMock ).not.toHaveBeenCalled();
|
|
281
|
-
expect( result ).toEqual( { x: 'child' } );
|
|
364
|
+
expect( result ).toEqual( { output: { x: 'child' }, attributes: [] } );
|
|
282
365
|
} );
|
|
283
366
|
} );
|
|
284
367
|
|
|
@@ -381,6 +464,7 @@ describe( 'workflow()', () => {
|
|
|
381
464
|
it( 'calls executeChild with correct args and TERMINATE when not detached', async () => {
|
|
382
465
|
const { workflow } = await import( './workflow.js' );
|
|
383
466
|
const { ParentClosePolicy } = await import( '@temporalio/workflow' );
|
|
467
|
+
executeChildMock.mockResolvedValueOnce( { output: {}, attributes: [] } );
|
|
384
468
|
|
|
385
469
|
const wf = workflow( {
|
|
386
470
|
name: 'parent_wf',
|
|
@@ -408,6 +492,7 @@ describe( 'workflow()', () => {
|
|
|
408
492
|
it( 'uses ABANDON when extra.detached is true', async () => {
|
|
409
493
|
const { workflow } = await import( './workflow.js' );
|
|
410
494
|
const { ParentClosePolicy } = await import( '@temporalio/workflow' );
|
|
495
|
+
executeChildMock.mockResolvedValueOnce( { output: {}, attributes: [] } );
|
|
411
496
|
|
|
412
497
|
const wf = workflow( {
|
|
413
498
|
name: 'detach_wf',
|
|
@@ -428,6 +513,7 @@ describe( 'workflow()', () => {
|
|
|
428
513
|
|
|
429
514
|
it( 'passes empty args when input is null/omitted', async () => {
|
|
430
515
|
const { workflow } = await import( './workflow.js' );
|
|
516
|
+
executeChildMock.mockResolvedValueOnce( { output: {}, attributes: [] } );
|
|
431
517
|
|
|
432
518
|
const wf = workflow( {
|
|
433
519
|
name: 'no_input_wf',
|
|
@@ -445,11 +531,96 @@ describe( 'workflow()', () => {
|
|
|
445
531
|
args: []
|
|
446
532
|
} ) );
|
|
447
533
|
} );
|
|
534
|
+
|
|
535
|
+
it( 'returns child output and merges child attributes into the root result', async () => {
|
|
536
|
+
const { workflow } = await import( './workflow.js' );
|
|
537
|
+
const { Attribute } = await import( '#trace_attribute' );
|
|
538
|
+
const childAttribute = {
|
|
539
|
+
type: Attribute.LLMUsage.TYPE,
|
|
540
|
+
modelId: 'gpt-4o',
|
|
541
|
+
total: 0.4,
|
|
542
|
+
tokensUsed: 20,
|
|
543
|
+
usage: [
|
|
544
|
+
{ type: 'input', ppm: 10, amount: 20, total: 0.4 }
|
|
545
|
+
]
|
|
546
|
+
};
|
|
547
|
+
executeChildMock.mockResolvedValueOnce( {
|
|
548
|
+
output: { child: 'ok' },
|
|
549
|
+
attributes: [ childAttribute ]
|
|
550
|
+
} );
|
|
551
|
+
|
|
552
|
+
const wf = workflow( {
|
|
553
|
+
name: 'merge_child_wf',
|
|
554
|
+
description: 'Merge child attributes',
|
|
555
|
+
inputSchema: z.object( {} ),
|
|
556
|
+
outputSchema: z.object( { child: z.string() } ),
|
|
557
|
+
async fn() {
|
|
558
|
+
return this.startWorkflow( 'child_wf', { id: 1 } );
|
|
559
|
+
}
|
|
560
|
+
} );
|
|
561
|
+
|
|
562
|
+
const result = await wf( {} );
|
|
563
|
+
expect( result ).toEqual( {
|
|
564
|
+
output: { child: 'ok' },
|
|
565
|
+
trace: { destinations: { local: '/tmp/trace' } },
|
|
566
|
+
attributes: [ childAttribute ],
|
|
567
|
+
aggregations: {
|
|
568
|
+
cost: { total: 0.4 },
|
|
569
|
+
tokens: {
|
|
570
|
+
total: 20,
|
|
571
|
+
input: 20
|
|
572
|
+
},
|
|
573
|
+
httpRequests: { total: 0 }
|
|
574
|
+
}
|
|
575
|
+
} );
|
|
576
|
+
} );
|
|
577
|
+
|
|
578
|
+
it( 'merges child error attributes before rethrowing to root metadata', async () => {
|
|
579
|
+
const { workflow } = await import( './workflow.js' );
|
|
580
|
+
const { ChildWorkflowFailure } = await import( '@temporalio/workflow' );
|
|
581
|
+
const { METADATA_ACCESS_SYMBOL } = await import( '#consts' );
|
|
582
|
+
const { Attribute } = await import( '#trace_attribute' );
|
|
583
|
+
const childAttribute = {
|
|
584
|
+
type: Attribute.HTTPRequestCost.TYPE,
|
|
585
|
+
url: 'https://api.example.test',
|
|
586
|
+
requestId: 'req-child',
|
|
587
|
+
total: 2
|
|
588
|
+
};
|
|
589
|
+
const childError = new ChildWorkflowFailure( 'child failed', {
|
|
590
|
+
message: 'Child workflow execution failed',
|
|
591
|
+
details: [ { attributes: [ childAttribute ] } ]
|
|
592
|
+
} );
|
|
593
|
+
executeChildMock.mockRejectedValueOnce( childError );
|
|
594
|
+
|
|
595
|
+
const wf = workflow( {
|
|
596
|
+
name: 'child_error_wf',
|
|
597
|
+
description: 'Child error attributes',
|
|
598
|
+
inputSchema: z.object( {} ),
|
|
599
|
+
outputSchema: z.object( {} ),
|
|
600
|
+
async fn() {
|
|
601
|
+
await this.startWorkflow( 'child_wf', { id: 1 } );
|
|
602
|
+
return {};
|
|
603
|
+
}
|
|
604
|
+
} );
|
|
605
|
+
|
|
606
|
+
await expect( wf( {} ) ).rejects.toThrow( 'child failed' );
|
|
607
|
+
expect( childError[METADATA_ACCESS_SYMBOL] ).toEqual( {
|
|
608
|
+
attributes: [ childAttribute ],
|
|
609
|
+
trace: { destinations: { local: '/tmp/trace' } },
|
|
610
|
+
aggregations: {
|
|
611
|
+
cost: { total: 2 },
|
|
612
|
+
tokens: { total: 0 },
|
|
613
|
+
httpRequests: { total: 0 }
|
|
614
|
+
}
|
|
615
|
+
} );
|
|
616
|
+
} );
|
|
448
617
|
} );
|
|
449
618
|
|
|
450
619
|
describe( 'error handling (root workflow)', () => {
|
|
451
|
-
it( 'rethrows error from fn
|
|
620
|
+
it( 'rethrows error from fn with trace attributes and aggregation metadata', async () => {
|
|
452
621
|
const { workflow } = await import( './workflow.js' );
|
|
622
|
+
const { METADATA_ACCESS_SYMBOL } = await import( '#consts' );
|
|
623
|
+
const error = new Error( 'workflow failed' );
|
|
453
624
|
|
|
454
625
|
const wf = workflow( {
|
|
455
626
|
name: 'err_wf',
|
|
@@ -457,11 +628,16 @@ describe( 'workflow()', () => {
|
|
|
457
628
|
inputSchema: z.object( {} ),
|
|
458
629
|
outputSchema: z.object( {} ),
|
|
459
630
|
fn: async () => {
|
|
460
|
-
throw
|
|
631
|
+
throw error;
|
|
461
632
|
}
|
|
462
633
|
} );
|
|
463
634
|
|
|
464
635
|
await expect( wf( {} ) ).rejects.toThrow( 'workflow failed' );
|
|
636
|
+
expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( {
|
|
637
|
+
trace: { destinations: { local: '/tmp/trace' } },
|
|
638
|
+
attributes: [],
|
|
639
|
+
aggregations: emptyAggregations
|
|
640
|
+
} );
|
|
465
641
|
} );
|
|
466
642
|
} );
|
|
467
643
|
} );
|
|
@@ -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',
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import Decimal from 'decimal.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* All attributes inherit from this
|
|
5
|
+
*/
|
|
6
|
+
export class BaseAttribute {
|
|
7
|
+
activityId;
|
|
8
|
+
activityName;
|
|
9
|
+
date = Date.now();
|
|
10
|
+
type;
|
|
11
|
+
|
|
12
|
+
constructor( type ) {
|
|
13
|
+
this.type = type;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setActivity( id, name ) {
|
|
17
|
+
this.activityId = id;
|
|
18
|
+
this.activityName = name;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class HTTPRequestCount extends BaseAttribute {
|
|
23
|
+
static TYPE = 'http:request:count';
|
|
24
|
+
url;
|
|
25
|
+
requestId;
|
|
26
|
+
|
|
27
|
+
constructor( url, requestId ) {
|
|
28
|
+
super( HTTPRequestCount.TYPE );
|
|
29
|
+
this.url = url;
|
|
30
|
+
this.requestId = requestId;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class HTTPRequestCost extends BaseAttribute {
|
|
35
|
+
static TYPE = 'http:request:cost';
|
|
36
|
+
url;
|
|
37
|
+
requestId;
|
|
38
|
+
total = 0;
|
|
39
|
+
|
|
40
|
+
constructor( url, requestId, total ) {
|
|
41
|
+
super( HTTPRequestCost.TYPE );
|
|
42
|
+
this.url = url;
|
|
43
|
+
this.requestId = requestId;
|
|
44
|
+
this.total = total;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class LLMUsage extends BaseAttribute {
|
|
49
|
+
static TYPE = 'llm:usage';
|
|
50
|
+
modelId;
|
|
51
|
+
usage = [];
|
|
52
|
+
total = 0;
|
|
53
|
+
tokensUsed = 0;
|
|
54
|
+
|
|
55
|
+
constructor( modelId ) {
|
|
56
|
+
super( LLMUsage.TYPE );
|
|
57
|
+
this.modelId = modelId;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
addUsage( { type, ppm, amount } ) {
|
|
61
|
+
const total = Decimal( amount ).div( 1_000_000 ).mul( ppm ).toNumber();
|
|
62
|
+
this.usage.push( {
|
|
63
|
+
type,
|
|
64
|
+
ppm,
|
|
65
|
+
amount,
|
|
66
|
+
total
|
|
67
|
+
} );
|
|
68
|
+
this.total = Decimal( this.total ).add( total ).toNumber();
|
|
69
|
+
this.tokensUsed = Decimal( this.tokensUsed ).add( amount ).toNumber();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Types of ADD_ATTR attributes
|
|
75
|
+
*/
|
|
76
|
+
export const Attribute = {
|
|
77
|
+
LLMUsage,
|
|
78
|
+
HTTPRequestCost,
|
|
79
|
+
HTTPRequestCount
|
|
80
|
+
};
|
|
@@ -4,8 +4,10 @@ import { serializeError } from './tools/utils.js';
|
|
|
4
4
|
import { isStringboolTrue } from '#utils';
|
|
5
5
|
import * as localProcessor from './processors/local/index.js';
|
|
6
6
|
import * as s3Processor from './processors/s3/index.js';
|
|
7
|
-
import { ComponentType } from '#consts';
|
|
7
|
+
import { ComponentType, Signal } from '#consts';
|
|
8
8
|
import { createChildLogger } from '#logger';
|
|
9
|
+
import { EventAction } from './trace_consts.js';
|
|
10
|
+
import { BaseAttribute } from './trace_attribute.js';
|
|
9
11
|
|
|
10
12
|
const log = createChildLogger( 'Tracing' );
|
|
11
13
|
|
|
@@ -91,7 +93,15 @@ export const addEventAction = ( action, { kind, name, id, parentId, details, exe
|
|
|
91
93
|
export function addEventActionWithContext( action, options ) {
|
|
92
94
|
const storeContent = Storage.load();
|
|
93
95
|
if ( storeContent ) { // If there is no storageContext this was not called from a Temporal environment
|
|
94
|
-
const { parentId, executionContext } = storeContent;
|
|
96
|
+
const { parentId, parentName, executionContext, workflowHandle } = storeContent;
|
|
97
|
+
if ( action === EventAction.ADD_ATTR ) {
|
|
98
|
+
const attribute = options.details;
|
|
99
|
+
if ( !( attribute instanceof BaseAttribute ) ) {
|
|
100
|
+
throw new Error( `${EventAction.ADD_ATTR} called argument that is not a BaseAttribute instance` );
|
|
101
|
+
}
|
|
102
|
+
attribute.setActivity( parentId, parentName );
|
|
103
|
+
workflowHandle.signal( Signal.ADD_ATTRIBUTE, attribute );
|
|
104
|
+
}
|
|
95
105
|
addEventAction( action, { ...options, parentId, executionContext } );
|
|
96
106
|
}
|
|
97
107
|
};
|
package/src/worker/index.js
CHANGED
|
@@ -69,7 +69,7 @@ const callerDir = process.argv[2];
|
|
|
69
69
|
workflowsPath,
|
|
70
70
|
activities,
|
|
71
71
|
sinks,
|
|
72
|
-
interceptors: initInterceptors( { activities, workflows } ),
|
|
72
|
+
interceptors: initInterceptors( { activities, workflows, connection } ),
|
|
73
73
|
maxConcurrentWorkflowTaskExecutions,
|
|
74
74
|
maxConcurrentActivityTaskExecutions,
|
|
75
75
|
maxCachedWorkflows,
|
package/src/worker/index.spec.js
CHANGED
|
@@ -123,7 +123,7 @@ describe( 'worker/index', () => {
|
|
|
123
123
|
maxConcurrentActivityTaskPolls: configValues.maxConcurrentActivityTaskPolls,
|
|
124
124
|
maxConcurrentWorkflowTaskPolls: configValues.maxConcurrentWorkflowTaskPolls
|
|
125
125
|
} ) );
|
|
126
|
-
expect( initInterceptorsMock ).toHaveBeenCalledWith( { activities: {}, workflows: [] } );
|
|
126
|
+
expect( initInterceptorsMock ).toHaveBeenCalledWith( { activities: {}, workflows: [], connection: mockConnection } );
|
|
127
127
|
expect( registerShutdownMock ).toHaveBeenCalledWith( { worker: mockWorker, log: mockLog } );
|
|
128
128
|
expect( startCatalogMock ).toHaveBeenCalledWith( {
|
|
129
129
|
connection: mockConnection,
|
|
@@ -5,6 +5,7 @@ import { headersToObject } from '../sandboxed_utils.js';
|
|
|
5
5
|
import { BusEventType, METADATA_ACCESS_SYMBOL } from '#consts';
|
|
6
6
|
import { activityHeartbeatEnabled, activityHeartbeatIntervalMs } from '../configs.js';
|
|
7
7
|
import { messageBus } from '#bus';
|
|
8
|
+
import { Client } from '@temporalio/client';
|
|
8
9
|
|
|
9
10
|
/*
|
|
10
11
|
This interceptor wraps every activity execution with cross-cutting concerns:
|
|
@@ -23,7 +24,7 @@ import { messageBus } from '#bus';
|
|
|
23
24
|
- Headers injected by the workflow interceptor (executionContext)
|
|
24
25
|
*/
|
|
25
26
|
export class ActivityExecutionInterceptor {
|
|
26
|
-
constructor( { activities, workflows } ) {
|
|
27
|
+
constructor( { activities, workflows, connection } ) {
|
|
27
28
|
this.activities = activities;
|
|
28
29
|
this.workflowsMap = workflows.reduce( ( map, w ) => {
|
|
29
30
|
map.set( w.name, w );
|
|
@@ -32,14 +33,19 @@ export class ActivityExecutionInterceptor {
|
|
|
32
33
|
}
|
|
33
34
|
return map;
|
|
34
35
|
}, new Map() );
|
|
36
|
+
this.connection = connection;
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
async execute( input, next ) {
|
|
38
40
|
const startDate = Date.now();
|
|
41
|
+
const client = new Client( { connection: this.connection } );
|
|
42
|
+
|
|
39
43
|
const { workflowExecution: { workflowId }, activityId: id, activityType: name, workflowType: workflowName } = Context.current().info;
|
|
40
44
|
const { executionContext } = headersToObject( input.headers );
|
|
41
45
|
const { type: kind } = this.activities?.[name]?.[METADATA_ACCESS_SYMBOL];
|
|
42
46
|
|
|
47
|
+
const workflowHandle = client.workflow.getHandle( workflowId );
|
|
48
|
+
|
|
43
49
|
messageBus.emit( BusEventType.ACTIVITY_START, { id, name, kind, workflowId, workflowName } );
|
|
44
50
|
Tracing.addEventStart( { id, name, kind, parentId: workflowId, details: input.args[0], executionContext } );
|
|
45
51
|
|
|
@@ -56,7 +62,8 @@ export class ActivityExecutionInterceptor {
|
|
|
56
62
|
intervals.heartbeat = activityHeartbeatEnabled && setInterval( () => Context.current().heartbeat(), activityHeartbeatIntervalMs );
|
|
57
63
|
|
|
58
64
|
// Wraps the execution with accessible metadata for the activity
|
|
59
|
-
const
|
|
65
|
+
const ctx = { parentId: id, parentName: name, executionContext, workflowFilename, workflowHandle };
|
|
66
|
+
const output = await Storage.runWithContext( async _ => next( input ), ctx );
|
|
60
67
|
|
|
61
68
|
messageBus.emit( BusEventType.ACTIVITY_END, { id, name, kind, workflowId, workflowName, duration: Date.now() - startDate } );
|
|
62
69
|
Tracing.addEventEnd( { id, details: output, executionContext } );
|
|
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
2
2
|
import { BusEventType } from '#consts';
|
|
3
3
|
|
|
4
4
|
const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
|
|
5
|
+
const workflowHandleMock = vi.hoisted( () => ( { signal: vi.fn() } ) );
|
|
6
|
+
const getHandleMock = vi.hoisted( () => vi.fn( () => workflowHandleMock ) );
|
|
5
7
|
|
|
6
8
|
const heartbeatMock = vi.fn();
|
|
7
9
|
const runWithContextMock = vi.hoisted( () => vi.fn().mockImplementation( async fn => fn() ) );
|
|
@@ -21,6 +23,14 @@ vi.mock( '@temporalio/activity', () => ( {
|
|
|
21
23
|
}
|
|
22
24
|
} ) );
|
|
23
25
|
|
|
26
|
+
vi.mock( '@temporalio/client', () => ( {
|
|
27
|
+
Client: class Client {
|
|
28
|
+
workflow = {
|
|
29
|
+
getHandle: getHandleMock
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
} ) );
|
|
33
|
+
|
|
24
34
|
vi.mock( '#async_storage', () => ( {
|
|
25
35
|
Storage: {
|
|
26
36
|
runWithContext: runWithContextMock
|
|
@@ -108,12 +118,15 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
108
118
|
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
109
119
|
expect( runWithContextMock ).toHaveBeenCalledWith(
|
|
110
120
|
expect.any( Function ),
|
|
111
|
-
{
|
|
121
|
+
expect.objectContaining( {
|
|
112
122
|
parentId: 'act-1',
|
|
123
|
+
parentName: 'myWorkflow#myStep',
|
|
113
124
|
executionContext: { workflowId: 'wf-1' },
|
|
114
|
-
workflowFilename: '/workflows/myWorkflow.js'
|
|
115
|
-
|
|
125
|
+
workflowFilename: '/workflows/myWorkflow.js',
|
|
126
|
+
workflowHandle: workflowHandleMock
|
|
127
|
+
} )
|
|
116
128
|
);
|
|
129
|
+
expect( getHandleMock ).toHaveBeenCalledWith( 'wf-1' );
|
|
117
130
|
} );
|
|
118
131
|
|
|
119
132
|
it( 'records trace error event on failed execution', async () => {
|
|
@@ -4,7 +4,7 @@ import { ActivityExecutionInterceptor } from './interceptors/activity.js';
|
|
|
4
4
|
|
|
5
5
|
const __dirname = dirname( fileURLToPath( import.meta.url ) );
|
|
6
6
|
|
|
7
|
-
export const initInterceptors = ( { activities, workflows } ) => ( {
|
|
7
|
+
export const initInterceptors = ( { activities, workflows, connection } ) => ( {
|
|
8
8
|
workflowModules: [ join( __dirname, './interceptors/workflow.js' ) ],
|
|
9
|
-
activityInbound: [ () => new ActivityExecutionInterceptor( { activities, workflows } ) ]
|
|
9
|
+
activityInbound: [ () => new ActivityExecutionInterceptor( { activities, workflows, connection } ) ]
|
|
10
10
|
} );
|