@outputai/core 0.5.3-next.0eeffec.0 → 0.5.3-next.69060d7.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 +2 -1
- package/src/consts.js +3 -1
- package/src/interface/workflow.js +110 -41
- package/src/interface/workflow.replay_compatibility.spec.js +254 -0
- package/src/interface/workflow.spec.js +78 -126
- package/src/internal_utils/aggregations.js +54 -0
- package/src/{interface → internal_utils}/aggregations.spec.js +49 -1
- package/src/internal_utils/errors.js +10 -0
- package/src/tracing/trace_attribute.js +0 -7
- package/src/tracing/trace_engine.js +2 -2
- package/src/tracing/trace_engine.spec.js +8 -8
- package/src/utils/index.d.ts +15 -1
- package/src/utils/utils.js +38 -7
- package/src/utils/utils.spec.js +132 -0
- package/src/worker/configs.js +0 -6
- package/src/worker/configs.spec.js +1 -27
- package/src/worker/interceptors/activity.js +31 -48
- package/src/worker/interceptors/activity.spec.js +67 -59
- package/src/interface/aggregations.js +0 -24
package/src/utils/utils.spec.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
serializeBodyAndInferContentType,
|
|
6
6
|
serializeFetchResponse,
|
|
7
7
|
deepMerge,
|
|
8
|
+
deepMergeWithResolver,
|
|
8
9
|
isPlainObject,
|
|
9
10
|
toUrlSafeBase64,
|
|
10
11
|
allSettledWithTimeout
|
|
@@ -21,6 +22,86 @@ describe( 'clone', () => {
|
|
|
21
22
|
expect( copied.nested.b ).toBe( 3 );
|
|
22
23
|
expect( copied ).not.toBe( original );
|
|
23
24
|
} );
|
|
25
|
+
|
|
26
|
+
it( 'deep copies JSON-compatible arrays and objects', () => {
|
|
27
|
+
const original = {
|
|
28
|
+
arr: [ 1, { nested: true } ],
|
|
29
|
+
str: 'value',
|
|
30
|
+
bool: false,
|
|
31
|
+
nil: null
|
|
32
|
+
};
|
|
33
|
+
const copied = clone( original );
|
|
34
|
+
|
|
35
|
+
copied.arr[1].nested = false;
|
|
36
|
+
|
|
37
|
+
expect( copied ).toEqual( {
|
|
38
|
+
arr: [ 1, { nested: false } ],
|
|
39
|
+
str: 'value',
|
|
40
|
+
bool: false,
|
|
41
|
+
nil: null
|
|
42
|
+
} );
|
|
43
|
+
expect( original.arr[1].nested ).toBe( true );
|
|
44
|
+
expect( copied ).not.toBe( original );
|
|
45
|
+
expect( copied.arr ).not.toBe( original.arr );
|
|
46
|
+
} );
|
|
47
|
+
|
|
48
|
+
it( 'returns primitive JSON values when they can be parsed', () => {
|
|
49
|
+
expect( clone( null ) ).toBeNull();
|
|
50
|
+
expect( clone( true ) ).toBe( true );
|
|
51
|
+
expect( clone( false ) ).toBe( false );
|
|
52
|
+
expect( clone( 123 ) ).toBe( 123 );
|
|
53
|
+
expect( clone( 'hello' ) ).toBe( 'hello' );
|
|
54
|
+
} );
|
|
55
|
+
|
|
56
|
+
it( 'returns original values when JSON serialization produces no parseable payload', () => {
|
|
57
|
+
const sym = Symbol( 'x' );
|
|
58
|
+
const fn = () => {};
|
|
59
|
+
class Foo {}
|
|
60
|
+
|
|
61
|
+
expect( clone( undefined ) ).toBeUndefined();
|
|
62
|
+
expect( clone( sym ) ).toBe( sym );
|
|
63
|
+
expect( clone( fn ) ).toBe( fn );
|
|
64
|
+
expect( clone( Foo ) ).toBe( Foo );
|
|
65
|
+
expect( clone( Date ) ).toBe( Date );
|
|
66
|
+
expect( clone( Object ) ).toBe( Object );
|
|
67
|
+
expect( clone( Number ) ).toBe( Number );
|
|
68
|
+
} );
|
|
69
|
+
|
|
70
|
+
it( 'returns original values when JSON serialization throws', () => {
|
|
71
|
+
const circular = { name: 'circular' };
|
|
72
|
+
circular.self = circular;
|
|
73
|
+
const bigint = 1n;
|
|
74
|
+
|
|
75
|
+
expect( clone( circular ) ).toBe( circular );
|
|
76
|
+
expect( clone( bigint ) ).toBe( bigint );
|
|
77
|
+
} );
|
|
78
|
+
|
|
79
|
+
it( 'keeps JSON.stringify semantics for special numeric values', () => {
|
|
80
|
+
expect( clone( NaN ) ).toBeNull();
|
|
81
|
+
expect( clone( Infinity ) ).toBeNull();
|
|
82
|
+
expect( clone( -Infinity ) ).toBeNull();
|
|
83
|
+
} );
|
|
84
|
+
|
|
85
|
+
it( 'keeps JSON.stringify semantics for non-plain object instances', () => {
|
|
86
|
+
const date = new Date( '2025-01-01T00:00:00.000Z' );
|
|
87
|
+
|
|
88
|
+
expect( clone( date ) ).toBe( '2025-01-01T00:00:00.000Z' );
|
|
89
|
+
expect( clone( /abc/ ) ).toEqual( {} );
|
|
90
|
+
expect( clone( new Map( [ [ 'a', 1 ] ] ) ) ).toEqual( {} );
|
|
91
|
+
expect( clone( new Set( [ 1, 2 ] ) ) ).toEqual( {} );
|
|
92
|
+
} );
|
|
93
|
+
|
|
94
|
+
it( 'drops object properties that JSON.stringify omits', () => {
|
|
95
|
+
const sym = Symbol( 'x' );
|
|
96
|
+
const original = {
|
|
97
|
+
kept: 'yes',
|
|
98
|
+
missing: undefined,
|
|
99
|
+
fn: () => {},
|
|
100
|
+
sym
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
expect( clone( original ) ).toEqual( { kept: 'yes' } );
|
|
104
|
+
} );
|
|
24
105
|
} );
|
|
25
106
|
|
|
26
107
|
describe( 'allSettledWithTimeout', () => {
|
|
@@ -353,6 +434,57 @@ describe( 'deepMerge', () => {
|
|
|
353
434
|
} );
|
|
354
435
|
} );
|
|
355
436
|
|
|
437
|
+
describe( 'deepMergeWithResolver', () => {
|
|
438
|
+
it( 'uses resolver for existing leaf values, including nested leaves', () => {
|
|
439
|
+
const a = {
|
|
440
|
+
cost: { total: 1 },
|
|
441
|
+
tokens: { total: 2, input: 3 }
|
|
442
|
+
};
|
|
443
|
+
const b = {
|
|
444
|
+
cost: { total: 4 },
|
|
445
|
+
tokens: { total: 5, input: 6, output: 7 }
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
expect( deepMergeWithResolver( a, b, ( x, y ) => x + y ) ).toEqual( {
|
|
449
|
+
cost: { total: 5 },
|
|
450
|
+
tokens: { total: 7, input: 9, output: 7 }
|
|
451
|
+
} );
|
|
452
|
+
} );
|
|
453
|
+
|
|
454
|
+
it( 'copies values from "b" when they do not exist in "a"', () => {
|
|
455
|
+
const resolver = vi.fn( ( x, y ) => x + y );
|
|
456
|
+
|
|
457
|
+
expect( deepMergeWithResolver( { a: 1 }, { b: 2, nested: { c: 3 } }, resolver ) ).toEqual( {
|
|
458
|
+
a: 1,
|
|
459
|
+
b: 2,
|
|
460
|
+
nested: { c: 3 }
|
|
461
|
+
} );
|
|
462
|
+
expect( resolver ).not.toHaveBeenCalled();
|
|
463
|
+
} );
|
|
464
|
+
|
|
465
|
+
it( 'keeps extra values from "a" when absent from "b"', () => {
|
|
466
|
+
expect( deepMergeWithResolver( { a: 1, nested: { kept: 2 } }, { b: 3 }, ( x, y ) => x + y ) ).toEqual( {
|
|
467
|
+
a: 1,
|
|
468
|
+
nested: { kept: 2 },
|
|
469
|
+
b: 3
|
|
470
|
+
} );
|
|
471
|
+
} );
|
|
472
|
+
|
|
473
|
+
it( 'returns a clone of "a" when "b" is not an object', () => {
|
|
474
|
+
const a = { nested: { value: 1 } };
|
|
475
|
+
const result = deepMergeWithResolver( a, null, ( x, y ) => x + y );
|
|
476
|
+
|
|
477
|
+
a.nested.value = 2;
|
|
478
|
+
expect( result ).toEqual( { nested: { value: 1 } } );
|
|
479
|
+
} );
|
|
480
|
+
|
|
481
|
+
it( 'throws when first argument is not a plain object', () => {
|
|
482
|
+
expect( () => deepMergeWithResolver( null, {}, ( x, y ) => x + y ) ).toThrow( Error );
|
|
483
|
+
expect( () => deepMergeWithResolver( [], {}, ( x, y ) => x + y ) ).toThrow( Error );
|
|
484
|
+
expect( () => deepMergeWithResolver( 'a', {}, ( x, y ) => x + y ) ).toThrow( Error );
|
|
485
|
+
} );
|
|
486
|
+
} );
|
|
487
|
+
|
|
356
488
|
describe( 'isPlainObject', () => {
|
|
357
489
|
it( 'Detects plain objects', () => {
|
|
358
490
|
expect( isPlainObject( {} ) ).toBe( true );
|
package/src/worker/configs.js
CHANGED
|
@@ -25,11 +25,6 @@ const envVarSchema = z.object( {
|
|
|
25
25
|
OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 2 * 60 * 1000 ) ), // 2min
|
|
26
26
|
// Whether to send activity heartbeats (enabled by default)
|
|
27
27
|
OUTPUT_ACTIVITY_HEARTBEAT_ENABLED: z.transform( v => v === undefined ? true : isStringboolTrue( v ) ),
|
|
28
|
-
// When true, activities fire Temporal signals carrying attribute/event data (LLM usage,
|
|
29
|
-
// HTTP request count/cost) back to the workflow for aggregation in the result.
|
|
30
|
-
// Defaulted OFF: the current emission architecture bloats Temporal history.
|
|
31
|
-
// Set to "true" to opt in to per-event attribute collection and aggregations.
|
|
32
|
-
OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION: z.transform( v => v === undefined ? false : isStringboolTrue( v ) ),
|
|
33
28
|
// Time to allow for hooks to flush before shutdown
|
|
34
29
|
OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 3000 ) ),
|
|
35
30
|
// HTTP CONNECT proxy for Temporal gRPC connections (e.g. "proxy-host:8080").
|
|
@@ -58,6 +53,5 @@ export const taskQueue = envVars.OUTPUT_CATALOG_ID;
|
|
|
58
53
|
export const catalogId = envVars.OUTPUT_CATALOG_ID;
|
|
59
54
|
export const activityHeartbeatIntervalMs = envVars.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS;
|
|
60
55
|
export const activityHeartbeatEnabled = envVars.OUTPUT_ACTIVITY_HEARTBEAT_ENABLED;
|
|
61
|
-
export const enableAttributeSignalEmission = envVars.OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION;
|
|
62
56
|
export const processFailureShutdownDelay = envVars.OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY;
|
|
63
57
|
export const grpcProxy = envVars.TEMPORAL_GRPC_PROXY;
|
|
@@ -11,8 +11,7 @@ const CONFIG_KEYS = [
|
|
|
11
11
|
'TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_POLLS',
|
|
12
12
|
'TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_POLLS',
|
|
13
13
|
'OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS',
|
|
14
|
-
'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED'
|
|
15
|
-
'OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION'
|
|
14
|
+
'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED'
|
|
16
15
|
];
|
|
17
16
|
|
|
18
17
|
const setEnv = ( overrides = {} ) => {
|
|
@@ -64,7 +63,6 @@ describe( 'worker/configs', () => {
|
|
|
64
63
|
expect( configs.maxConcurrentWorkflowTaskPolls ).toBe( 5 );
|
|
65
64
|
expect( configs.activityHeartbeatIntervalMs ).toBe( 2 * 60 * 1000 );
|
|
66
65
|
expect( configs.activityHeartbeatEnabled ).toBe( true );
|
|
67
|
-
expect( configs.enableAttributeSignalEmission ).toBe( false );
|
|
68
66
|
expect( configs.taskQueue ).toBe( 'test-catalog' );
|
|
69
67
|
expect( configs.catalogId ).toBe( 'test-catalog' );
|
|
70
68
|
} );
|
|
@@ -122,30 +120,6 @@ describe( 'worker/configs', () => {
|
|
|
122
120
|
expect( configsDefault.activityHeartbeatEnabled ).toBe( true );
|
|
123
121
|
} );
|
|
124
122
|
|
|
125
|
-
it( 'OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION: "true"|"1"|"on" → true', async () => {
|
|
126
|
-
for ( const val of [ 'true', '1', 'on' ] ) {
|
|
127
|
-
setEnv( { OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION: val } );
|
|
128
|
-
const configs = await loadConfigs();
|
|
129
|
-
expect( configs.enableAttributeSignalEmission ).toBe( true );
|
|
130
|
-
clearEnv();
|
|
131
|
-
}
|
|
132
|
-
} );
|
|
133
|
-
|
|
134
|
-
it( 'OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION: "false"|other → false, undefined → false (default off)', async () => {
|
|
135
|
-
setEnv( { OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION: 'false' } );
|
|
136
|
-
const configsFalse = await loadConfigs();
|
|
137
|
-
expect( configsFalse.enableAttributeSignalEmission ).toBe( false );
|
|
138
|
-
|
|
139
|
-
setEnv( { OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION: '0' } );
|
|
140
|
-
const configsZero = await loadConfigs();
|
|
141
|
-
expect( configsZero.enableAttributeSignalEmission ).toBe( false );
|
|
142
|
-
|
|
143
|
-
clearEnv();
|
|
144
|
-
setEnv(); // only OUTPUT_CATALOG_ID; OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION absent → default false
|
|
145
|
-
const configsDefault = await loadConfigs();
|
|
146
|
-
expect( configsDefault.enableAttributeSignalEmission ).toBe( false );
|
|
147
|
-
} );
|
|
148
|
-
|
|
149
123
|
it( 'parses TEMPORAL_ADDRESS and TEMPORAL_NAMESPACE', async () => {
|
|
150
124
|
setEnv( { TEMPORAL_ADDRESS: 'temporal:7233', TEMPORAL_NAMESPACE: 'my-ns' } );
|
|
151
125
|
const configs = await loadConfigs();
|
|
@@ -2,17 +2,15 @@ import { Context } from '@temporalio/activity';
|
|
|
2
2
|
import { Storage } from '#async_storage';
|
|
3
3
|
import * as Tracing from '#tracing';
|
|
4
4
|
import { headersToObject } from '../sandboxed_utils.js';
|
|
5
|
-
import { BusEventType, METADATA_ACCESS_SYMBOL, Signal } from '#consts';
|
|
6
|
-
import { activityHeartbeatEnabled, activityHeartbeatIntervalMs,
|
|
5
|
+
import { ACTIVITY_WRAPPER_VERSION_FIELD, BusEventType, METADATA_ACCESS_SYMBOL, Signal } from '#consts';
|
|
6
|
+
import { activityHeartbeatEnabled, activityHeartbeatIntervalMs, namespace } from '../configs.js';
|
|
7
7
|
import { messageBus } from '#bus';
|
|
8
8
|
import { Client } from '@temporalio/client';
|
|
9
9
|
import { createChildLogger } from '#logger';
|
|
10
|
-
import {
|
|
10
|
+
import { aggregateAttributes } from '#internal_utils/aggregations';
|
|
11
11
|
|
|
12
12
|
const log = createChildLogger( 'ActivityInterceptor' );
|
|
13
13
|
|
|
14
|
-
const IN_FLIGHT_SIGNALS_TIMEOUT_MS = 30_000;
|
|
15
|
-
|
|
16
14
|
/*
|
|
17
15
|
This interceptor wraps every activity execution with cross-cutting concerns:
|
|
18
16
|
|
|
@@ -58,56 +56,40 @@ export class ActivityExecutionInterceptor {
|
|
|
58
56
|
|
|
59
57
|
async execute( input, next ) {
|
|
60
58
|
const startDate = Date.now();
|
|
61
|
-
const client = new Client( { connection: this.connection, namespace } );
|
|
62
59
|
|
|
63
60
|
const { workflowExecution: { workflowId }, activityId: id, activityType: name, workflowType: workflowName } = Context.current().info;
|
|
64
61
|
const { executionContext } = headersToObject( input.headers );
|
|
65
62
|
const { type: kind } = this.activities?.[name]?.[METADATA_ACCESS_SYMBOL];
|
|
66
63
|
const { path: workflowFilename } = this.getWorkflowEntry( workflowName );
|
|
67
64
|
|
|
68
|
-
const workflowHandle = client.workflow.getHandle( workflowId );
|
|
69
|
-
|
|
70
65
|
const state = {
|
|
71
66
|
heartbeat: null,
|
|
72
|
-
|
|
73
|
-
signals: []
|
|
67
|
+
attributes: []
|
|
74
68
|
};
|
|
75
69
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
94
|
-
);
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const flushSignals = async signals => {
|
|
98
|
-
try {
|
|
99
|
-
await allSettledWithTimeout( signals, IN_FLIGHT_SIGNALS_TIMEOUT_MS );
|
|
100
|
-
} catch ( error ) {
|
|
101
|
-
if ( error.isTimeout ) {
|
|
102
|
-
log.warn( 'Some usage/cost attributes were missed because not all activity signals were sent to the workflow', errorContext );
|
|
103
|
-
} else {
|
|
104
|
-
throw error;
|
|
70
|
+
const addAttribute = attribute => state.attributes.push( attribute );
|
|
71
|
+
|
|
72
|
+
const sendAggregationsViaSignal = async () => {
|
|
73
|
+
if ( state.attributes.length > 0 ) {
|
|
74
|
+
try {
|
|
75
|
+
const client = new Client( { connection: this.connection, namespace } );
|
|
76
|
+
const workflowHandle = client.workflow.getHandle( workflowId );
|
|
77
|
+
await workflowHandle.signal( Signal.SEND_AGGREGATIONS, aggregateAttributes( state.attributes ) );
|
|
78
|
+
} catch ( error ) {
|
|
79
|
+
log.warn( `Signal "${Signal.SEND_AGGREGATIONS}" failed`, {
|
|
80
|
+
message: error.message,
|
|
81
|
+
stack: error.stack,
|
|
82
|
+
activityId: id,
|
|
83
|
+
activityName: name,
|
|
84
|
+
workflowId,
|
|
85
|
+
workflowName
|
|
86
|
+
} );
|
|
105
87
|
}
|
|
106
88
|
}
|
|
107
89
|
};
|
|
108
90
|
|
|
109
91
|
// Wraps the execution with accessible metadata for the activity
|
|
110
|
-
const ctx = { parentId: id, executionContext, workflowFilename,
|
|
92
|
+
const ctx = { parentId: id, executionContext, workflowFilename, addAttribute };
|
|
111
93
|
|
|
112
94
|
messageBus.emit( BusEventType.ACTIVITY_START, { id, name, kind, workflowId, workflowName } );
|
|
113
95
|
Tracing.addEventStart( { id, name, kind, parentId: workflowId, details: input.args[0], executionContext } );
|
|
@@ -116,21 +98,22 @@ export class ActivityExecutionInterceptor {
|
|
|
116
98
|
// Sends heartbeat to communicate that activity is still alive
|
|
117
99
|
state.heartbeat = activityHeartbeatEnabled && setInterval( () => Context.current().heartbeat(), activityHeartbeatIntervalMs );
|
|
118
100
|
|
|
119
|
-
|
|
120
|
-
state.activityOutput = await Storage.runWithContext( async _ => next( input ), ctx );
|
|
121
|
-
} finally {
|
|
122
|
-
// Ensure in-flight signals are delivered (up to a reasonable time) before handling errors
|
|
123
|
-
await flushSignals( state.signals );
|
|
124
|
-
}
|
|
101
|
+
const output = await Storage.runWithContext( async _ => next( input ), ctx );
|
|
125
102
|
|
|
126
103
|
messageBus.emit( BusEventType.ACTIVITY_END, { id, name, kind, workflowId, workflowName, duration: Date.now() - startDate } );
|
|
127
|
-
Tracing.addEventEnd( { id, details:
|
|
128
|
-
return
|
|
104
|
+
Tracing.addEventEnd( { id, details: output, executionContext } );
|
|
105
|
+
return {
|
|
106
|
+
[ACTIVITY_WRAPPER_VERSION_FIELD]: 1,
|
|
107
|
+
output,
|
|
108
|
+
aggregations: state.attributes.length > 0 ? aggregateAttributes( state.attributes ) : null
|
|
109
|
+
};
|
|
129
110
|
|
|
130
111
|
} catch ( error ) {
|
|
131
112
|
messageBus.emit( BusEventType.ACTIVITY_ERROR, { id, name, kind, workflowId, workflowName, duration: Date.now() - startDate, error } );
|
|
132
113
|
Tracing.addEventError( { id, details: error, executionContext } );
|
|
133
114
|
|
|
115
|
+
await sendAggregationsViaSignal();
|
|
116
|
+
|
|
134
117
|
throw error;
|
|
135
118
|
} finally {
|
|
136
119
|
clearInterval( state.heartbeat );
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { BusEventType, Signal } from '#consts';
|
|
2
|
+
import { ACTIVITY_WRAPPER_VERSION_FIELD, BusEventType, Signal } from '#consts';
|
|
3
|
+
import { Attribute } from '#trace_attribute';
|
|
3
4
|
|
|
4
5
|
const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
|
|
5
6
|
const workflowHandleMock = vi.hoisted( () => ( { signal: vi.fn() } ) );
|
|
6
7
|
const getHandleMock = vi.hoisted( () => vi.fn( () => workflowHandleMock ) );
|
|
7
8
|
const clientConstructorMock = vi.hoisted( () => vi.fn() );
|
|
8
|
-
const allSettledWithTimeoutMock = vi.hoisted( () => vi.fn().mockResolvedValue( [] ) );
|
|
9
9
|
const logWarnMock = vi.hoisted( () => vi.fn() );
|
|
10
10
|
|
|
11
11
|
const heartbeatMock = vi.fn();
|
|
@@ -44,11 +44,6 @@ vi.mock( '#async_storage', () => ( {
|
|
|
44
44
|
}
|
|
45
45
|
} ) );
|
|
46
46
|
|
|
47
|
-
vi.mock( '#utils', async importOriginal => {
|
|
48
|
-
const actual = await importOriginal();
|
|
49
|
-
return { ...actual, allSettledWithTimeout: allSettledWithTimeoutMock };
|
|
50
|
-
} );
|
|
51
|
-
|
|
52
47
|
vi.mock( '#logger', () => ( {
|
|
53
48
|
createChildLogger: () => ( { warn: logWarnMock } )
|
|
54
49
|
} ) );
|
|
@@ -85,9 +80,6 @@ vi.mock( '../configs.js', () => ( {
|
|
|
85
80
|
get activityHeartbeatIntervalMs() {
|
|
86
81
|
return parseInt( process.env.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS || '120000', 10 );
|
|
87
82
|
},
|
|
88
|
-
get enableAttributeSignalEmission() {
|
|
89
|
-
return process.env.OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION === 'true';
|
|
90
|
-
},
|
|
91
83
|
get namespace() {
|
|
92
84
|
return process.env.TEMPORAL_NAMESPACE || 'default';
|
|
93
85
|
}
|
|
@@ -104,18 +96,21 @@ const makeInput = () => ( {
|
|
|
104
96
|
headers: {}
|
|
105
97
|
} );
|
|
106
98
|
|
|
99
|
+
const httpRequestAttribute = {
|
|
100
|
+
type: Attribute.HTTPRequestCount.TYPE,
|
|
101
|
+
url: 'https://api.example.test/items',
|
|
102
|
+
requestId: 'req-1'
|
|
103
|
+
};
|
|
104
|
+
|
|
107
105
|
describe( 'ActivityExecutionInterceptor', () => {
|
|
108
106
|
beforeEach( () => {
|
|
109
107
|
vi.clearAllMocks();
|
|
110
|
-
allSettledWithTimeoutMock.mockResolvedValue( [] );
|
|
111
108
|
workflowHandleMock.signal.mockResolvedValue( undefined );
|
|
112
109
|
vi.useFakeTimers();
|
|
113
110
|
vi.resetModules();
|
|
114
111
|
// Default: heartbeat enabled with 50ms interval for fast tests
|
|
115
112
|
vi.stubEnv( 'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED', 'true' );
|
|
116
113
|
vi.stubEnv( 'OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS', '50' );
|
|
117
|
-
// Default: attribute signal emission enabled so existing tests can verify signal-sending behaviour
|
|
118
|
-
vi.stubEnv( 'OUTPUT_ENABLE_ATTRIBUTE_SIGNAL_EMISSION', 'true' );
|
|
119
114
|
} );
|
|
120
115
|
|
|
121
116
|
afterEach( () => {
|
|
@@ -132,7 +127,11 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
132
127
|
vi.advanceTimersByTime( 0 );
|
|
133
128
|
const output = await promise;
|
|
134
129
|
|
|
135
|
-
expect( output ).toEqual( {
|
|
130
|
+
expect( output ).toEqual( {
|
|
131
|
+
output: { result: 'ok' },
|
|
132
|
+
aggregations: null,
|
|
133
|
+
[ACTIVITY_WRAPPER_VERSION_FIELD]: 1
|
|
134
|
+
} );
|
|
136
135
|
expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_START, expect.objectContaining( {
|
|
137
136
|
id: 'act-1', name: 'myWorkflow#myStep', kind: 'step', workflowId: 'wf-1', workflowName: 'myWorkflow'
|
|
138
137
|
} ) );
|
|
@@ -142,17 +141,17 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
142
141
|
expect( addEventStartMock ).toHaveBeenCalledOnce();
|
|
143
142
|
expect( addEventEndMock ).toHaveBeenCalledOnce();
|
|
144
143
|
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
145
|
-
expect( clientConstructorMock ).
|
|
144
|
+
expect( clientConstructorMock ).not.toHaveBeenCalled();
|
|
146
145
|
expect( runWithContextMock ).toHaveBeenCalledWith(
|
|
147
146
|
expect.any( Function ),
|
|
148
147
|
expect.objectContaining( {
|
|
149
148
|
parentId: 'act-1',
|
|
150
149
|
executionContext: { workflowId: 'wf-1' },
|
|
151
150
|
workflowFilename: '/workflows/myWorkflow.js',
|
|
152
|
-
|
|
151
|
+
addAttribute: expect.any( Function )
|
|
153
152
|
} )
|
|
154
153
|
);
|
|
155
|
-
expect( getHandleMock ).
|
|
154
|
+
expect( getHandleMock ).not.toHaveBeenCalled();
|
|
156
155
|
} );
|
|
157
156
|
|
|
158
157
|
it( 'handles next returning a non-Promise value', async () => {
|
|
@@ -160,36 +159,45 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
160
159
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
161
160
|
const next = vi.fn( () => ( { result: 'sync' } ) );
|
|
162
161
|
|
|
163
|
-
await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( {
|
|
162
|
+
await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( {
|
|
163
|
+
output: { result: 'sync' },
|
|
164
|
+
aggregations: null,
|
|
165
|
+
[ACTIVITY_WRAPPER_VERSION_FIELD]: 1
|
|
166
|
+
} );
|
|
164
167
|
|
|
165
|
-
expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [], 30_000 );
|
|
166
168
|
expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, expect.any( Object ) );
|
|
167
169
|
expect( addEventEndMock ).toHaveBeenCalledWith( { id: 'act-1', details: { result: 'sync' }, executionContext: { workflowId: 'wf-1' } } );
|
|
168
170
|
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
169
171
|
} );
|
|
170
172
|
|
|
171
|
-
it( '
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
it( 'does not signal collected attributes after successful execution', async () => {
|
|
174
|
+
runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
|
|
175
|
+
ctx.addAttribute( httpRequestAttribute );
|
|
176
|
+
return fn();
|
|
177
|
+
} );
|
|
174
178
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
175
179
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
176
180
|
const next = vi.fn().mockResolvedValue( { result: 'ok' } );
|
|
177
181
|
|
|
178
|
-
await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( {
|
|
182
|
+
await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( {
|
|
183
|
+
output: { result: 'ok' },
|
|
184
|
+
aggregations: {
|
|
185
|
+
cost: { total: 0 },
|
|
186
|
+
tokens: { total: 0 },
|
|
187
|
+
httpRequests: { total: 1 }
|
|
188
|
+
},
|
|
189
|
+
[ACTIVITY_WRAPPER_VERSION_FIELD]: 1
|
|
190
|
+
} );
|
|
179
191
|
|
|
180
|
-
expect(
|
|
181
|
-
expect(
|
|
182
|
-
'Some usage/cost attributes were missed because not all activity signals were sent to the workflow',
|
|
183
|
-
{ workflowId: 'wf-1', workflowName: 'myWorkflow', activityId: 'act-1', activityName: 'myWorkflow#myStep' }
|
|
184
|
-
);
|
|
185
|
-
expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, expect.any( Object ) );
|
|
186
|
-
expect( addEventEndMock ).toHaveBeenCalledOnce();
|
|
187
|
-
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
192
|
+
expect( workflowHandleMock.signal ).not.toHaveBeenCalled();
|
|
193
|
+
expect( clientConstructorMock ).not.toHaveBeenCalled();
|
|
188
194
|
} );
|
|
189
195
|
|
|
190
|
-
it( '
|
|
191
|
-
|
|
192
|
-
|
|
196
|
+
it( 'signals collected aggregations after failed execution', async () => {
|
|
197
|
+
runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
|
|
198
|
+
ctx.addAttribute( httpRequestAttribute );
|
|
199
|
+
return fn();
|
|
200
|
+
} );
|
|
193
201
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
194
202
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
195
203
|
const error = new Error( 'step failed' );
|
|
@@ -197,49 +205,49 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
197
205
|
|
|
198
206
|
await expect( interceptor.execute( makeInput(), next ) ).rejects.toThrow( 'step failed' );
|
|
199
207
|
|
|
200
|
-
expect(
|
|
201
|
-
expect(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
208
|
+
expect( clientConstructorMock ).toHaveBeenCalledWith( { connection: undefined, namespace: 'default' } );
|
|
209
|
+
expect( getHandleMock ).toHaveBeenCalledWith( 'wf-1' );
|
|
210
|
+
expect( workflowHandleMock.signal ).toHaveBeenCalledWith( Signal.SEND_AGGREGATIONS, {
|
|
211
|
+
cost: { total: 0 },
|
|
212
|
+
tokens: { total: 0 },
|
|
213
|
+
httpRequests: { total: 1 }
|
|
214
|
+
} );
|
|
205
215
|
expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, expect.objectContaining( { error } ) );
|
|
206
216
|
expect( addEventErrorMock ).toHaveBeenCalledOnce();
|
|
207
217
|
expect( addEventEndMock ).not.toHaveBeenCalled();
|
|
208
218
|
} );
|
|
209
219
|
|
|
210
|
-
it( '
|
|
211
|
-
const attribute = { setActivity: vi.fn() };
|
|
212
|
-
runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
|
|
213
|
-
ctx.sendAttributeSignal( attribute );
|
|
214
|
-
return fn();
|
|
215
|
-
} );
|
|
220
|
+
it( 'does not send fallback signal when failed execution collected no attributes', async () => {
|
|
216
221
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
217
222
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
218
|
-
const next = vi.fn().
|
|
223
|
+
const next = vi.fn().mockRejectedValue( new Error( 'step failed' ) );
|
|
219
224
|
|
|
220
|
-
await expect( interceptor.execute( makeInput(), next ) ).
|
|
225
|
+
await expect( interceptor.execute( makeInput(), next ) ).rejects.toThrow( 'step failed' );
|
|
221
226
|
|
|
222
|
-
expect(
|
|
223
|
-
expect(
|
|
224
|
-
expect( allSettledWithTimeoutMock ).toHaveBeenCalledWith( [ expect.any( Promise ) ], 30_000 );
|
|
227
|
+
expect( workflowHandleMock.signal ).not.toHaveBeenCalled();
|
|
228
|
+
expect( clientConstructorMock ).not.toHaveBeenCalled();
|
|
225
229
|
} );
|
|
226
230
|
|
|
227
|
-
it( '
|
|
228
|
-
|
|
229
|
-
|
|
231
|
+
it( 'logs when fallback attribute signal fails', async () => {
|
|
232
|
+
const signalError = new Error( 'signal failed' );
|
|
233
|
+
workflowHandleMock.signal.mockRejectedValueOnce( signalError );
|
|
230
234
|
runWithContextMock.mockImplementationOnce( async ( fn, ctx ) => {
|
|
231
|
-
ctx.
|
|
235
|
+
ctx.addAttribute( httpRequestAttribute );
|
|
232
236
|
return fn();
|
|
233
237
|
} );
|
|
234
238
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
235
239
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
236
|
-
const next = vi.fn().
|
|
240
|
+
const next = vi.fn().mockRejectedValue( new Error( 'step failed' ) );
|
|
237
241
|
|
|
238
|
-
await expect( interceptor.execute( makeInput(), next ) ).
|
|
242
|
+
await expect( interceptor.execute( makeInput(), next ) ).rejects.toThrow( 'step failed' );
|
|
239
243
|
|
|
240
|
-
expect(
|
|
241
|
-
|
|
242
|
-
|
|
244
|
+
expect( logWarnMock ).toHaveBeenCalledWith( `Signal "${Signal.SEND_AGGREGATIONS}" failed`, expect.objectContaining( {
|
|
245
|
+
message: 'signal failed',
|
|
246
|
+
activityId: 'act-1',
|
|
247
|
+
activityName: 'myWorkflow#myStep',
|
|
248
|
+
workflowId: 'wf-1',
|
|
249
|
+
workflowName: 'myWorkflow'
|
|
250
|
+
} ) );
|
|
243
251
|
} );
|
|
244
252
|
|
|
245
253
|
it( 'records trace error event on failed execution', async () => {
|
|
@@ -1,24 +0,0 @@
|
|
|
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
|
-
} );
|