@outputai/core 0.4.1-dev.7b85c96.0 → 0.4.1-dev.ae4bd16.0

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