@outputai/core 0.4.1-dev.56c13a8.0 → 0.4.1-dev.6555a2c.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.
Files changed (37) hide show
  1. package/package.json +5 -4
  2. package/src/activity_integration/events.d.ts +6 -1
  3. package/src/activity_integration/events.js +2 -2
  4. package/src/activity_integration/events.spec.js +87 -0
  5. package/src/activity_integration/tracing.d.ts +5 -11
  6. package/src/activity_integration/tracing.js +4 -10
  7. package/src/consts.js +4 -0
  8. package/src/hooks/index.d.ts +40 -3
  9. package/src/hooks/index.js +6 -6
  10. package/src/interface/aggregations.js +24 -0
  11. package/src/interface/aggregations.spec.js +91 -0
  12. package/src/interface/workflow.d.ts +12 -1
  13. package/src/interface/workflow.js +44 -20
  14. package/src/interface/workflow.spec.js +183 -7
  15. package/src/interface/workflow_context.js +4 -2
  16. package/src/tracing/processors/local/index.js +10 -4
  17. package/src/tracing/processors/local/index.spec.js +52 -21
  18. package/src/tracing/processors/s3/index.js +3 -3
  19. package/src/tracing/processors/s3/index.spec.js +26 -1
  20. package/src/tracing/processors/s3/s3_client.js +11 -3
  21. package/src/tracing/processors/s3/s3_client.spec.js +27 -15
  22. package/src/tracing/tools/build_trace_tree.js +1 -1
  23. package/src/tracing/tools/build_trace_tree.spec.js +49 -11
  24. package/src/tracing/tools/utils.js +0 -28
  25. package/src/tracing/tools/utils.spec.js +2 -134
  26. package/src/tracing/trace_attribute.d.ts +38 -0
  27. package/src/tracing/trace_attribute.js +80 -0
  28. package/src/tracing/trace_engine.js +12 -2
  29. package/src/worker/index.js +1 -1
  30. package/src/worker/index.spec.js +1 -1
  31. package/src/worker/interceptors/activity.js +9 -2
  32. package/src/worker/interceptors/activity.spec.js +16 -3
  33. package/src/worker/interceptors.js +2 -2
  34. package/src/worker/sinks.js +6 -6
  35. package/src/tracing/tools/aggregate_trace_attributes.js +0 -118
  36. package/src/tracing/tools/aggregate_trace_attributes.spec.js +0 -231
  37. package/src/tracing/tools/index.js +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/core",
3
- "version": "0.4.1-dev.56c13a8.0",
3
+ "version": "0.4.1-dev.6555a2c.0",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,9 +19,6 @@
19
19
  "./sdk_utils": {
20
20
  "types": "./src/utils/index.d.ts",
21
21
  "import": "./src/utils/index.js"
22
- },
23
- "./sdk_tracing_tools": {
24
- "import": "./src/tracing/tools/index.js"
25
22
  }
26
23
  },
27
24
  "files": [
@@ -36,6 +33,7 @@
36
33
  },
37
34
  "dependencies": {
38
35
  "@aws-sdk/client-s3": "3.1038.0",
36
+ "@aws-sdk/lib-storage": "3.1038.0",
39
37
  "@babel/generator": "7.29.1",
40
38
  "@babel/parser": "7.29.2",
41
39
  "@babel/traverse": "7.29.0",
@@ -45,6 +43,8 @@
45
43
  "@temporalio/common": "1.17.0",
46
44
  "@temporalio/worker": "1.17.0",
47
45
  "@temporalio/workflow": "1.17.0",
46
+ "decimal.js": "10.6.0",
47
+ "json-stream-stringify": "3.1.6",
48
48
  "redis": "5.12.1",
49
49
  "stacktrace-parser": "0.1.11",
50
50
  "undici": "8.1.0",
@@ -67,6 +67,7 @@
67
67
  "#logger": "./src/logger.js",
68
68
  "#utils": "./src/utils/index.js",
69
69
  "#tracing": "./src/tracing/internal_interface.js",
70
+ "#trace_attribute": "./src/tracing/trace_attribute.js",
70
71
  "#async_storage": "./src/async_storage.js",
71
72
  "#internal_activities": "./src/internal_activities/index.js"
72
73
  },
