@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Signal } from '#consts';
|
|
1
|
+
import { ACTIVITY_WRAPPER_VERSION_FIELD, Signal, WORKFLOW_WRAPPER_VERSION_FIELD } from '#consts';
|
|
2
2
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
@@ -65,12 +65,6 @@ vi.mock( '#consts', async importOriginal => {
|
|
|
65
65
|
};
|
|
66
66
|
} );
|
|
67
67
|
|
|
68
|
-
const emptyAggregations = {
|
|
69
|
-
cost: { total: 0 },
|
|
70
|
-
tokens: { total: 0 },
|
|
71
|
-
httpRequests: { total: 0 }
|
|
72
|
-
};
|
|
73
|
-
|
|
74
68
|
describe( 'workflow()', () => {
|
|
75
69
|
beforeEach( () => {
|
|
76
70
|
vi.clearAllMocks();
|
|
@@ -236,94 +230,64 @@ describe( 'workflow()', () => {
|
|
|
236
230
|
} );
|
|
237
231
|
|
|
238
232
|
describe( 'root workflow (in workflow context)', () => {
|
|
239
|
-
it( '
|
|
233
|
+
it( 'unwraps wrapped trace destinations and assigns executionContext to memo', async () => {
|
|
234
|
+
traceDestinationsStepMock.mockResolvedValueOnce( {
|
|
235
|
+
output: { local: '/tmp/wrapped-trace' },
|
|
236
|
+
aggregations: null,
|
|
237
|
+
[ACTIVITY_WRAPPER_VERSION_FIELD]: 1
|
|
238
|
+
} );
|
|
240
239
|
const { workflow } = await import( './workflow.js' );
|
|
241
240
|
|
|
242
241
|
const wf = workflow( {
|
|
243
|
-
name: '
|
|
244
|
-
description: '
|
|
242
|
+
name: 'wrapped_trace_wf',
|
|
243
|
+
description: 'Wrapped trace',
|
|
245
244
|
inputSchema: z.object( {} ),
|
|
246
|
-
outputSchema: z.object( {
|
|
247
|
-
fn: async () => ( {
|
|
245
|
+
outputSchema: z.object( { ok: z.boolean() } ),
|
|
246
|
+
fn: async () => ( { ok: true } )
|
|
248
247
|
} );
|
|
249
248
|
|
|
250
249
|
const result = await wf( {} );
|
|
251
250
|
expect( traceDestinationsStepMock ).toHaveBeenCalledTimes( 1 );
|
|
252
251
|
expect( result ).toEqual( {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
aggregations:
|
|
252
|
+
[WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
|
|
253
|
+
output: { ok: true },
|
|
254
|
+
trace: { destinations: { local: '/tmp/wrapped-trace' } },
|
|
255
|
+
aggregations: null
|
|
257
256
|
} );
|
|
258
257
|
const memo = workflowInfoMock().memo;
|
|
259
258
|
expect( memo.executionContext ).toEqual( {
|
|
260
259
|
workflowId: 'wf-test-123',
|
|
261
|
-
workflowName: '
|
|
260
|
+
workflowName: 'wrapped_trace_wf',
|
|
262
261
|
disableTrace: false,
|
|
263
262
|
startTime: new Date( '2025-01-01T00:00:00Z' ).getTime()
|
|
264
263
|
} );
|
|
265
264
|
} );
|
|
266
265
|
|
|
267
|
-
it( 'collects
|
|
266
|
+
it( 'collects batched aggregation signals from failed activities', async () => {
|
|
268
267
|
const { workflow } = await import( './workflow.js' );
|
|
269
|
-
const
|
|
270
|
-
const handlers = { addAttribute: () => {} };
|
|
268
|
+
const handlers = { sendAggregations: () => {} };
|
|
271
269
|
setHandlerMock.mockImplementation( ( signalName, handler ) => {
|
|
272
|
-
if ( signalName === Signal.
|
|
273
|
-
handlers.
|
|
270
|
+
if ( signalName === Signal.SEND_AGGREGATIONS ) {
|
|
271
|
+
handlers.sendAggregations = handler;
|
|
274
272
|
}
|
|
275
273
|
} );
|
|
276
274
|
|
|
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
275
|
const wf = workflow( {
|
|
300
|
-
name: '
|
|
301
|
-
description: '
|
|
276
|
+
name: 'batched_attr_wf',
|
|
277
|
+
description: 'Batched aggregations',
|
|
302
278
|
inputSchema: z.object( {} ),
|
|
303
279
|
outputSchema: z.object( { ok: z.boolean() } ),
|
|
304
280
|
fn: async () => {
|
|
305
|
-
handlers.
|
|
306
|
-
handlers.addAttribute( httpCost );
|
|
307
|
-
handlers.addAttribute( llmUsage );
|
|
281
|
+
handlers.sendAggregations( { cost: { total: 3 }, tokens: { total: 0 }, httpRequests: { total: 1 } } );
|
|
308
282
|
return { ok: true };
|
|
309
283
|
}
|
|
310
284
|
} );
|
|
311
285
|
|
|
312
286
|
const result = await wf( {} );
|
|
313
|
-
expect( result ).
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
} );
|
|
287
|
+
expect( result.cost ).toBeUndefined();
|
|
288
|
+
expect( result ).not.toHaveProperty( 'attributes' );
|
|
289
|
+
expect( result.aggregations.cost ).toEqual( { total: 3 } );
|
|
290
|
+
expect( result.aggregations.httpRequests ).toEqual( { total: 1 } );
|
|
327
291
|
} );
|
|
328
292
|
|
|
329
293
|
it( 'sets executionContext.disableTrace when options.disableTrace is true', async () => {
|
|
@@ -361,41 +325,39 @@ describe( 'workflow()', () => {
|
|
|
361
325
|
|
|
362
326
|
const result = await wf( {} );
|
|
363
327
|
expect( traceDestinationsStepMock ).not.toHaveBeenCalled();
|
|
364
|
-
expect( result ).toEqual( {
|
|
328
|
+
expect( result ).toEqual( {
|
|
329
|
+
[WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
|
|
330
|
+
output: { x: 'child' },
|
|
331
|
+
aggregations: null
|
|
332
|
+
} );
|
|
365
333
|
} );
|
|
366
334
|
} );
|
|
367
335
|
|
|
368
336
|
describe( 'bound this: invokeStep, invokeSharedStep, invokeEvaluator', () => {
|
|
369
|
-
it( 'invokeStep
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if ( typeof prop === 'string' && prop.includes( '#' ) ) {
|
|
377
|
-
getCalls.push( prop );
|
|
378
|
-
return vi.fn().mockResolvedValue( {} );
|
|
379
|
-
}
|
|
380
|
-
return vi.fn();
|
|
381
|
-
}
|
|
382
|
-
} ) );
|
|
337
|
+
it( 'invokeStep unwraps step output and merges step aggregations', async () => {
|
|
338
|
+
const stepSpy = vi.fn().mockResolvedValue( {
|
|
339
|
+
output: { value: 'wrapped' },
|
|
340
|
+
aggregations: { cost: { total: 0 }, tokens: { total: 0 }, httpRequests: { total: 1 } },
|
|
341
|
+
[ACTIVITY_WRAPPER_VERSION_FIELD]: 1
|
|
342
|
+
} );
|
|
343
|
+
proxyActivitiesMock.mockImplementation( () => createStepsProxy( stepSpy ) );
|
|
383
344
|
|
|
384
345
|
const { workflow } = await import( './workflow.js' );
|
|
385
346
|
|
|
386
347
|
const wf = workflow( {
|
|
387
|
-
name: '
|
|
388
|
-
description: '
|
|
348
|
+
name: 'unwrap_step_wf',
|
|
349
|
+
description: 'Unwrap step',
|
|
389
350
|
inputSchema: z.object( {} ),
|
|
390
|
-
outputSchema: z.object( {} ),
|
|
351
|
+
outputSchema: z.object( { value: z.string() } ),
|
|
391
352
|
async fn() {
|
|
392
|
-
|
|
393
|
-
return {};
|
|
353
|
+
return this.invokeStep( 'myStep', { foo: 1 } );
|
|
394
354
|
}
|
|
395
355
|
} );
|
|
396
356
|
|
|
397
|
-
await wf( {} );
|
|
398
|
-
expect(
|
|
357
|
+
const result = await wf( {} );
|
|
358
|
+
expect( result.output ).toEqual( { value: 'wrapped' } );
|
|
359
|
+
expect( result ).not.toHaveProperty( 'attributes' );
|
|
360
|
+
expect( result.aggregations.httpRequests ).toEqual( { total: 1 } );
|
|
399
361
|
} );
|
|
400
362
|
|
|
401
363
|
it( 'invokeSharedStep calls steps with SHARED_STEP_PREFIX#stepName', async () => {
|
|
@@ -464,7 +426,7 @@ describe( 'workflow()', () => {
|
|
|
464
426
|
it( 'calls executeChild with correct args and TERMINATE when not detached', async () => {
|
|
465
427
|
const { workflow } = await import( './workflow.js' );
|
|
466
428
|
const { ParentClosePolicy } = await import( '@temporalio/workflow' );
|
|
467
|
-
executeChildMock.mockResolvedValueOnce( { output: {},
|
|
429
|
+
executeChildMock.mockResolvedValueOnce( { output: {}, aggregations: null } );
|
|
468
430
|
|
|
469
431
|
const wf = workflow( {
|
|
470
432
|
name: 'parent_wf',
|
|
@@ -492,7 +454,7 @@ describe( 'workflow()', () => {
|
|
|
492
454
|
it( 'uses ABANDON when extra.detached is true', async () => {
|
|
493
455
|
const { workflow } = await import( './workflow.js' );
|
|
494
456
|
const { ParentClosePolicy } = await import( '@temporalio/workflow' );
|
|
495
|
-
executeChildMock.mockResolvedValueOnce( { output: {},
|
|
457
|
+
executeChildMock.mockResolvedValueOnce( { output: {}, aggregations: null } );
|
|
496
458
|
|
|
497
459
|
const wf = workflow( {
|
|
498
460
|
name: 'detach_wf',
|
|
@@ -513,7 +475,7 @@ describe( 'workflow()', () => {
|
|
|
513
475
|
|
|
514
476
|
it( 'passes empty args when input is null/omitted', async () => {
|
|
515
477
|
const { workflow } = await import( './workflow.js' );
|
|
516
|
-
executeChildMock.mockResolvedValueOnce( { output: {},
|
|
478
|
+
executeChildMock.mockResolvedValueOnce( { output: {}, aggregations: null } );
|
|
517
479
|
|
|
518
480
|
const wf = workflow( {
|
|
519
481
|
name: 'no_input_wf',
|
|
@@ -532,26 +494,20 @@ describe( 'workflow()', () => {
|
|
|
532
494
|
} ) );
|
|
533
495
|
} );
|
|
534
496
|
|
|
535
|
-
it( 'returns child output and merges child
|
|
497
|
+
it( 'returns child output and merges child workflow aggregations into the root aggregations', async () => {
|
|
536
498
|
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
499
|
executeChildMock.mockResolvedValueOnce( {
|
|
548
500
|
output: { child: 'ok' },
|
|
549
|
-
|
|
501
|
+
aggregations: {
|
|
502
|
+
cost: { total: 1.5 },
|
|
503
|
+
tokens: { total: 4, input: 4 },
|
|
504
|
+
httpRequests: { total: 2 }
|
|
505
|
+
}
|
|
550
506
|
} );
|
|
551
507
|
|
|
552
508
|
const wf = workflow( {
|
|
553
|
-
name: '
|
|
554
|
-
description: 'Merge child
|
|
509
|
+
name: 'merge_child_aggregations_wf',
|
|
510
|
+
description: 'Merge child aggregations',
|
|
555
511
|
inputSchema: z.object( {} ),
|
|
556
512
|
outputSchema: z.object( { child: z.string() } ),
|
|
557
513
|
async fn() {
|
|
@@ -561,40 +517,36 @@ describe( 'workflow()', () => {
|
|
|
561
517
|
|
|
562
518
|
const result = await wf( {} );
|
|
563
519
|
expect( result ).toEqual( {
|
|
520
|
+
[WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
|
|
564
521
|
output: { child: 'ok' },
|
|
565
522
|
trace: { destinations: { local: '/tmp/trace' } },
|
|
566
|
-
attributes: [ childAttribute ],
|
|
567
523
|
aggregations: {
|
|
568
|
-
cost: { total:
|
|
569
|
-
tokens: {
|
|
570
|
-
|
|
571
|
-
input: 20
|
|
572
|
-
},
|
|
573
|
-
httpRequests: { total: 0 }
|
|
524
|
+
cost: { total: 1.5 },
|
|
525
|
+
tokens: { total: 4, input: 4 },
|
|
526
|
+
httpRequests: { total: 2 }
|
|
574
527
|
}
|
|
575
528
|
} );
|
|
576
529
|
} );
|
|
577
530
|
|
|
578
|
-
it( 'merges child error
|
|
531
|
+
it( 'merges child error aggregations before rethrowing to root metadata', async () => {
|
|
579
532
|
const { workflow } = await import( './workflow.js' );
|
|
580
533
|
const { ChildWorkflowFailure } = await import( '@temporalio/workflow' );
|
|
581
534
|
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
535
|
const childError = new ChildWorkflowFailure( 'child failed', {
|
|
590
536
|
message: 'Child workflow execution failed',
|
|
591
|
-
details: [ {
|
|
537
|
+
details: [ {
|
|
538
|
+
aggregations: {
|
|
539
|
+
cost: { total: 3 },
|
|
540
|
+
tokens: { total: 8, output: 8 },
|
|
541
|
+
httpRequests: { total: 0 }
|
|
542
|
+
}
|
|
543
|
+
} ]
|
|
592
544
|
} );
|
|
593
545
|
executeChildMock.mockRejectedValueOnce( childError );
|
|
594
546
|
|
|
595
547
|
const wf = workflow( {
|
|
596
|
-
name: '
|
|
597
|
-
description: 'Child error
|
|
548
|
+
name: 'child_error_aggregations_wf',
|
|
549
|
+
description: 'Child error aggregations',
|
|
598
550
|
inputSchema: z.object( {} ),
|
|
599
551
|
outputSchema: z.object( {} ),
|
|
600
552
|
async fn() {
|
|
@@ -605,11 +557,11 @@ describe( 'workflow()', () => {
|
|
|
605
557
|
|
|
606
558
|
await expect( wf( {} ) ).rejects.toThrow( 'child failed' );
|
|
607
559
|
expect( childError[METADATA_ACCESS_SYMBOL] ).toEqual( {
|
|
608
|
-
|
|
560
|
+
[WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
|
|
609
561
|
trace: { destinations: { local: '/tmp/trace' } },
|
|
610
562
|
aggregations: {
|
|
611
|
-
cost: { total:
|
|
612
|
-
tokens: { total:
|
|
563
|
+
cost: { total: 3 },
|
|
564
|
+
tokens: { total: 8, output: 8 },
|
|
613
565
|
httpRequests: { total: 0 }
|
|
614
566
|
}
|
|
615
567
|
} );
|
|
@@ -617,7 +569,7 @@ describe( 'workflow()', () => {
|
|
|
617
569
|
} );
|
|
618
570
|
|
|
619
571
|
describe( 'error handling (root workflow)', () => {
|
|
620
|
-
it( 'rethrows error from fn with trace
|
|
572
|
+
it( 'rethrows error from fn with trace and aggregation metadata', async () => {
|
|
621
573
|
const { workflow } = await import( './workflow.js' );
|
|
622
574
|
const { METADATA_ACCESS_SYMBOL } = await import( '#consts' );
|
|
623
575
|
const error = new Error( 'workflow failed' );
|
|
@@ -634,9 +586,9 @@ describe( 'workflow()', () => {
|
|
|
634
586
|
|
|
635
587
|
await expect( wf( {} ) ).rejects.toThrow( 'workflow failed' );
|
|
636
588
|
expect( error[METADATA_ACCESS_SYMBOL] ).toEqual( {
|
|
589
|
+
[WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
|
|
637
590
|
trace: { destinations: { local: '/tmp/trace' } },
|
|
638
|
-
|
|
639
|
-
aggregations: emptyAggregations
|
|
591
|
+
aggregations: null
|
|
640
592
|
} );
|
|
641
593
|
} );
|
|
642
594
|
} );
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { deepMergeWithResolver } from '#utils';
|
|
2
|
+
import { Attribute } from '#trace_attribute';
|
|
3
|
+
import Decimal from 'decimal.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} Aggregation
|
|
7
|
+
*
|
|
8
|
+
* @property {object} cost
|
|
9
|
+
* @property {object} cost.total - Total cost
|
|
10
|
+
* @property {object} tokens
|
|
11
|
+
* @property {object} tokens.total - Total tokens used
|
|
12
|
+
* @property {object} tokens.input - Total input tokens used
|
|
13
|
+
* @property {object} tokens.input_cached - Total cached input tokens used
|
|
14
|
+
* @property {object} tokens.output - Total output tokens used
|
|
15
|
+
* @property {object} tokens.reasoning - Total reasoning tokens used
|
|
16
|
+
* @property {object} httpRequests
|
|
17
|
+
* @property {object} httpRequests.total - Total number of http requests made
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Aggregates a collection of Attributes into a object with totals
|
|
21
|
+
*
|
|
22
|
+
* @param {Attribute} attributes
|
|
23
|
+
* @returns {Aggregation} aggregation object
|
|
24
|
+
*/
|
|
25
|
+
export const aggregateAttributes = attributes => ( {
|
|
26
|
+
cost: {
|
|
27
|
+
total: attributes
|
|
28
|
+
.filter( a => [ Attribute.HTTPRequestCost.TYPE, Attribute.LLMUsage.TYPE ].includes( a.type ) )
|
|
29
|
+
.reduce( ( sum, a ) => sum.add( a.total ), Decimal( 0 ) ).toNumber()
|
|
30
|
+
},
|
|
31
|
+
tokens: {
|
|
32
|
+
total: attributes
|
|
33
|
+
.filter( a => Attribute.LLMUsage.TYPE === a.type )
|
|
34
|
+
.reduce( ( sum, a ) => sum.add( a.tokensUsed ), Decimal( 0 ) ).toNumber(),
|
|
35
|
+
...Object.entries( attributes
|
|
36
|
+
.filter( a => Attribute.LLMUsage.TYPE === a.type )
|
|
37
|
+
.flatMap( a => a.usage )
|
|
38
|
+
.reduce( ( obj, a ) => Object.assign( obj, { [a.type]: ( obj[a.type] ?? Decimal( 0 ) ).add( a.amount ) } ), {} ) )
|
|
39
|
+
.reduce( ( obj, [ k, v ] ) => Object.assign( obj, { [k]: v.toNumber() } ), {} ) // convert all values to number
|
|
40
|
+
|
|
41
|
+
},
|
|
42
|
+
httpRequests: {
|
|
43
|
+
total: attributes.filter( a => Attribute.HTTPRequestCount.TYPE === a.type ).length
|
|
44
|
+
}
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Combine two or more aggregation objects into a single Aggregation, adding up the totals and merging keys.
|
|
49
|
+
*
|
|
50
|
+
* @param {Aggregation} aggregations
|
|
51
|
+
* @returns {Aggregation}
|
|
52
|
+
*/
|
|
53
|
+
export const mergeAggregations = ( ...aggregations ) =>
|
|
54
|
+
aggregations.reduce( ( final, item ) => deepMergeWithResolver( final, item, ( a, b ) => Decimal( a ?? 0 ).add( b ?? 0 ).toNumber() ), {} );
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { Attribute } from '#trace_attribute';
|
|
3
|
-
import { aggregateAttributes } from './aggregations.js';
|
|
3
|
+
import { aggregateAttributes, mergeAggregations } from './aggregations.js';
|
|
4
4
|
|
|
5
5
|
describe( 'aggregateAttributes', () => {
|
|
6
6
|
it( 'returns zeroed aggregations when there are no attributes', () => {
|
|
@@ -89,3 +89,51 @@ describe( 'aggregateAttributes', () => {
|
|
|
89
89
|
} );
|
|
90
90
|
} );
|
|
91
91
|
} );
|
|
92
|
+
|
|
93
|
+
describe( 'mergeAggregations', () => {
|
|
94
|
+
it( 'returns an empty object when no aggregations are provided', () => {
|
|
95
|
+
expect( mergeAggregations() ).toEqual( {} );
|
|
96
|
+
} );
|
|
97
|
+
|
|
98
|
+
it( 'sums nested aggregation values', () => {
|
|
99
|
+
expect( mergeAggregations(
|
|
100
|
+
{
|
|
101
|
+
cost: { total: 1.2 },
|
|
102
|
+
tokens: { total: 10, input: 6 },
|
|
103
|
+
httpRequests: { total: 2 }
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
cost: { total: 0.3 },
|
|
107
|
+
tokens: { total: 5, input: 2, output: 3 },
|
|
108
|
+
httpRequests: { total: 1 }
|
|
109
|
+
}
|
|
110
|
+
) ).toEqual( {
|
|
111
|
+
cost: { total: 1.5 },
|
|
112
|
+
tokens: { total: 15, input: 8, output: 3 },
|
|
113
|
+
httpRequests: { total: 3 }
|
|
114
|
+
} );
|
|
115
|
+
} );
|
|
116
|
+
|
|
117
|
+
it( 'handles undefined or partial aggregation objects', () => {
|
|
118
|
+
expect( mergeAggregations(
|
|
119
|
+
undefined,
|
|
120
|
+
{ cost: { total: 2 } },
|
|
121
|
+
{ tokens: { total: 4, reasoning: 1 } },
|
|
122
|
+
{ httpRequests: { total: 3 } }
|
|
123
|
+
) ).toEqual( {
|
|
124
|
+
cost: { total: 2 },
|
|
125
|
+
tokens: { total: 4, reasoning: 1 },
|
|
126
|
+
httpRequests: { total: 3 }
|
|
127
|
+
} );
|
|
128
|
+
} );
|
|
129
|
+
|
|
130
|
+
it( 'does not mutate source aggregation objects', () => {
|
|
131
|
+
const first = { cost: { total: 1 }, tokens: { total: 2 }, httpRequests: { total: 3 } };
|
|
132
|
+
const second = { cost: { total: 4 }, tokens: { total: 5 }, httpRequests: { total: 6 } };
|
|
133
|
+
|
|
134
|
+
mergeAggregations( first, second );
|
|
135
|
+
|
|
136
|
+
expect( first ).toEqual( { cost: { total: 1 }, tokens: { total: 2 }, httpRequests: { total: 3 } } );
|
|
137
|
+
expect( second ).toEqual( { cost: { total: 4 }, tokens: { total: 5 }, httpRequests: { total: 6 } } );
|
|
138
|
+
} );
|
|
139
|
+
} );
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract a property from the error .details.
|
|
3
|
+
* If error does not have details, navigate up the .cause chain.
|
|
4
|
+
*
|
|
5
|
+
* @param {Error} e
|
|
6
|
+
* @param {string} key
|
|
7
|
+
* @returns {any} The value of the property
|
|
8
|
+
*/
|
|
9
|
+
export const extractErrorDetail = ( e, key ) =>
|
|
10
|
+
e ? ( e.details?.find?.( d => d[key] )?.[key] ?? extractErrorDetail( e.cause, key ) ) : null;
|
|
@@ -4,19 +4,12 @@ import Decimal from 'decimal.js';
|
|
|
4
4
|
* All attributes inherit from this
|
|
5
5
|
*/
|
|
6
6
|
export class BaseAttribute {
|
|
7
|
-
activityId;
|
|
8
|
-
activityName;
|
|
9
7
|
date = Date.now();
|
|
10
8
|
type;
|
|
11
9
|
|
|
12
10
|
constructor( type ) {
|
|
13
11
|
this.type = type;
|
|
14
12
|
}
|
|
15
|
-
|
|
16
|
-
setActivity( id, name ) {
|
|
17
|
-
this.activityId = id;
|
|
18
|
-
this.activityName = name;
|
|
19
|
-
}
|
|
20
13
|
}
|
|
21
14
|
|
|
22
15
|
class HTTPRequestCount extends BaseAttribute {
|
|
@@ -93,13 +93,13 @@ export const addEventAction = ( action, { kind, name, id, parentId, details, exe
|
|
|
93
93
|
export function addEventActionWithContext( action, options ) {
|
|
94
94
|
const storeContent = Storage.load();
|
|
95
95
|
if ( storeContent ) { // If there is no storageContext this was not called from a Temporal environment
|
|
96
|
-
const { parentId, executionContext,
|
|
96
|
+
const { parentId, executionContext, addAttribute } = storeContent;
|
|
97
97
|
if ( action === EventAction.ADD_ATTR ) {
|
|
98
98
|
const attribute = options.details;
|
|
99
99
|
if ( !( attribute instanceof BaseAttribute ) ) {
|
|
100
100
|
throw new Error( `Event ${EventAction.ADD_ATTR} argument is not a BaseAttribute instance` );
|
|
101
101
|
} else {
|
|
102
|
-
|
|
102
|
+
addAttribute( options.details );
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
addEventAction( action, { ...options, parentId, executionContext } );
|
|
@@ -123,14 +123,14 @@ describe( 'tracing/trace_engine', () => {
|
|
|
123
123
|
expect( payload.entry.action ).toBe( 'tick' );
|
|
124
124
|
} );
|
|
125
125
|
|
|
126
|
-
it( 'addEventActionWithContext()
|
|
126
|
+
it( 'addEventActionWithContext() records ADD_ATTR attributes through storage context', async () => {
|
|
127
127
|
process.env.OUTPUT_TRACE_LOCAL_ON = 'true';
|
|
128
|
-
const
|
|
128
|
+
const addAttributeMock = vi.fn();
|
|
129
129
|
const executionContext = { runId: 'r1', disableTrace: false };
|
|
130
130
|
storageLoadMock.mockReturnValue( {
|
|
131
131
|
parentId: 'ctx-p',
|
|
132
132
|
executionContext,
|
|
133
|
-
|
|
133
|
+
addAttribute: addAttributeMock
|
|
134
134
|
} );
|
|
135
135
|
const { init, addEventActionWithContext } = await loadTraceEngine();
|
|
136
136
|
const { EventAction } = await import( './trace_consts.js' );
|
|
@@ -140,8 +140,8 @@ describe( 'tracing/trace_engine', () => {
|
|
|
140
140
|
const attribute = new Attribute.HTTPRequestCount( 'https://example.test', 'req-1' );
|
|
141
141
|
addEventActionWithContext( EventAction.ADD_ATTR, { kind: 'http', name: 'request', id: 'req-1', details: attribute } );
|
|
142
142
|
|
|
143
|
-
expect(
|
|
144
|
-
expect(
|
|
143
|
+
expect( addAttributeMock ).toHaveBeenCalledTimes( 1 );
|
|
144
|
+
expect( addAttributeMock ).toHaveBeenCalledWith( attribute );
|
|
145
145
|
expect( localExecMock ).toHaveBeenCalledTimes( 1 );
|
|
146
146
|
expect( localExecMock.mock.calls[0][0] ).toEqual( {
|
|
147
147
|
executionContext,
|
|
@@ -159,11 +159,11 @@ describe( 'tracing/trace_engine', () => {
|
|
|
159
159
|
|
|
160
160
|
it( 'addEventActionWithContext() throws on invalid ADD_ATTR signal payloads', async () => {
|
|
161
161
|
process.env.OUTPUT_TRACE_LOCAL_ON = 'true';
|
|
162
|
-
const
|
|
162
|
+
const addAttributeMock = vi.fn();
|
|
163
163
|
storageLoadMock.mockReturnValue( {
|
|
164
164
|
parentId: 'ctx-p',
|
|
165
165
|
executionContext: { runId: 'r1', disableTrace: false },
|
|
166
|
-
|
|
166
|
+
addAttribute: addAttributeMock
|
|
167
167
|
} );
|
|
168
168
|
const { init, addEventActionWithContext } = await loadTraceEngine();
|
|
169
169
|
const { EventAction } = await import( './trace_consts.js' );
|
|
@@ -175,7 +175,7 @@ describe( 'tracing/trace_engine', () => {
|
|
|
175
175
|
{ kind: 'http', name: 'request', id: 'req-1', details: invalidAttribute }
|
|
176
176
|
) ).toThrow( /not a BaseAttribute instance/ );
|
|
177
177
|
|
|
178
|
-
expect(
|
|
178
|
+
expect( addAttributeMock ).not.toHaveBeenCalled();
|
|
179
179
|
expect( localExecMock ).not.toHaveBeenCalled();
|
|
180
180
|
} );
|
|
181
181
|
|
package/src/utils/index.d.ts
CHANGED
|
@@ -122,7 +122,21 @@ export function shuffleArray( arr: unknown[] ): unknown[];
|
|
|
122
122
|
* @throws {Error} If either `a` or `b` is not a plain object.
|
|
123
123
|
* @returns A new merged object.
|
|
124
124
|
*/
|
|
125
|
-
export function deepMerge( a: object, b: object ): object;
|
|
125
|
+
export function deepMerge( a: object, b: object | null | undefined ): object;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Creates a new object by merging object `b` onto object `a`, biased toward `b`:
|
|
129
|
+
* - Fields in `b` that don't exist in `a` are created.
|
|
130
|
+
* - Fields in `a` that don't exist in `b` are left unchanged.
|
|
131
|
+
* - Fields in `a` and `b` are passed as arguments to the resolve function (a,b) and its return assigns the new value.
|
|
132
|
+
*
|
|
133
|
+
* @param a - The base object.
|
|
134
|
+
* @param b - The overriding object.
|
|
135
|
+
* @param resolver - The resolver function.
|
|
136
|
+
* @throws {Error} If either `a` or `b` is not a plain object.
|
|
137
|
+
* @returns A new merged object.
|
|
138
|
+
*/
|
|
139
|
+
export function deepMergeWithResolver( a: object, b: object | null | undefined, resolver: function ): object;
|
|
126
140
|
|
|
127
141
|
/**
|
|
128
142
|
* Shortens a UUID to a url-safe base64-like string (custom 64-char alphabet).
|
package/src/utils/utils.js
CHANGED
|
@@ -5,7 +5,13 @@ import { METADATA_ACCESS_SYMBOL } from '#consts';
|
|
|
5
5
|
* @param {object} v
|
|
6
6
|
* @returns {object}
|
|
7
7
|
*/
|
|
8
|
-
export const clone = v =>
|
|
8
|
+
export const clone = v => {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse( JSON.stringify( v ) );
|
|
11
|
+
} catch {
|
|
12
|
+
return v;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
17
|
* Detect a JS plain object.
|
|
@@ -170,18 +176,19 @@ export const shuffleArray = arr => arr
|
|
|
170
176
|
.map( ( { v } ) => v );
|
|
171
177
|
|
|
172
178
|
/**
|
|
173
|
-
* Creates a new object merging object "b" onto object "a"
|
|
174
|
-
* - Object "b"
|
|
175
|
-
* - Object "b" fields that don't exist on object "a" will be
|
|
176
|
-
* - Object "a" fields that don't exist on object "b" will
|
|
179
|
+
* Creates a new object merging object "b" onto object "a", using a resolver function to define the value to keep.
|
|
180
|
+
* - Object "b" fields that also exists on "a" will have their value defined by the "resolver" function
|
|
181
|
+
* - Object "b" fields that don't exist on object "a" will be added;
|
|
182
|
+
* - Object "a" fields that don't exist on object "b" will be preserved;
|
|
177
183
|
*
|
|
178
184
|
* If "b" isn't an object, a new object equal to "a" is returned
|
|
179
185
|
*
|
|
180
186
|
* @param {object} a - The base object
|
|
181
187
|
* @param {object} b - The target object
|
|
188
|
+
* @param {function} resolver - A function that return the value to be kept. First argument is value a, second is value b
|
|
182
189
|
* @returns {object} A new object
|
|
183
190
|
*/
|
|
184
|
-
export const
|
|
191
|
+
export const deepMergeWithResolver = ( a, b, resolver ) => {
|
|
185
192
|
if ( !isPlainObject( a ) ) {
|
|
186
193
|
throw new Error( 'Parameter "a" is not an object.' );
|
|
187
194
|
}
|
|
@@ -189,10 +196,34 @@ export const deepMerge = ( a, b ) => {
|
|
|
189
196
|
return clone( a );
|
|
190
197
|
}
|
|
191
198
|
return Object.entries( b ).reduce( ( obj, [ k, v ] ) =>
|
|
192
|
-
Object.assign( obj, {
|
|
199
|
+
Object.assign( obj, {
|
|
200
|
+
[k]: ( () => {
|
|
201
|
+
if ( isPlainObject( v ) && isPlainObject( a[k] ) ) {
|
|
202
|
+
return deepMergeWithResolver( a[k], v, resolver );
|
|
203
|
+
}
|
|
204
|
+
if ( Object.hasOwn( a, k ) ) {
|
|
205
|
+
return resolver( a[k], v );
|
|
206
|
+
}
|
|
207
|
+
return v;
|
|
208
|
+
} )()
|
|
209
|
+
} )
|
|
193
210
|
, clone( a ) );
|
|
194
211
|
};
|
|
195
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Creates a new object merging object "b" onto object "a" biased to "b":
|
|
215
|
+
* - Object "b" will overwrite fields on object "a";
|
|
216
|
+
* - Object "b" fields that don't exist on object "a" will be added;
|
|
217
|
+
* - Object "a" fields that don't exist on object "b" will be preserved;
|
|
218
|
+
*
|
|
219
|
+
* If "b" isn't an object, a new object equal to "a" is returned
|
|
220
|
+
*
|
|
221
|
+
* @param {object} a - The base object
|
|
222
|
+
* @param {object} b - The target object
|
|
223
|
+
* @returns {object} A new object
|
|
224
|
+
*/
|
|
225
|
+
export const deepMerge = ( a, b ) => deepMergeWithResolver( a, b, ( _, b ) => b );
|
|
226
|
+
|
|
196
227
|
/**
|
|
197
228
|
* Shortens a UUID by re-encoding it to base62.
|
|
198
229
|
*
|