@outputai/core 0.13.1-next.a28b894.0 → 0.14.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 +1 -1
- package/src/helpers/error_serializer.js +19 -1
- package/src/helpers/error_serializer.spec.js +67 -1
- package/src/worker/interceptors/activity.js +9 -6
- package/src/worker/interceptors/activity.spec.js +40 -6
- package/src/worker/sinks.js +1 -1
- package/src/worker/sinks.spec.js +2 -2
package/package.json
CHANGED
|
@@ -61,6 +61,18 @@ export const flattenPrototypeChain = ( target, depth = 0, result = [], maxDepth
|
|
|
61
61
|
!target || depth >= maxDepth ? result :
|
|
62
62
|
flattenPrototypeChain( Object.getPrototypeOf( target ), depth + 1, result.concat( target ), maxDepth );
|
|
63
63
|
|
|
64
|
+
// Realm agnostic check for DOMException, works for AbortController errors even on environments without DOMExceptions
|
|
65
|
+
export const isDomException = target => Object.prototype.toString.call( target ) === '[object DOMException]';
|
|
66
|
+
|
|
67
|
+
const evaluateSpecialObjectsKeysList = target => {
|
|
68
|
+
// DOMException has a special serialization because it carries a bunch of legacy constant fields
|
|
69
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/DOMException
|
|
70
|
+
if ( isDomException( target ) ) {
|
|
71
|
+
return [ 'name', 'message', 'code', 'cause', 'stack' ];
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
};
|
|
75
|
+
|
|
64
76
|
/**
|
|
65
77
|
* Define the best "name" for an Error object
|
|
66
78
|
* Rules: Assigned .name property > inherited .name if not "Error" > constructor.name
|
|
@@ -176,6 +188,8 @@ const serializeValue = ( target, options, state = { depth: 0, seen: new GlobalCo
|
|
|
176
188
|
const prototypes = flattenPrototypeChain( target );
|
|
177
189
|
const receiver = target;
|
|
178
190
|
|
|
191
|
+
const strictKeysList = evaluateSpecialObjectsKeysList( target );
|
|
192
|
+
|
|
179
193
|
const props = prototypes.reduce( ( projection, proto ) => {
|
|
180
194
|
if ( Object.keys( projection ).length > MAX_OBJECT_KEYS ) {
|
|
181
195
|
return projection;
|
|
@@ -187,6 +201,10 @@ const serializeValue = ( target, options, state = { depth: 0, seen: new GlobalCo
|
|
|
187
201
|
continue;
|
|
188
202
|
}
|
|
189
203
|
|
|
204
|
+
if ( Array.isArray( strictKeysList ) && !strictKeysList.includes( key ) ) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
190
208
|
const sanitizedKey = truncateString( key, MAX_OBJECT_KEY_LEN );
|
|
191
209
|
if ( Object.hasOwn( projection, sanitizedKey ) ) { // do not overwrite similar keys
|
|
192
210
|
continue;
|
|
@@ -212,7 +230,7 @@ const serializeValue = ( target, options, state = { depth: 0, seen: new GlobalCo
|
|
|
212
230
|
if ( !options.ignoredKeys.includes( 'name' ) && target instanceof Error ) {
|
|
213
231
|
const name = resolveErrorName( target );
|
|
214
232
|
if ( name !== undefined ) {
|
|
215
|
-
props.name = name;
|
|
233
|
+
props.name = typeof name === 'string' ? truncateString( name ) : serializeValue( name, options, nextState );
|
|
216
234
|
}
|
|
217
235
|
}
|
|
218
236
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
flattenPrototypeChain,
|
|
4
|
+
isDomException,
|
|
4
5
|
resolveErrorName,
|
|
5
6
|
serializeError,
|
|
6
7
|
truncateArray,
|
|
@@ -43,6 +44,33 @@ describe( 'error serializer helpers', () => {
|
|
|
43
44
|
} );
|
|
44
45
|
} );
|
|
45
46
|
|
|
47
|
+
describe( 'isDomException', () => {
|
|
48
|
+
afterEach( () => {
|
|
49
|
+
vi.unstubAllGlobals();
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
it( 'detects DOMExceptions, including abort reasons, and rejects lookalikes', () => {
|
|
53
|
+
expect( isDomException( new DOMException( 'aborted', 'AbortError' ) ) ).toBe( true );
|
|
54
|
+
expect( isDomException( AbortSignal.abort().reason ) ).toBe( true );
|
|
55
|
+
expect( isDomException( new Error( 'aborted' ) ) ).toBe( false );
|
|
56
|
+
expect( isDomException( { name: 'AbortError', code: 20 } ) ).toBe( false );
|
|
57
|
+
expect( isDomException( undefined ) ).toBe( false );
|
|
58
|
+
expect( isDomException( null ) ).toBe( false );
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
it( 'detects DOMExceptions where the global is unavailable, as in the workflow sandbox', () => {
|
|
62
|
+
const error = new DOMException( 'aborted', 'AbortError' );
|
|
63
|
+
vi.stubGlobal( 'DOMException', undefined );
|
|
64
|
+
|
|
65
|
+
expect( isDomException( error ) ).toBe( true );
|
|
66
|
+
expect( serializeError( error, { dropKeys: [ 'stack' ] } ) ).toEqual( {
|
|
67
|
+
name: 'AbortError',
|
|
68
|
+
message: 'aborted',
|
|
69
|
+
code: 20
|
|
70
|
+
} );
|
|
71
|
+
} );
|
|
72
|
+
} );
|
|
73
|
+
|
|
46
74
|
describe( 'resolveErrorName', () => {
|
|
47
75
|
it( 'uses explicit, inherited, and constructor names in precedence order', () => {
|
|
48
76
|
class ConstructorNamedError extends Error {}
|
|
@@ -194,6 +222,33 @@ describe( 'serializeError', () => {
|
|
|
194
222
|
expect( serializeError( /request-\d+/gi ) ).toBe( '/request-\\d+/gi' );
|
|
195
223
|
} );
|
|
196
224
|
|
|
225
|
+
it( 'serializes DOMException values without the legacy prototype constants', () => {
|
|
226
|
+
const error = new DOMException( 'Cancelled by caller', 'AbortError' );
|
|
227
|
+
|
|
228
|
+
expect( serializeError( error ) ).toEqual( {
|
|
229
|
+
name: 'AbortError',
|
|
230
|
+
message: 'Cancelled by caller',
|
|
231
|
+
code: 20,
|
|
232
|
+
stack: expect.stringContaining( 'AbortError: Cancelled by caller' )
|
|
233
|
+
} );
|
|
234
|
+
expect( serializeError( error, { dropKeys: [ /stack/ ] } ) ).toEqual( {
|
|
235
|
+
name: 'AbortError',
|
|
236
|
+
message: 'Cancelled by caller',
|
|
237
|
+
code: 20
|
|
238
|
+
} );
|
|
239
|
+
} );
|
|
240
|
+
|
|
241
|
+
it( 'marks circular DOMException fields and drops properties outside the allowed list', () => {
|
|
242
|
+
const error = new DOMException( 'aborted', 'AbortError' );
|
|
243
|
+
Object.defineProperty( error, 'message', { value: error, enumerable: true } );
|
|
244
|
+
error.attempt = 2;
|
|
245
|
+
|
|
246
|
+
expect( serializeError( { reason: error, sibling: 'preserved' }, { dropKeys: [ 'stack' ] } ) ).toEqual( {
|
|
247
|
+
reason: { name: 'AbortError', message: '[Circular Reference]', code: 20 },
|
|
248
|
+
sibling: 'preserved'
|
|
249
|
+
} );
|
|
250
|
+
} );
|
|
251
|
+
|
|
197
252
|
it( 'serializes bigint values with an n suffix', () => {
|
|
198
253
|
expect( serializeError( 42n ) ).toBe( '42n' );
|
|
199
254
|
expect( serializeError( -42n ) ).toBe( '-42n' );
|
|
@@ -298,6 +353,17 @@ describe( 'serializeError', () => {
|
|
|
298
353
|
expect( serialized['...'] ).toBe( '[Maximum Object Properties Reached]' );
|
|
299
354
|
} );
|
|
300
355
|
|
|
356
|
+
it( 'serializes the resolved error name instead of returning it raw', () => {
|
|
357
|
+
const circular = new Error( 'boom' );
|
|
358
|
+
circular.name = circular;
|
|
359
|
+
const oversized = new Error( 'boom' );
|
|
360
|
+
oversized.name = 'n'.repeat( 16_385 );
|
|
361
|
+
|
|
362
|
+
expect( serializeError( circular, { dropKeys: [ 'stack' ] } ).name ).toBe( '[Circular Reference]' );
|
|
363
|
+
expect( serializeError( oversized, { dropKeys: [ 'stack' ] } ).name )
|
|
364
|
+
.toBe( `${'n'.repeat( 16_384 )}... [1 more characters omitted]` );
|
|
365
|
+
} );
|
|
366
|
+
|
|
301
367
|
it( 'retains error identity when properties reach the object limit', () => {
|
|
302
368
|
class DetailedError extends Error {}
|
|
303
369
|
const error = new DetailedError( 'failed' );
|
|
@@ -40,10 +40,13 @@ export class ActivityExecutionInterceptor {
|
|
|
40
40
|
|
|
41
41
|
async execute( input, next ) {
|
|
42
42
|
const activityInfo = activityInfoFn();
|
|
43
|
-
const { workflowExecution: { runId }, activityId, activityType, workflowType } = activityInfo;
|
|
43
|
+
const { workflowExecution: { runId }, activityId, attempt, activityType, workflowType } = activityInfo;
|
|
44
44
|
const { traceInfo, workflowDetails } = headersToObject( input.headers );
|
|
45
45
|
const outputActivityKind = this.activityKindMap.get( activityType );
|
|
46
46
|
const workflowFilename = this.workflowsPathMap.get( workflowType );
|
|
47
|
+
// Retries reuse the same activityId, so the attempt is appended to keep each attempt a discrete trace entry.
|
|
48
|
+
// Also prefix with the runId to avoid conflict with child workflows.
|
|
49
|
+
const traceId = `${runId}:${activityId}:${attempt}`;
|
|
47
50
|
|
|
48
51
|
if ( !outputActivityKind ) {
|
|
49
52
|
throw new Error( `Activity interceptor: activity "${activityType}" was not registered.` );
|
|
@@ -58,7 +61,7 @@ export class ActivityExecutionInterceptor {
|
|
|
58
61
|
|
|
59
62
|
// Adds context accessible information
|
|
60
63
|
const storageContext = {
|
|
61
|
-
parentId:
|
|
64
|
+
parentId: traceId,
|
|
62
65
|
outputActivityKind,
|
|
63
66
|
activityInfo,
|
|
64
67
|
workflowDetails,
|
|
@@ -67,7 +70,7 @@ export class ActivityExecutionInterceptor {
|
|
|
67
70
|
};
|
|
68
71
|
|
|
69
72
|
mainEventBus.emit( BusEventType.ACTIVITY_START, { activityInfo, workflowDetails, outputActivityKind } );
|
|
70
|
-
Tracing.addEventStart( { id:
|
|
73
|
+
Tracing.addEventStart( { id: traceId, name: activityType, kind: outputActivityKind, parentId: runId, details: input.args[0], traceInfo } );
|
|
71
74
|
|
|
72
75
|
try {
|
|
73
76
|
// Sends heartbeat to communicate that activity is still alive
|
|
@@ -76,20 +79,20 @@ export class ActivityExecutionInterceptor {
|
|
|
76
79
|
const output = await Storage.runWithContext( async _ => next( input ), storageContext );
|
|
77
80
|
|
|
78
81
|
mainEventBus.emit( BusEventType.ACTIVITY_END, { activityInfo, workflowDetails, outputActivityKind } );
|
|
79
|
-
Tracing.addEventEnd( { id:
|
|
82
|
+
Tracing.addEventEnd( { id: traceId, details: output, traceInfo } );
|
|
80
83
|
|
|
81
84
|
return output;
|
|
82
85
|
|
|
83
86
|
} catch ( error ) {
|
|
84
87
|
// Record async completion handoff as a trace end without emitting a bus event.
|
|
85
88
|
if ( error instanceof CompleteAsyncError ) {
|
|
86
|
-
Tracing.addEventEnd( { id:
|
|
89
|
+
Tracing.addEventEnd( { id: traceId, details: ActivitySpecialOutput.ASYNC_HANDOFF, traceInfo } );
|
|
87
90
|
throw error;
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
const unwrappedError = error instanceof TransparentFatalError ? error.cause : error;
|
|
91
94
|
|
|
92
|
-
Tracing.addEventError( { id:
|
|
95
|
+
Tracing.addEventError( { id: traceId, details: unwrappedError, traceInfo } );
|
|
93
96
|
mainEventBus.emit( BusEventType.ACTIVITY_ERROR, { activityInfo, workflowDetails, outputActivityKind, error: unwrappedError } );
|
|
94
97
|
|
|
95
98
|
// Native Temporal errors are just re-thrown
|
|
@@ -8,6 +8,7 @@ const runWithContextMock = vi.hoisted( () => vi.fn().mockImplementation( async f
|
|
|
8
8
|
const activityInfoMock = vi.hoisted( () => ( {
|
|
9
9
|
workflowExecution: { workflowId: 'wf-1', runId: 'run-1' },
|
|
10
10
|
activityId: 'act-1',
|
|
11
|
+
attempt: 1,
|
|
11
12
|
activityType: 'myWorkflow#myStep',
|
|
12
13
|
workflowType: 'myWorkflow'
|
|
13
14
|
} ) );
|
|
@@ -87,6 +88,8 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
87
88
|
beforeEach( () => {
|
|
88
89
|
vi.clearAllMocks();
|
|
89
90
|
activityInfoMock.workflowType = 'myWorkflow';
|
|
91
|
+
activityInfoMock.workflowExecution.runId = 'run-1';
|
|
92
|
+
activityInfoMock.attempt = 1;
|
|
90
93
|
delete activityInfoMock.retryPolicy;
|
|
91
94
|
vi.useFakeTimers();
|
|
92
95
|
// Default: heartbeat enabled with 50ms interval for fast tests
|
|
@@ -114,19 +117,19 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
114
117
|
{ activityInfo: activityInfoMock, workflowDetails: workflowDetailsMock, outputActivityKind: 'step' }
|
|
115
118
|
);
|
|
116
119
|
expect( addEventStartMock ).toHaveBeenCalledWith( {
|
|
117
|
-
id: 'act-1',
|
|
120
|
+
id: 'run-1:act-1:1',
|
|
118
121
|
name: 'myWorkflow#myStep',
|
|
119
122
|
kind: 'step',
|
|
120
123
|
parentId: 'run-1',
|
|
121
124
|
details: { someInput: 'data' },
|
|
122
125
|
traceInfo: traceInfoMock
|
|
123
126
|
} );
|
|
124
|
-
expect( addEventEndMock ).toHaveBeenCalledWith( { id: 'act-1', details: { result: 'ok' }, traceInfo: traceInfoMock } );
|
|
127
|
+
expect( addEventEndMock ).toHaveBeenCalledWith( { id: 'run-1:act-1:1', details: { result: 'ok' }, traceInfo: traceInfoMock } );
|
|
125
128
|
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
126
129
|
expect( runWithContextMock ).toHaveBeenCalledWith(
|
|
127
130
|
expect.any( Function ),
|
|
128
131
|
expect.objectContaining( {
|
|
129
|
-
parentId: 'act-1',
|
|
132
|
+
parentId: 'run-1:act-1:1',
|
|
130
133
|
activityInfo: activityInfoMock,
|
|
131
134
|
workflowDetails: workflowDetailsMock,
|
|
132
135
|
outputActivityKind: 'step',
|
|
@@ -136,6 +139,37 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
136
139
|
);
|
|
137
140
|
} );
|
|
138
141
|
|
|
142
|
+
it( 'scopes trace ids by attempt so retries do not reuse the same id', async () => {
|
|
143
|
+
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
144
|
+
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
145
|
+
activityInfoMock.attempt = 3;
|
|
146
|
+
const next = vi.fn().mockResolvedValue( { result: 'ok' } );
|
|
147
|
+
|
|
148
|
+
await expect( interceptor.execute( makeInput(), next ) ).resolves.toEqual( { result: 'ok' } );
|
|
149
|
+
expect( addEventStartMock ).toHaveBeenCalledWith( expect.objectContaining( { id: 'run-1:act-1:3', parentId: 'run-1' } ) );
|
|
150
|
+
expect( addEventEndMock ).toHaveBeenCalledWith( expect.objectContaining( { id: 'run-1:act-1:3' } ) );
|
|
151
|
+
expect( runWithContextMock ).toHaveBeenCalledWith(
|
|
152
|
+
expect.any( Function ),
|
|
153
|
+
expect.objectContaining( { parentId: 'run-1:act-1:3' } )
|
|
154
|
+
);
|
|
155
|
+
} );
|
|
156
|
+
|
|
157
|
+
it( 'scopes trace ids by run so a child workflow reusing an activityId gets a distinct id', async () => {
|
|
158
|
+
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
159
|
+
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
160
|
+
const next = vi.fn().mockResolvedValue( { result: 'ok' } );
|
|
161
|
+
|
|
162
|
+
// Temporal numbers activityIds per run, so a parent and its child both reach the same 'act-1' on attempt 1.
|
|
163
|
+
await interceptor.execute( makeInput(), next );
|
|
164
|
+
activityInfoMock.workflowExecution.runId = 'run-2';
|
|
165
|
+
await interceptor.execute( makeInput(), next );
|
|
166
|
+
|
|
167
|
+
expect( addEventStartMock.mock.calls.map( ( [ { id, parentId } ] ) => ( { id, parentId } ) ) ).toEqual( [
|
|
168
|
+
{ id: 'run-1:act-1:1', parentId: 'run-1' },
|
|
169
|
+
{ id: 'run-2:act-1:1', parentId: 'run-2' }
|
|
170
|
+
] );
|
|
171
|
+
} );
|
|
172
|
+
|
|
139
173
|
it( 'wraps native errors with serialized details', async () => {
|
|
140
174
|
const { ActivityExecutionInterceptor } = await import( './activity.js' );
|
|
141
175
|
const interceptor = new ActivityExecutionInterceptor( { activities: makeActivities(), workflows: makeWorkflows() } );
|
|
@@ -210,7 +244,7 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
210
244
|
}
|
|
211
245
|
} ]
|
|
212
246
|
} );
|
|
213
|
-
expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'act-1', details: cause, traceInfo: traceInfoMock } );
|
|
247
|
+
expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'run-1:act-1:1', details: cause, traceInfo: traceInfoMock } );
|
|
214
248
|
expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, {
|
|
215
249
|
activityInfo: activityInfoMock,
|
|
216
250
|
workflowDetails: workflowDetailsMock,
|
|
@@ -226,7 +260,7 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
226
260
|
const next = vi.fn().mockRejectedValue( error );
|
|
227
261
|
|
|
228
262
|
await expect( interceptor.execute( makeInput(), next ) ).rejects.toBe( error );
|
|
229
|
-
expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'act-1', details: error, traceInfo: traceInfoMock } );
|
|
263
|
+
expect( addEventErrorMock ).toHaveBeenCalledWith( { id: 'run-1:act-1:1', details: error, traceInfo: traceInfoMock } );
|
|
230
264
|
expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, {
|
|
231
265
|
activityInfo: activityInfoMock,
|
|
232
266
|
workflowDetails: workflowDetailsMock,
|
|
@@ -245,7 +279,7 @@ describe( 'ActivityExecutionInterceptor', () => {
|
|
|
245
279
|
expect( mainEventBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_START, expect.any( Object ) );
|
|
246
280
|
expect( mainEventBusEmitMock ).toHaveBeenCalledTimes( 1 );
|
|
247
281
|
expect( addEventEndMock ).toHaveBeenCalledWith( {
|
|
248
|
-
id: 'act-1',
|
|
282
|
+
id: 'run-1:act-1:1',
|
|
249
283
|
details: ActivitySpecialOutput.ASYNC_HANDOFF,
|
|
250
284
|
traceInfo: traceInfoMock
|
|
251
285
|
} );
|
package/src/worker/sinks.js
CHANGED
|
@@ -65,7 +65,7 @@ export const sinks = {
|
|
|
65
65
|
trace: {
|
|
66
66
|
start: {
|
|
67
67
|
fn: ( workflowInfo, { id, name, kind, details } ) =>
|
|
68
|
-
Tracing.addEventStart( { id, kind, name, details, parentId: workflowInfo.
|
|
68
|
+
Tracing.addEventStart( { id, kind, name, details, parentId: workflowInfo.runId, traceInfo: workflowInfo.memo.traceInfo } ),
|
|
69
69
|
callDuringReplay: false
|
|
70
70
|
},
|
|
71
71
|
|
package/src/worker/sinks.spec.js
CHANGED
|
@@ -167,7 +167,7 @@ describe( 'worker/sinks', () => {
|
|
|
167
167
|
expect( addEventErrorMock ).not.toHaveBeenCalled();
|
|
168
168
|
} );
|
|
169
169
|
|
|
170
|
-
it( 'trace.start
|
|
170
|
+
it( 'trace.start parents trace start events on the current workflow, not its parent workflow', async () => {
|
|
171
171
|
const { sinks } = await import( './sinks.js' );
|
|
172
172
|
|
|
173
173
|
sinks.trace.start.fn( workflowInfo, {
|
|
@@ -182,7 +182,7 @@ describe( 'worker/sinks', () => {
|
|
|
182
182
|
kind: ComponentType.STEP,
|
|
183
183
|
name: 'myStep',
|
|
184
184
|
details: { input: true },
|
|
185
|
-
parentId: '
|
|
185
|
+
parentId: 'run-1',
|
|
186
186
|
traceInfo: { traceId: 'trace-1' }
|
|
187
187
|
} );
|
|
188
188
|
} );
|