@@ -1,5 +1,10 @@
1
1
  /**
2
- * Emits a custom event
2
+ * Emits a custom event on the in-process message bus.
3
+ *
4
+ * The framework automatically attaches `workflowId`, `runId`, and `activityId`
5
+ * (pulled from `executionContext`) onto every emitted payload, so consumer
6
+ * subscribers registered via `on(...)` always receive those identifiers
7
+ * alongside whatever custom fields the emitter supplies.
3
8
  *
4
9
  * @param eventName - The name of the event to emit
5
10
  * @param payload - An optional payload to send to the event
@@ -5,6 +5,6 @@ export const emitEvent = ( eventName, payload ) => {
5
5
  const ctx = Storage.load();
6
6
 
7
7
  const { executionContext, parentId: activityId } = ctx ?? {};
8
- const { workflowId } = executionContext ?? {};
9
- messageBus.emit( `external:${eventName}`, { workflowId, activityId, ...payload ?? {} } );
8
+ const { workflowId, runId } = executionContext ?? {};
9
+ messageBus.emit( `external:${eventName}`, { ...payload ?? {}, workflowId, runId, activityId } );
10
10
  };
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ const loadMock = vi.hoisted( () => vi.fn() );
4
+ const emitMock = vi.hoisted( () => vi.fn() );
5
+
6
+ vi.mock( '#async_storage', () => ( {
7
+ Storage: { load: loadMock }
8
+ } ) );
9
+
10
+ vi.mock( '#bus', () => ( {
11
+ messageBus: { emit: emitMock }
12
+ } ) );
13
+
14
+ import { emitEvent } from './events.js';
15
+
16
+ describe( 'emitEvent', () => {
17
+ beforeEach( () => {
18
+ vi.clearAllMocks();
19
+ } );
20
+
21
+ it( 'forwards workflowId, runId, and activityId from executionContext', () => {
22
+ loadMock.mockReturnValue( {
23
+ executionContext: { workflowId: 'wf-1', runId: 'run-1' },
24
+ parentId: 'act-1'
25
+ } );
26
+
27
+ emitEvent( 'cost:llm:request', { modelId: 'gpt-4o' } );
28
+
29
+ expect( emitMock ).toHaveBeenCalledWith( 'external:cost:llm:request', {
30
+ workflowId: 'wf-1',
31
+ runId: 'run-1',
32
+ activityId: 'act-1',
33
+ modelId: 'gpt-4o'
34
+ } );
35
+ } );
36
+
37
+ it( 'handles missing executionContext gracefully', () => {
38
+ loadMock.mockReturnValue( undefined );
39
+
40
+ emitEvent( 'foo:bar', { x: 1 } );
41
+
42
+ expect( emitMock ).toHaveBeenCalledWith( 'external:foo:bar', {
43
+ workflowId: undefined,
44
+ runId: undefined,
45
+ activityId: undefined,
46
+ x: 1
47
+ } );
48
+ } );
49
+
50
+ it( 'handles missing payload', () => {
51
+ loadMock.mockReturnValue( {
52
+ executionContext: { workflowId: 'wf-2', runId: 'run-2' },
53
+ parentId: 'act-2'
54
+ } );
55
+
56
+ emitEvent( 'lifecycle:start' );
57
+
58
+ expect( emitMock ).toHaveBeenCalledWith( 'external:lifecycle:start', {
59
+ workflowId: 'wf-2',
60
+ runId: 'run-2',
61
+ activityId: 'act-2'
62
+ } );
63
+ } );
64
+
65
+ it( 'does not let payload override workflowId / runId / activityId', () => {
66
+ loadMock.mockReturnValue( {
67
+ executionContext: { workflowId: 'wf-3', runId: 'run-3' },
68
+ parentId: 'act-3'
69
+ } );
70
+
71
+ emitEvent( 'cost:http:request', {
72
+ workflowId: 'should-be-overridden',
73
+ runId: 'should-be-overridden',
74
+ activityId: 'should-be-overridden',
75
+ url: 'https://example.com'
76
+ } );
77
+
78
+ // Context fields are spread after the payload, so caller-supplied
79
+ // workflowId / runId / activityId cannot escape the executionContext.
80
+ expect( emitMock ).toHaveBeenCalledWith( 'external:cost:http:request', {
81
+ workflowId: 'wf-3',
82
+ runId: 'run-3',
83
+ activityId: 'act-3',
84
+ url: 'https://example.com'
85
+ } );
86
+ } );
87
+ } );
@@ -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,15 +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
- TOKEN_USAGE: 'token_usage';
46
- };
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,13 +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
- TOKEN_USAGE: 'token_usage'
54
- };
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
  };
@@ -16,8 +16,10 @@ export interface ErrorHookPayload {
16
16
  * Payload passed to the onWorkflowStart handler when a workflow run begins.
17
17
  */
