@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.
@@ -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( 'calls getTraceDestinations, returns root trace data and assigns executionContext to memo', async () => {
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: 'root_wf',
244
- description: 'Root',
242
+ name: 'wrapped_trace_wf',
243
+ description: 'Wrapped trace',
245
244
  inputSchema: z.object( {} ),
246
- outputSchema: z.object( { v: z.number() } ),
247
- fn: async () => ( { v: 42 } )
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
- output: { v: 42 },
254
- trace: { destinations: { local: '/tmp/trace' } },
255
- attributes: [],
256
- aggregations: emptyAggregations
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: 'root_wf',
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 attribute signals and returns aggregated attributes', async () => {
266
+ it( 'collects batched aggregation signals from failed activities', async () => {
268
267
  const { workflow } = await import( './workflow.js' );
269
- const { Attribute } = await import( '#trace_attribute' );
270
- const handlers = { addAttribute: () => {} };
268
+ const handlers = { sendAggregations: () => {} };
271
269
  setHandlerMock.mockImplementation( ( signalName, handler ) => {
272
- if ( signalName === Signal.ADD_ATTRIBUTE ) {
273
- handlers.addAttribute = handler;
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: 'attr_wf',
301
- description: 'Attributes',
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.addAttribute( httpRequest );
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 ).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
- } );
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( { output: { x: 'child' }, attributes: [] } );
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 calls steps with workflowName#stepName', async () => {
370
- const getCalls = [];
371
- proxyActivitiesMock.mockImplementation( () => new Proxy( {}, {
372
- get: ( _, prop ) => {
373
- if ( prop === '__internal#getTraceDestinations' ) {
374
- return traceDestinationsStepMock;
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: 'invoke_wf',
388
- description: 'Invoke',
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
- await this.invokeStep( 'myStep', { foo: 1 } );
393
- return {};
353
+ return this.invokeStep( 'myStep', { foo: 1 } );
394
354
  }
395
355
  } );
396
356
 
397
- await wf( {} );
398
- expect( getCalls ).toContain( 'invoke_wf#myStep' );
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: {}, attributes: [] } );
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: {}, attributes: [] } );
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: {}, attributes: [] } );
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 attributes into the root result', async () => {
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
- attributes: [ childAttribute ]
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: 'merge_child_wf',
554
- description: 'Merge child attributes',
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: 0.4 },
569
- tokens: {
570
- total: 20,
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 attributes before rethrowing to root metadata', async () => {
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: [ { attributes: [ childAttribute ] } ]
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: 'child_error_wf',
597
- description: 'Child error attributes',
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
- attributes: [ childAttribute ],
560
+ [WORKFLOW_WRAPPER_VERSION_FIELD]: 1,
609
561
  trace: { destinations: { local: '/tmp/trace' } },
610
562
  aggregations: {
611
- cost: { total: 2 },
612
- tokens: { total: 0 },
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 attributes and aggregation metadata', async () => {
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
- attributes: [],
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, sendAttributeSignal } = storeContent;
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
- sendAttributeSignal( options.details );
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() sends ADD_ATTR attributes through storage context', async () => {
126
+ it( 'addEventActionWithContext() records ADD_ATTR attributes through storage context', async () => {
127
127
  process.env.OUTPUT_TRACE_LOCAL_ON = 'true';
128
- const sendAttributeSignalMock = vi.fn();
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
- sendAttributeSignal: sendAttributeSignalMock
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( sendAttributeSignalMock ).toHaveBeenCalledTimes( 1 );
144
- expect( sendAttributeSignalMock ).toHaveBeenCalledWith( attribute );
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 sendAttributeSignalMock = vi.fn();
162
+ const addAttributeMock = vi.fn();
163
163
  storageLoadMock.mockReturnValue( {
164
164
  parentId: 'ctx-p',
165
165
  executionContext: { runId: 'r1', disableTrace: false },
166
- sendAttributeSignal: sendAttributeSignalMock
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( sendAttributeSignalMock ).not.toHaveBeenCalled();
178
+ expect( addAttributeMock ).not.toHaveBeenCalled();
179
179
  expect( localExecMock ).not.toHaveBeenCalled();
180
180
  } );
181
181
 
@@ -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).
@@ -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 => JSON.parse( JSON.stringify( 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" biased to "b":
174
- * - Object "b" will overwrite fields on object "a";
175
- * - Object "b" fields that don't exist on object "a" will be created;
176
- * - Object "a" fields that don't exist on object "b" will not be touched;
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 deepMerge = ( a, b ) => {
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, { [k]: isPlainObject( v ) && isPlainObject( a[k] ) ? deepMerge( a[k], v ) : v } )
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
  *