18
18
  export interface WorkflowStartHookPayload {
19
- /** Identifier of the workflow run. */
19
+ /** Workflow id (stable across retries / continue-as-new). */
20
20
  id: string;
21
+ /** Temporal run id for the current execution attempt. */
22
+ runId: string;
21
23
  /** Name of the workflow. */
22
24
  name: string;
23
25
  }
@@ -26,8 +28,10 @@ export interface WorkflowStartHookPayload {
26
28
  * Payload passed to the onWorkflowEnd handler when a workflow run completes successfully.
27
29
  */
28
30
  export interface WorkflowEndHookPayload {
29
- /** Identifier of the workflow run. */
31
+ /** Workflow id (stable across retries / continue-as-new). */
30
32
  id: string;
33
+ /** Temporal run id for the current execution attempt. */
34
+ runId: string;
31
35
  /** Name of the workflow. */
32
36
  name: string;
33
37
  /** Duration of the workflow run in milliseconds. */
@@ -38,8 +42,10 @@ export interface WorkflowEndHookPayload {
38
42
  * Payload passed to the onWorkflowError handler when a workflow run fails.
39
43
  */
40
44
  export interface WorkflowErrorHookPayload {
41
- /** Identifier of the workflow run. */
45
+ /** Workflow id (stable across retries / continue-as-new). */
42
46
  id: string;
47
+ /** Temporal run id for the current execution attempt. */
48
+ runId: string;
43
49
  /** Name of the workflow. */
44
50
  name: string;
45
51
  /** Elapsed time before failure in milliseconds. */
@@ -90,6 +96,37 @@ export declare function onWorkflowEnd( handler: ( payload: WorkflowEndHookPayloa
90
96
  */
91
97
  export declare function onWorkflowError( handler: ( payload: WorkflowErrorHookPayload ) => void ): void;
92
98
 
99
+ /**
100
+ * Payload broadcast on the `http:request` event for every HTTP call issued
101
+ * through `@outputai/http`'s `fetch`. Fires for success, non-2xx, and network
102
+ * failure paths — `cost:http:request` continues to fire only when the consumer
103
+ * has attached a cost via `addRequestCost`.
104
+ *
105
+ * The framework auto-attaches `workflowId`, `runId`, and `activityId` onto the
106
+ * payload before broadcast, so consumers receive those identifiers in addition
107
+ * to the fields listed here.
108
+ */
109
+ export interface HttpRequestHookPayload {
110
+ /** Workflow id (stable across retries / continue-as-new). */
111
+ workflowId: string;
112
+ /** Temporal run id for the current execution attempt. */
113
+ runId: string;
114
+ /** Activity / step id, when emitted from inside a step. */
115
+ activityId?: string;
116
+ /** UUID generated per request inside `@outputai/http`. */
117
+ requestId: string;
118
+ /** HTTP method (uppercase). */
119
+ method: string;
120
+ /** Absolute request URL. */
121
+ url: string;
122
+ /** HTTP status code; undefined on network failure. */
123
+ status?: number;
124
+ /** Elapsed time from request issuance to response (or failure), in milliseconds. */
125
+ durationMs: number;
126
+ /** Outcome bucket: `success` (2xx-3xx), `http_error` (>=400), `network_error` (DNS / timeout / abort). */
127
+ outcome: 'success' | 'http_error' | 'network_error';
128
+ }
129
+
93
130
  /**
94
131
  * Register a handler to be invoked when a given event happens
95
132
  *
@@ -34,16 +34,16 @@ export const onBeforeWorkerStart = handler => messageBus.on( BusEventType.WORKER
34
34
  safeInvoke( handler, undefined, 'onBeforeWorkerStart' ) );
35
35
 
36
36
  /** Listen to workflow start events, excludes catalog workflow */
37
- export const onWorkflowStart = handler => messageBus.on( BusEventType.WORKFLOW_START, ( { id, name } ) =>
38
- WORKFLOW_CATALOG !== name ? safeInvoke( handler, { id, name }, 'onWorkflowStart' ) : null );
37
+ export const onWorkflowStart = handler => messageBus.on( BusEventType.WORKFLOW_START, ( { id, runId, name } ) =>
38
+ WORKFLOW_CATALOG !== name ? safeInvoke( handler, { id, runId, name }, 'onWorkflowStart' ) : null );
39
39
 
40
40
  /** Listen to workflow end events, excludes catalog workflow */
41
- export const onWorkflowEnd = handler => messageBus.on( BusEventType.WORKFLOW_END, ( { id, name, duration } ) =>
42
- WORKFLOW_CATALOG !== name ? safeInvoke( handler, { id, name, duration }, 'onWorkflowEnd' ) : null );
41
+ export const onWorkflowEnd = handler => messageBus.on( BusEventType.WORKFLOW_END, ( { id, runId, name, duration } ) =>
42
+ WORKFLOW_CATALOG !== name ? safeInvoke( handler, { id, runId, name, duration }, 'onWorkflowEnd' ) : null );
43
43
 
44
44
  /** Listen to workflow error events, excludes catalog workflow */
45
- export const onWorkflowError = handler => messageBus.on( BusEventType.WORKFLOW_ERROR, ( { id, name, duration, error } ) =>
46
- WORKFLOW_CATALOG !== name ? safeInvoke( handler, { id, name, duration, error }, 'onWorkflowError' ) : null );
45
+ export const onWorkflowError = handler => messageBus.on( BusEventType.WORKFLOW_ERROR, ( { id, runId, name, duration, error } ) =>
46
+ WORKFLOW_CATALOG !== name ? safeInvoke( handler, { id, runId, name, duration, error }, 'onWorkflowError' ) : null );
47
47
 
48
48
  /** Generic listener for events emitted elsewhere (outside core) */
49
49
  export const on = ( eventName, handler ) => messageBus.on( `external:${eventName}`, payload =>
@@ -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
+ } );
@@ -63,7 +63,18 @@ export type WorkflowContext<
63
63
  *
64
64
  * @see {@link https://docs.temporal.io/workflow-execution/workflowid-runid#workflow-id}
65
65
  */
66
- workflowId: string
66
+ workflowId: string,
67
+
68
+ /**
69
+ * Internal Temporal run id for the current execution attempt.
70
+ *
71
+ * A single `workflowId` can map to multiple `runId`s when a workflow is
72
+ * retried, reset, or continued-as-new. The current run can be pinned in
73
+ * downstream `/workflow/{id}/runs/{rid}/...` API calls.
74
+ *
75
+ * @see {@link https://docs.temporal.io/workflow-execution/workflowid-runid#run-id}
76
+ */
77
+ runId: string
67
78
  }
68
79
  };
69
80
 
@@ -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
 
@@ -39,15 +44,20 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
39
44
  // this returns a plain function, for example, in unit tests
40
45
  if ( !inWorkflowContext() ) {
41
46
  validateWithSchema( inputSchema, input, `Workflow ${name} input` );
42
- const context = Context.build( { workflowId: 'test-workflow', continueAsNew: async () => {}, isContinueAsNewSuggested: () => false } );
47
+ const context = Context.build( {
48
+ workflowId: 'test-workflow',
49
+ runId: 'test-run',
50
+ continueAsNew: async () => {},
51
+ isContinueAsNewSuggested: () => false
52
+ } );
43
53
  const output = await fn( input, deepMerge( context, extra.context ) );
44
54
  validateWithSchema( outputSchema, output, `Workflow ${name} output` );
45
55
  return output;
46
56
  }
47
57
 
48
- const { workflowId, memo, startTime } = workflowInfo();
58
+ const { workflowId, runId, memo, startTime } = workflowInfo();
49
59
 
50
- const context = Context.build( { workflowId, continueAsNew, isContinueAsNewSuggested: () => workflowInfo().continueAsNewSuggested } );
60
+ const context = Context.build( { workflowId, runId, continueAsNew, isContinueAsNewSuggested: () => workflowInfo().continueAsNewSuggested } );
51
61
 
52
62
  // Root workflows will not have the execution context yet, since it is set here.
53
63
  const isRoot = !memo.executionContext;
@@ -57,6 +67,7 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
57
67
  It will be used to as context for tracing (connecting events) */
58
68
  const executionContext = memo.executionContext ?? {
59
69
  workflowId,
70
+ runId,
60
71
  workflowName: name,
61
72
  disableTrace,
62
73
  startTime: startTime.getTime()
@@ -69,7 +80,9 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
69
80
 
70
81
  // Run the internal activity to retrieve the workflow trace destinations (only for root workflows, not nested)
71
82
  const traceDestinations = isRoot ? ( await steps[ACTIVITY_GET_TRACE_DESTINATIONS]( executionContext ) ) : null;
72
- const traceObject = { trace: { destinations: traceDestinations } };
83
+
84
+ const attributes = [];
85
+ setHandler( defineSignal( Signal.ADD_ATTRIBUTE ), e => attributes.push( e ) );
73
86
 
74
87
  try {
75
88
  // validation comes after setting memo to have that info already set for interceptor even if validations fail
@@ -91,17 +104,25 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
91
104
  * @param {import('@temporalio/workflow').ActivityOptions} extra.options
92
105
  * @returns {Promise<unknown>}
93
106
  */
94
- startWorkflow: async ( childName, input, extra = {} ) =>
95
- executeChild( childName, {
96
- args: input ? [ input ] : [],
97
- workflowId: `${workflowId}-${toUrlSafeBase64( uuid4() )}`,
98
- parentClosePolicy: ParentClosePolicy[extra?.detached ? 'ABANDON' : 'TERMINATE'],
99
- memo: {
100
- executionContext,
101
- parentId: workflowId,
102
- ...( extra?.options?.activityOptions && { activityOptions: deepMerge( activityOptions, extra.options.activityOptions ) } )
103
- }
104
- } )
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
+ }
105
126
  };
106
127
 
107
128
  const output = await fn.call( dispatchers, input, context );
@@ -110,14 +131,17 @@ export function workflow( { name, description, inputSchema, outputSchema, fn, op
110
131
 
111
132
  if ( isRoot ) {
112
133
  // Append the trace info to the result of the workflow
113
- return { output, ...traceObject };
134
+ return { output, trace: { destinations: traceDestinations }, attributes, aggregations: aggregateAttributes( attributes ) };
114
135
  }
115
136
 
116
- return output;
137
+ return { output, attributes };
117
138
  } catch ( e ) {
118
- // 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
119
142
  if ( isRoot ) {
120
- 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 );
121
145
  }
122
146
  throw e;
123
147
  }