@output.ai/core 0.5.12 → 0.5.13

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": "@output.ai/core",
3
- "version": "0.5.12",
3
+ "version": "0.5.13",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,6 +23,10 @@
23
23
  "./context": {
24
24
  "types": "./src/context/index.d.ts",
25
25
  "import": "./src/context/index.js"
26
+ },
27
+ "./event_hub": {
28
+ "types": "./src/event_hub/index.d.ts",
29
+ "import": "./src/event_hub/index.js"
26
30
  }
27
31
  },
28
32
  "files": [
package/src/consts.js CHANGED
@@ -1,17 +1,32 @@
1
- export const ACTIVITY_SEND_HTTP_REQUEST = '__internal#sendHttpRequest';
2
1
  export const ACTIVITY_GET_TRACE_DESTINATIONS = '__internal#getTraceDestinations';
2
+ export const ACTIVITY_OPTIONS_FILENAME = '__activity_options.js';
3
+ export const ACTIVITY_SEND_HTTP_REQUEST = '__internal#sendHttpRequest';
3
4
  export const METADATA_ACCESS_SYMBOL = Symbol( '__metadata' );
4
5
  export const SHARED_STEP_PREFIX = '$shared';
5
- export const WORKFLOWS_INDEX_FILENAME = '__workflows_entrypoint.js';
6
- export const ACTIVITY_OPTIONS_FILENAME = '__activity_options.js';
7
6
  export const WORKFLOW_CATALOG = '$catalog';
7
+ export const WORKFLOWS_INDEX_FILENAME = '__workflows_entrypoint.js';
8
+
8
9
  export const ComponentType = {
9
10
  EVALUATOR: 'evaluator',
10
11
  INTERNAL_STEP: 'internal_step',
11
- STEP: 'step'
12
+ STEP: 'step',
13
+ WORKFLOW: 'workflow'
12
14
  };
15
+
13
16
  export const LifecycleEvent = {
14
17
  START: 'start',
15
18
  END: 'end',
16
19
  ERROR: 'error'
17
20
  };
21
+
22
+ export const BusEventType = {
23
+ WORKFLOW_START: 'workflow:start',
24
+ WORKFLOW_END: 'workflow:end',
25
+ WORKFLOW_ERROR: 'workflow:error',
26
+
27
+ ACTIVITY_START: 'activity:start',
28
+ ACTIVITY_END: 'activity:end',
29
+ ACTIVITY_ERROR: 'activity:error',
30
+
31
+ RUNTIME_ERROR: 'runtime_error'
32
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Emits a custom event
3
+ *
4
+ * @param eventName - The name of the event to emit
5
+ * @param payload - An optional payload to send to the event
6
+ */
7
+ export declare function emit( eventName: string, payload?: unknown ): void;
@@ -0,0 +1,6 @@
1
+ import { messageBus } from '#bus';
2
+
3
+ export const emit = ( eventName, payload ) => {
4
+ messageBus.emit( `external:${eventName}`, payload );
5
+ };
6
+
@@ -13,8 +13,16 @@ export interface ErrorHookPayload {
13
13
  }
14
14
 
15
15
  /**
16
- * Register a handler invoked on workflow, activity or runtime errors.
16
+ * Register a handler to be invoked on workflow, activity or runtime errors.
17
17
  *
18
18
  * @param handler - Function called with the error payload.
19
19
  */
20
20
  export declare function onError( handler: ( payload: ErrorHookPayload ) => void ): void;
21
+
22
+ /**
23
+ * Register a handler to be invoked when a given event happens
24
+ *
25
+ * @param eventName - The name of the event to subscribe
26
+ * @param handler - Function called with the event payload
27
+ */
28
+ export declare function on( eventName: string, handler: ( payload: object ) => void ): void;
@@ -1,4 +1,5 @@
1
1
  import { messageBus } from '#bus';
2
+ import { BusEventType } from '#consts';
2
3
  import { createChildLogger } from '#logger';
3
4
 
4
5
  const log = createChildLogger( 'Hooks' );
@@ -12,10 +13,20 @@ export const onError = handler => {
12
13
  }
13
14
  };
14
15
 
15
- messageBus.on( 'activity:error', async ( { workflowName, activityName, error } ) =>
16
- invokeHandler( { source: 'activity', workflowName, activityName, error } ) );
17
- messageBus.on( 'workflow:error', async ( { workflowName, error } ) =>
18
- invokeHandler( { source: 'workflow', workflowName, error } ) );
19
- messageBus.on( 'runtime:error', async ( { error } ) =>
16
+ messageBus.on( BusEventType.ACTIVITY_ERROR, async ( { name, workflowName, error } ) =>
17
+ invokeHandler( { source: 'activity', activityName: name, workflowName, error } ) );
18
+ messageBus.on( BusEventType.WORKFLOW_ERROR, async ( { name, error } ) =>
19
+ invokeHandler( { source: 'workflow', workflowName: name, error } ) );
20
+ messageBus.on( BusEventType.RUNTIME_ERROR, async ( { error } ) =>
20
21
  invokeHandler( { source: 'runtime', error } ) );
21
22
  };
23
+
24
+ export const on = ( eventName, handler ) => {
25
+ messageBus.on( `external:${eventName}`, async payload => {
26
+ try {
27
+ await handler( payload );
28
+ } catch ( error ) {
29
+ log.error( `on(${eventName}) hook error`, { error } );
30
+ }
31
+ } );
32
+ };
@@ -51,11 +51,11 @@ export async function sendPostRequestAndAwaitWebhook( { url, payload = undefined
51
51
  const resumeSignal = defineSignal( 'resume' );
52
52
 
53
53
  const traceId = `${workflowId}-${url}-${uuid4()}`;
54
- sinks.trace.addEventStart( { id: traceId, name: 'resume', kind: 'webhook' } );
54
+ sinks.trace.start( { id: traceId, name: 'resume', kind: 'webhook' } );
55
55
 
56
56
  setHandler( resumeSignal, webhookPayload => {
57
57
  if ( !resumeTrigger.resolved ) {
58
- sinks.trace.addEventEnd( { id: traceId, details: webhookPayload } );
58
+ sinks.trace.end( { id: traceId, details: webhookPayload } );
59
59
  resumeTrigger.resolve( webhookPayload );
60
60
  }
61
61
  } );
@@ -21,7 +21,7 @@ const setHandlerMock = ( signal, fn ) => {
21
21
  };
22
22
 
23
23
  const workflowInfoMock = vi.fn( () => ( { workflowId: 'wf-123' } ) );
24
- const sinks = { trace: { addEventStart: vi.fn(), addEventEnd: vi.fn() } };
24
+ const sinks = { trace: { start: vi.fn(), end: vi.fn() } };
25
25
  const proxySinksMock = vi.fn( async () => sinks );
26
26
 
27
27
  class TestTrigger {
@@ -78,7 +78,7 @@ export const exec = async ( { entry, executionContext } ) => {
78
78
 
79
79
  await addEntry( entry, cacheKey );
80
80
 
81
- const isRootWorkflowEnd = !entry.parentId && entry.phase !== 'start';
81
+ const isRootWorkflowEnd = entry.id === workflowId && entry.phase !== 'start';
82
82
  if ( !isRootWorkflowEnd ) {
83
83
  return;
84
84
  }
@@ -50,36 +50,31 @@ describe( 'tracing/processors/s3', () => {
50
50
  const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
51
51
  const ctx = { executionContext: { workflowId: 'id1', workflowName: 'WF', startTime } };
52
52
 
53
- // multi().exec() just needs to resolve for addEntry calls
54
53
  redisMulti.exec.mockResolvedValue( [] );
55
54
 
56
- // zRange is only called once at the end to get all entries
55
+ const workflowStart = { id: 'id1', name: 'WF', kind: 'workflow', phase: 'start', details: {}, timestamp: startTime };
56
+ const activityStart = { id: 'act-1', name: 'DoSomething', kind: 'step', parentId: 'id1', phase: 'start', details: {}, timestamp: startTime + 1 };
57
+ const workflowEnd = { id: 'id1', phase: 'end', details: { ok: true }, timestamp: startTime + 2 };
57
58
  zRangeMock.mockResolvedValue( [
58
- JSON.stringify( { name: 'A', phase: 'start', timestamp: startTime } ),
59
- JSON.stringify( { name: 'A', phase: 'tick', timestamp: startTime + 1 } ),
60
- JSON.stringify( { name: 'A', phase: 'end', timestamp: startTime + 2 } )
59
+ JSON.stringify( workflowStart ),
60
+ JSON.stringify( activityStart ),
61
+ JSON.stringify( workflowEnd )
61
62
  ] );
62
63
 
63
- await exec( { ...ctx, entry: { name: 'A', phase: 'start', timestamp: startTime, parentId: 'root' } } );
64
- await exec( { ...ctx, entry: { name: 'A', phase: 'tick', timestamp: startTime + 1, parentId: 'root' } } );
65
- // Root end: no parentId and not start — triggers the 10s delay before upload
66
- const endPromise = exec( { ...ctx, entry: { name: 'A', phase: 'end', timestamp: startTime + 2 } } );
64
+ await exec( { ...ctx, entry: workflowStart } );
65
+ await exec( { ...ctx, entry: activityStart } );
66
+ // Root end: id matches workflowId and not start — triggers the 10s delay before upload
67
+ const endPromise = exec( { ...ctx, entry: workflowEnd } );
67
68
  await vi.advanceTimersByTimeAsync( 10_000 );
68
69
  await endPromise;
69
70
 
70
- // Accumulation happened 3 times
71
71
  expect( redisMulti.zAdd ).toHaveBeenCalledTimes( 3 );
72
-
73
- // Tree is only built once at the end (not on every event)
74
72
  expect( buildTraceTreeMock ).toHaveBeenCalledTimes( 1 );
75
73
  expect( zRangeMock ).toHaveBeenCalledTimes( 1 );
76
-
77
- // Only last call triggers upload
78
74
  expect( uploadMock ).toHaveBeenCalledTimes( 1 );
79
75
  const { key, content } = uploadMock.mock.calls[0][0];
80
76
  expect( key ).toMatch( /^WF\/2020\/01\/02\// );
81
77
  expect( JSON.parse( content.trim() ).count ).toBe( 3 );
82
-
83
78
  expect( delMock ).toHaveBeenCalledTimes( 1 );
84
79
  expect( delMock ).toHaveBeenCalledWith( 'traces/WF/id1' );
85
80
  } );
@@ -101,24 +96,52 @@ describe( 'tracing/processors/s3', () => {
101
96
  const ctx = { executionContext: { workflowId: 'id1', workflowName: 'WF', startTime } };
102
97
 
103
98
  redisMulti.exec.mockResolvedValue( [] );
104
- zRangeMock.mockResolvedValue( [ JSON.stringify( { phase: 'start', timestamp: startTime } ) ] );
99
+ const workflowStart = {
100
+ kind: 'workflow', id: 'id1', name: 'WF', parentId: undefined, phase: 'start', details: {}, timestamp: startTime
101
+ };
102
+ zRangeMock.mockResolvedValue( [ JSON.stringify( workflowStart ) ] );
105
103
 
106
- await exec( { ...ctx, entry: { phase: 'start', timestamp: startTime, parentId: 'root' } } );
104
+ await exec( { ...ctx, entry: workflowStart } );
107
105
 
108
106
  expect( redisMulti.expire ).toHaveBeenCalledTimes( 1 );
109
107
  expect( redisMulti.expire ).toHaveBeenCalledWith( 'traces/WF/id1', 3600 );
110
108
  } );
111
109
 
110
+ it( 'exec(): does not treat a non-root end (e.g. step without parentId) as root workflow end — regression for wrong root detection', async () => {
111
+ const { exec } = await import( './index.js' );
112
+ const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
113
+ const ctx = { executionContext: { workflowId: 'id1', workflowName: 'WF', startTime } };
114
+
115
+ redisMulti.exec.mockResolvedValue( [] );
116
+ const workflowStart = { id: 'id1', name: 'WF', kind: 'workflow', phase: 'start', details: {}, timestamp: startTime };
117
+ const stepEndNoParent = { id: 'step-1', phase: 'end', details: { done: true }, timestamp: startTime + 1 };
118
+ zRangeMock.mockResolvedValue( [
119
+ JSON.stringify( workflowStart ),
120
+ JSON.stringify( stepEndNoParent )
121
+ ] );
122
+
123
+ await exec( { ...ctx, entry: workflowStart } );
124
+ await exec( { ...ctx, entry: stepEndNoParent } );
125
+
126
+ expect( redisMulti.zAdd ).toHaveBeenCalledTimes( 2 );
127
+ expect( buildTraceTreeMock ).not.toHaveBeenCalled();
128
+ expect( uploadMock ).not.toHaveBeenCalled();
129
+ expect( delMock ).not.toHaveBeenCalled();
130
+ } );
131
+
112
132
  it( 'exec(): when buildTraceTree returns null (incomplete tree), does not upload or bust cache', async () => {
113
133
  const { exec } = await import( './index.js' );
114
134
  const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
115
135
  const ctx = { executionContext: { workflowId: 'id1', workflowName: 'WF', startTime } };
116
136
 
117
137
  redisMulti.exec.mockResolvedValue( [] );
118
- zRangeMock.mockResolvedValue( [ JSON.stringify( { id: 'wf', phase: 'end', timestamp: startTime } ) ] );
138
+ const workflowEnd = {
139
+ kind: 'workflow', id: 'id1', name: 'WF', parentId: undefined, phase: 'end', details: {}, timestamp: startTime
140
+ };
141
+ zRangeMock.mockResolvedValue( [ JSON.stringify( workflowEnd ) ] );
119
142
  buildTraceTreeMock.mockReturnValueOnce( null );
120
143
 
121
- const endPromise = exec( { ...ctx, entry: { id: 'wf', phase: 'end', timestamp: startTime } } );
144
+ const endPromise = exec( { ...ctx, entry: workflowEnd } );
122
145
  await vi.advanceTimersByTimeAsync( 10_000 );
123
146
  await endPromise;
124
147
 
@@ -29,7 +29,7 @@ this can indicate it timed out or was interrupted.>>' );
29
29
  const entries = [
30
30
  { kind: 'wf', id: 'r', parentId: undefined, phase: 'start', name: 'root', details: {}, timestamp: 100 },
31
31
  { kind: 'step', id: 's', parentId: 'r', phase: 'start', name: 'step', details: {}, timestamp: 200 },
32
- { kind: 'step', id: 's', parentId: 'r', phase: 'error', name: 'step', details: { message: 'failed' }, timestamp: 300 }
32
+ { id: 's', phase: 'error', details: { message: 'failed' }, timestamp: 300 }
33
33
  ];
34
34
  const result = buildTraceTree( entries );
35
35
  expect( result ).not.toBeNull();
@@ -44,7 +44,7 @@ this can indicate it timed out or was interrupted.>>' );
44
44
  { kind: 'workflow', phase: 'start', name: 'wf', id: 'wf', parentId: undefined, details: { a: 1 }, timestamp: 1000 },
45
45
  // evaluator start/stop
46
46
  { kind: 'evaluator', phase: 'start', name: 'eval', id: 'eval', parentId: 'wf', details: { z: 0 }, timestamp: 1500 },
47
- { kind: 'evaluator', phase: 'end', name: 'eval', id: 'eval', parentId: 'wf', details: { z: 1 }, timestamp: 1600 },
47
+ { id: 'eval', phase: 'end', details: { z: 1 }, timestamp: 1600 },
48
48
  // step1 start
49
49
  { kind: 'step', phase: 'start', name: 'step-1', id: 's1', parentId: 'wf', details: { x: 1 }, timestamp: 2000 },
50
50
  // IO under step1
@@ -53,15 +53,15 @@ this can indicate it timed out or was interrupted.>>' );
53
53
  { kind: 'step', phase: 'start', name: 'step-2', id: 's2', parentId: 'wf', details: { x: 2 }, timestamp: 2400 },
54
54
  // IO under step2
55
55
  { kind: 'IO', phase: 'start', name: 'test-2', id: 'io2', parentId: 's2', details: { y: 3 }, timestamp: 2500 },
56
- { kind: 'IO', phase: 'end', name: 'test-2', id: 'io2', parentId: 's2', details: { y: 4 }, timestamp: 2600 },
56
+ { id: 'io2', phase: 'end', details: { y: 4 }, timestamp: 2600 },
57
57
  // IO under step1 ends
58
- { kind: 'IO', phase: 'end', name: 'test-1', id: 'io1', parentId: 's1', details: { y: 5 }, timestamp: 2700 },
58
+ { id: 'io1', phase: 'end', details: { y: 5 }, timestamp: 2700 },
59
59
  // step1 end
60
- { kind: 'step', phase: 'end', name: 'step-1', id: 's1', parentId: 'wf', details: { done: true }, timestamp: 2800 },
60
+ { id: 's1', phase: 'end', details: { done: true }, timestamp: 2800 },
61
61
  // step2 end
62
- { kind: 'step', phase: 'end', name: 'step-2', id: 's2', parentId: 'wf', details: { done: true }, timestamp: 2900 },
62
+ { id: 's2', phase: 'end', details: { done: true }, timestamp: 2900 },
63
63
  // workflow end
64
- { kind: 'workflow', phase: 'end', name: 'wf', id: 'wf', parentId: undefined, details: { ok: true }, timestamp: 3000 }
64
+ { id: 'wf', phase: 'end', details: { ok: true }, timestamp: 3000 }
65
65
  ];
66
66
 
67
67
  const result = buildTraceTree( entries );
@@ -10,6 +10,8 @@ import { createChildLogger } from '#logger';
10
10
  import { registerShutdown } from './shutdown.js';
11
11
  import { startCatalog } from './start_catalog.js';
12
12
  import { messageBus } from '#bus';
13
+ import './log_hooks.js';
14
+ import { BusEventType } from '#consts';
13
15
 
14
16
  const log = createChildLogger( 'Worker' );
15
17
 
@@ -81,7 +83,7 @@ const callerDir = process.argv[2];
81
83
  process.exit( 0 );
82
84
  } )().catch( error => {
83
85
  log.error( 'Fatal error', { message: error.message, stack: error.stack } );
84
- messageBus.emit( 'runtime:error', { error } );
86
+ messageBus.emit( BusEventType.RUNTIME_ERROR, { error } );
85
87
  log.info( `Exiting in ${configs.processFailureShutdownDelay}ms` );
86
88
  setTimeout( () => process.exit( 1 ), configs.processFailureShutdownDelay );
87
89
  } );
@@ -3,7 +3,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
3
  const mockLog = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
4
4
  vi.mock( '#logger', () => ( { createChildLogger: () => mockLog } ) );
5
5
 
6
- vi.mock( '#consts', () => ( { WORKFLOW_CATALOG: 'catalog' } ) );
6
+ vi.mock( '#consts', async importOriginal => {
7
+ const actual = await importOriginal();
8
+ return { ...actual };
9
+ } );
7
10
 
8
11
  vi.mock( '#tracing', () => ( { init: vi.fn().mockResolvedValue( undefined ) } ) );
9
12
 
@@ -52,6 +55,8 @@ vi.mock( './start_catalog.js', () => ( { startCatalog: startCatalogMock } ) );
52
55
  const registerShutdownMock = vi.fn();
53
56
  vi.mock( './shutdown.js', () => ( { registerShutdown: registerShutdownMock } ) );
54
57
 
58
+ vi.mock( './log_hooks.js', () => ( {} ) );
59
+
55
60
  const runState = { resolve: null };
56
61
  const runPromise = new Promise( r => {
57
62
  runState.resolve = r;
@@ -1,13 +1,11 @@
1
1
  import { Context } from '@temporalio/activity';
2
2
  import { Storage } from '#async_storage';
3
- import { addEventStart, addEventEnd, addEventError } from '#tracing';
3
+ import * as Tracing from '#tracing';
4
4
  import { headersToObject } from '../sandboxed_utils.js';
5
- import { LifecycleEvent, METADATA_ACCESS_SYMBOL } from '#consts';
5
+ import { BusEventType, METADATA_ACCESS_SYMBOL } from '#consts';
6
6
  import { activityHeartbeatEnabled, activityHeartbeatIntervalMs } from '../configs.js';
7
- import { createChildLogger } from '#logger';
8
7
  import { messageBus } from '#bus';
9
8
 
10
- const log = createChildLogger( 'Activity' );
11
9
  /*
12
10
  This interceptor wraps every activity execution with cross-cutting concerns:
13
11
 
@@ -31,16 +29,14 @@ export class ActivityExecutionInterceptor {
31
29
  };
32
30
 
33
31
  async execute( input, next ) {
34
- const { workflowExecution: { workflowId }, activityId, activityType, workflowType: workflowName } = Context.current().info;
35
- const { executionContext } = headersToObject( input.headers );
36
- const { type: kind } = this.activities?.[activityType]?.[METADATA_ACCESS_SYMBOL];
37
-
38
32
  const startDate = Date.now();
39
- const logContext = { workflowName, workflowId, stepId: activityId, stepName: activityType };
40
- const traceArguments = { kind, id: activityId, parentId: workflowId, name: activityType, executionContext };
33
+ const { workflowExecution: { workflowId }, activityId: id, activityType: name, workflowType: workflowName } = Context.current().info;
34
+ const { executionContext } = headersToObject( input.headers );
35
+ const { type: kind } = this.activities?.[name]?.[METADATA_ACCESS_SYMBOL];
36
+ const workflowFilename = this.workflowsMap.get( workflowName ).path;
41
37
 
42
- log.info( `Started ${activityType} ${kind}`, { event: LifecycleEvent.START, kind, ...logContext } );
43
- addEventStart( { details: input.args[0], ...traceArguments } );
38
+ messageBus.emit( BusEventType.ACTIVITY_START, { id, name, kind, workflowId, workflowName } );
39
+ Tracing.addEventStart( { id, name, kind, parentId: workflowId, details: input.args[0], executionContext } );
44
40
 
45
41
  const intervals = { heartbeat: null };
46
42
  try {
@@ -48,25 +44,17 @@ export class ActivityExecutionInterceptor {
48
44
  intervals.heartbeat = activityHeartbeatEnabled && setInterval( () => Context.current().heartbeat(), activityHeartbeatIntervalMs );
49
45
 
50
46
  // Wraps the execution with accessible metadata for the activity
51
- const output = await Storage.runWithContext( async _ => next( input ), {
52
- parentId: activityId,
53
- executionContext,
54
- workflowFilename: this.workflowsMap.get( workflowName ).path
55
- } );
47
+ const output = await Storage.runWithContext( async _ => next( input ), { parentId: id, executionContext, workflowFilename } );
56
48
 
57
- log.info( `Ended ${activityType} ${kind}`, { event: LifecycleEvent.END, kind, ...logContext, durationMs: Date.now() - startDate } );
58
- addEventEnd( { details: output, ...traceArguments } );
49
+ messageBus.emit( BusEventType.ACTIVITY_END, { id, name, kind, workflowId, workflowName, duration: Date.now() - startDate } );
50
+ Tracing.addEventEnd( { id, details: output, executionContext } );
59
51
  return output;
60
52
 
61
53
  } catch ( error ) {
62
- log.error( `Error ${activityType} ${kind}: ${error.message}`, {
63
- event: LifecycleEvent.ERROR, kind, ...logContext, durationMs: Date.now() - startDate, error: error.message
64
- } );
65
- addEventError( { details: error, ...traceArguments } );
54
+ messageBus.emit( BusEventType.ACTIVITY_ERROR, { id, name, kind, workflowId, workflowName, duration: Date.now() - startDate, error } );
55
+ Tracing.addEventError( { id, details: error, executionContext } );
66
56
 
67
- messageBus.emit( 'activity:error', { error, workflowName, activityName: activityType } );
68
57
  throw error;
69
-
70
58
  } finally {
71
59
  clearInterval( intervals.heartbeat );
72
60
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { BusEventType } from '#consts';
2
3
 
3
4
  const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
4
5
 
@@ -39,6 +40,9 @@ vi.mock( '../sandboxed_utils.js', () => ( {
39
40
  headersToObject: () => ( { executionContext: { workflowId: 'wf-1' } } )
40
41
  } ) );
41
42
 
43
+ const messageBusEmitMock = vi.fn();
44
+ vi.mock( '#bus', () => ( { messageBus: { emit: messageBusEmitMock } } ) );
45
+
42
46
  vi.mock( '#consts', async importOriginal => {
43
47
  const actual = await importOriginal();
44
48
  return {
@@ -93,6 +97,12 @@ describe( 'ActivityExecutionInterceptor', () => {
93
97
  const output = await promise;
94
98
 
95
99
  expect( output ).toEqual( { result: 'ok' } );
100
+ expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_START, expect.objectContaining( {
101
+ id: 'act-1', name: 'myWorkflow#myStep', kind: 'step', workflowId: 'wf-1', workflowName: 'myWorkflow'
102
+ } ) );
103
+ expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_END, expect.objectContaining( {
104
+ id: 'act-1', name: 'myWorkflow#myStep', kind: 'step', workflowId: 'wf-1', workflowName: 'myWorkflow', duration: expect.any( Number )
105
+ } ) );
96
106
  expect( addEventStartMock ).toHaveBeenCalledOnce();
97
107
  expect( addEventEndMock ).toHaveBeenCalledOnce();
98
108
  expect( addEventErrorMock ).not.toHaveBeenCalled();
@@ -116,6 +126,11 @@ describe( 'ActivityExecutionInterceptor', () => {
116
126
  vi.advanceTimersByTime( 0 );
117
127
 
118
128
  await expect( promise ).rejects.toThrow( 'step failed' );
129
+ expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_START, expect.any( Object ) );
130
+ expect( messageBusEmitMock ).toHaveBeenCalledWith( BusEventType.ACTIVITY_ERROR, expect.objectContaining( {
131
+ id: 'act-1', name: 'myWorkflow#myStep', kind: 'step', workflowId: 'wf-1', workflowName: 'myWorkflow',
132
+ duration: expect.any( Number ), error: expect.any( Error )
133
+ } ) );
119
134
  expect( addEventStartMock ).toHaveBeenCalledOnce();
120
135
  expect( addEventErrorMock ).toHaveBeenCalledOnce();
121
136
  expect( addEventEndMock ).not.toHaveBeenCalled();
@@ -32,10 +32,10 @@ const sinks = proxySinks();
32
32
 
33
33
  class WorkflowExecutionInterceptor {
34
34
  async execute( input, next ) {
35
- sinks.trace.addWorkflowEventStart( input.args[0] );
35
+ sinks.workflow.start( input.args[0] );
36
36
  try {
37
37
  const output = await next( input );
38
- sinks.trace.addWorkflowEventEnd( output );
38
+ sinks.workflow.end( output );
39
39
  return output;
40
40
  } catch ( error ) {
41
41
  /*
@@ -44,11 +44,11 @@ class WorkflowExecutionInterceptor {
44
44
  * a new trace file will be generated
45
45
  */
46
46
  if ( error instanceof ContinueAsNew ) {
47
- sinks.trace.addWorkflowEventEnd( '<continued_as_new>' );
47
+ sinks.workflow.end( '<continued_as_new>' );
48
48
  throw error;
49
49
  }
50
50
 
51
- sinks.trace.addWorkflowEventError( error );
51
+ sinks.workflow.error( error );
52
52
  const failure = new ApplicationFailure( error.message, error.constructor.name, undefined, undefined, error );
53
53
 
54
54
  /*
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ const METADATA_ACCESS_SYMBOL = vi.hoisted( () => Symbol( '__metadata' ) );
4
+
5
+ const workflowInfoMock = vi.fn();
6
+ const workflowStartMock = vi.fn();
7
+ const workflowEndMock = vi.fn();
8
+ const workflowErrorMock = vi.fn();
9
+ vi.mock( '@temporalio/workflow', () => ( {
10
+ workflowInfo: ( ...args ) => workflowInfoMock( ...args ),
11
+ proxySinks: () => ( {
12
+ workflow: { start: workflowStartMock, end: workflowEndMock, error: workflowErrorMock }
13
+ } ),
14
+ ApplicationFailure: class ApplicationFailure {
15
+ constructor( message, type, nonRetryable, cause, originalError ) {
16
+ this.message = message;
17
+ this.type = type;
18
+ this.nonRetryable = nonRetryable;
19
+ this.cause = cause;
20
+ this.originalError = originalError;
21
+ this.details = undefined;
22
+ }
23
+ },
24
+ ContinueAsNew: class ContinueAsNew extends Error {
25
+ constructor() {
26
+ super( 'ContinueAsNew' );
27
+ this.name = 'ContinueAsNew';
28
+ }
29
+ }
30
+ } ) );
31
+
32
+ const memoToHeadersMock = vi.fn( memo => ( memo ? { ...memo, __asHeaders: true } : {} ) );
33
+ vi.mock( '../sandboxed_utils.js', () => ( { memoToHeaders: ( ...args ) => memoToHeadersMock( ...args ) } ) );
34
+
35
+ const deepMergeMock = vi.fn( ( a, b ) => ( { ...( a || {} ), ...( b || {} ) } ) );
36
+ vi.mock( '#utils', () => ( { deepMerge: ( ...args ) => deepMergeMock( ...args ) } ) );
37
+
38
+ vi.mock( '#consts', async importOriginal => {
39
+ const actual = await importOriginal();
40
+ return {
41
+ ...actual, get METADATA_ACCESS_SYMBOL() {
42
+ return METADATA_ACCESS_SYMBOL;
43
+ }
44
+ };
45
+ } );
46
+
47
+ const stepOptionsDefault = {};
48
+ vi.mock( '../temp/__activity_options.js', () => ( { default: stepOptionsDefault } ) );
49
+
50
+ describe( 'workflow interceptors', () => {
51
+ beforeEach( () => {
52
+ vi.clearAllMocks();
53
+ workflowInfoMock.mockReturnValue( { workflowType: 'MyWorkflow', memo: { executionContext: { id: 'ctx-1' } } } );
54
+ } );
55
+
56
+ describe( 'HeadersInjectionInterceptor', () => {
57
+ it( 'assigns memo as headers via memoToHeaders and calls next', async () => {
58
+ const { interceptors } = await import( './workflow.js' );
59
+ const { outbound } = interceptors();
60
+ const interceptor = outbound[0];
61
+ const input = { headers: { existing: 'header' }, activityType: 'MyWorkflow#step1' };
62
+ const next = vi.fn().mockResolvedValue( 'result' );
63
+
64
+ memoToHeadersMock.mockReturnValue( { executionContext: { id: 'ctx-1' } } );
65
+
66
+ const out = await interceptor.scheduleActivity( input, next );
67
+
68
+ expect( memoToHeadersMock ).toHaveBeenCalledWith( { executionContext: { id: 'ctx-1' } } );
69
+ expect( input.headers ).toEqual( { existing: 'header', executionContext: { id: 'ctx-1' } } );
70
+ expect( next ).toHaveBeenCalledWith( input );
71
+ expect( out ).toBe( 'result' );
72
+ } );
73
+
74
+ it( 'merges stepOptions with memo.activityOptions when stepOptions exist for activityType', async () => {
75
+ stepOptionsDefault['MyWorkflow#step1'] = { scheduleToCloseTimeout: 60 };
76
+ workflowInfoMock.mockReturnValue( {
77
+ workflowType: 'MyWorkflow',
78
+ memo: { executionContext: {}, activityOptions: { heartbeatTimeout: 10 } }
79
+ } );
80
+ memoToHeadersMock.mockReturnValue( {} );
81
+ deepMergeMock.mockReturnValue( { heartbeatTimeout: 10, scheduleToCloseTimeout: 60 } );
82
+
83
+ const { interceptors } = await import( './workflow.js' );
84
+ const { outbound } = interceptors();
85
+ const interceptor = outbound[0];
86
+ const input = { headers: {}, activityType: 'MyWorkflow#step1' };
87
+ const next = vi.fn().mockResolvedValue( undefined );
88
+
89
+ await interceptor.scheduleActivity( input, next );
90
+
91
+ expect( deepMergeMock ).toHaveBeenCalledWith( { heartbeatTimeout: 10 }, { scheduleToCloseTimeout: 60 } );
92
+ expect( input.options ).toEqual( { heartbeatTimeout: 10, scheduleToCloseTimeout: 60 } );
93
+ delete stepOptionsDefault['MyWorkflow#step1'];
94
+ } );
95
+ } );
96
+
97
+ describe( 'WorkflowExecutionInterceptor', () => {
98
+ it( 'calls sinks.workflow.start, next, then sinks.workflow.end on success', async () => {
99
+ const { interceptors } = await import( './workflow.js' );
100
+ const { inbound } = interceptors();
101
+ const interceptor = inbound[0];
102
+ const input = { args: [ { input: 'data' } ] };
103
+ const next = vi.fn().mockResolvedValue( { output: 'ok' } );
104
+
105
+ const result = await interceptor.execute( input, next );
106
+
107
+ expect( workflowStartMock ).toHaveBeenCalledWith( { input: 'data' } );
108
+ expect( next ).toHaveBeenCalledWith( input );
109
+ expect( workflowEndMock ).toHaveBeenCalledWith( { output: 'ok' } );
110
+ expect( result ).toEqual( { output: 'ok' } );
111
+ expect( workflowErrorMock ).not.toHaveBeenCalled();
112
+ } );
113
+
114
+ it( 'calls sinks.workflow.error and throws ApplicationFailure on error', async () => {
115
+ const { interceptors } = await import( './workflow.js' );
116
+ const { inbound } = interceptors();
117
+ const interceptor = inbound[0];
118
+ const input = { args: [ {} ] };
119
+ const err = new Error( 'workflow failed' );
120
+ const next = vi.fn().mockRejectedValue( err );
121
+
122
+ await expect( interceptor.execute( input, next ) ).rejects.toMatchObject( {
123
+ message: 'workflow failed',
124
+ type: 'Error',
125
+ originalError: err
126
+ } );
127
+ expect( workflowStartMock ).toHaveBeenCalled();
128
+ expect( workflowErrorMock ).toHaveBeenCalledWith( err );
129
+ expect( workflowEndMock ).not.toHaveBeenCalled();
130
+ } );
131
+
132
+ it( 'sets failure.details from error metadata when present', async () => {
133
+ const { interceptors } = await import( './workflow.js' );
134
+ const { ApplicationFailure } = await import( '@temporalio/workflow' );
135
+ const { inbound } = interceptors();
136
+ const interceptor = inbound[0];
137
+ const meta = { code: 'CUSTOM' };
138
+ const err = new Error( 'custom' );
139
+ err[METADATA_ACCESS_SYMBOL] = meta;
140
+ const next = vi.fn().mockRejectedValue( err );
141
+
142
+ const error = await ( async () => {
143
+ try {
144
+ await interceptor.execute( { args: [ {} ] }, next );
145
+ return null;
146
+ } catch ( error ) {
147
+ return error;
148
+ }
149
+ } )();
150
+ expect( error ).toBeInstanceOf( ApplicationFailure );
151
+ expect( error.details ).toEqual( [ meta ] );
152
+ } );
153
+
154
+ it( 'on ContinueAsNew calls sinks.trace.addWorkflowEventEnd and rethrows', async () => {
155
+ const { ContinueAsNew } = await import( '@temporalio/workflow' );
156
+ const { interceptors } = await import( './workflow.js' );
157
+ const { inbound } = interceptors();
158
+ const interceptor = inbound[0];
159
+ const continueErr = new ContinueAsNew();
160
+ const next = vi.fn().mockRejectedValue( continueErr );
161
+
162
+ await expect( interceptor.execute( { args: [ {} ] }, next ) ).rejects.toThrow( ContinueAsNew );
163
+ expect( workflowEndMock ).toHaveBeenCalledWith( '<continued_as_new>' );
164
+ expect( workflowErrorMock ).not.toHaveBeenCalled();
165
+ } );
166
+ } );
167
+ } );
@@ -0,0 +1,95 @@
1
+ import { messageBus } from '#bus';
2
+ import { createChildLogger } from '#logger';
3
+ import { BusEventType, ComponentType, LifecycleEvent, WORKFLOW_CATALOG } from '#consts';
4
+
5
+ const activityLog = createChildLogger( 'Activity' );
6
+ const workflowLog = createChildLogger( 'Workflow' );
7
+
8
+ /**
9
+ * Intercepts internal bus events for activity and workflow lifecycle and log them
10
+ */
11
+
12
+ /*
13
+ ╔═════════════════╗
14
+ ║ Activity events ║
15
+ ╚═════════════════╝
16
+ */
17
+
18
+ /**
19
+ * Returns true if activity event should be logged
20
+ */
21
+ const shouldLogActivityEvent = ( { kind } ) => kind !== ComponentType.INTERNAL_STEP;
22
+
23
+ messageBus.on( BusEventType.ACTIVITY_START, ( { id, name, kind, workflowId, workflowName } ) =>
24
+ shouldLogActivityEvent( { kind } ) && activityLog.info( `Started ${name} ${kind}`, {
25
+ event: LifecycleEvent.START,
26
+ activityId: id,
27
+ activityName: name,
28
+ activityKind: kind,
29
+ workflowId,
30
+ workflowName
31
+ } )
32
+ );
33
+
34
+ messageBus.on( BusEventType.ACTIVITY_END, ( { id, name, kind, workflowId, workflowName, duration } ) =>
35
+ shouldLogActivityEvent( { kind } ) && activityLog.info( `Ended ${name} ${kind}`, {
36
+ event: LifecycleEvent.END,
37
+ activityId: id,
38
+ activityName: name,
39
+ activityKind: kind,
40
+ workflowId,
41
+ workflowName,
42
+ durationMs: duration
43
+ } )
44
+ );
45
+
46
+ messageBus.on( BusEventType.ACTIVITY_ERROR, ( { id, name, kind, workflowId, workflowName, duration, error } ) =>
47
+ shouldLogActivityEvent( { kind } ) && activityLog.error( `Error ${name} ${kind}: ${error.constructor.name}`, {
48
+ event: LifecycleEvent.ERROR,
49
+ activityId: id,
50
+ activityName: name,
51
+ activityKind: kind,
52
+ workflowId,
53
+ workflowName,
54
+ durationMs: duration,
55
+ error: error.message
56
+ } )
57
+ );
58
+
59
+ /*
60
+ ╔═════════════════╗
61
+ ║ Workflow events ║
62
+ ╚═════════════════╝
63
+ */
64
+
65
+ /**
66
+ * Returns true if activity event should be logged
67
+ */
68
+ const shouldLogWorkflowEvent = ( { name } ) => name !== WORKFLOW_CATALOG;
69
+
70
+ messageBus.on( BusEventType.WORKFLOW_START, ( { id, name } ) =>
71
+ shouldLogWorkflowEvent( { name } ) && workflowLog.info( `Started ${name} workflow`, {
72
+ event: LifecycleEvent.START,
73
+ workflowId: id,
74
+ workflowName: name
75
+ } )
76
+ );
77
+
78
+ messageBus.on( BusEventType.WORKFLOW_END, ( { id, name, duration } ) =>
79
+ shouldLogWorkflowEvent( { name } ) && workflowLog.info( `Ended ${name} workflow`, {
80
+ event: LifecycleEvent.END,
81
+ workflowId: id,
82
+ workflowName: name,
83
+ durationMs: duration
84
+ } )
85
+ );
86
+
87
+ messageBus.on( BusEventType.WORKFLOW_ERROR, ( { id, name, duration, error } ) =>
88
+ shouldLogWorkflowEvent( { name } ) && workflowLog.error( `Error ${name} workflow: ${error.constructor.name}`, {
89
+ event: LifecycleEvent.ERROR,
90
+ workflowId: id,
91
+ workflowName: name,
92
+ durationMs: duration,
93
+ error: error.message
94
+ } )
95
+ );
@@ -0,0 +1,217 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ BusEventType,
4
+ ComponentType,
5
+ LifecycleEvent,
6
+ WORKFLOW_CATALOG
7
+ } from '#consts';
8
+
9
+ const activityLogMock = vi.hoisted( () => ( { info: vi.fn(), error: vi.fn() } ) );
10
+ const workflowLogMock = vi.hoisted( () => ( { info: vi.fn(), error: vi.fn() } ) );
11
+ const createChildLoggerMock = vi.hoisted( () =>
12
+ vi.fn( name => ( name === 'Activity' ? activityLogMock : workflowLogMock ) )
13
+ );
14
+
15
+ const onHandlers = vi.hoisted( () => ( {} ) );
16
+ const messageBusMock = vi.hoisted( () => ( {
17
+ on: vi.fn( ( eventType, handler ) => {
18
+ onHandlers[eventType] = handler;
19
+ } )
20
+ } ) );
21
+
22
+ vi.mock( '#logger', () => ( { createChildLogger: createChildLoggerMock } ) );
23
+ vi.mock( '#bus', () => ( { messageBus: messageBusMock } ) );
24
+
25
+ import './log_hooks.js';
26
+
27
+ describe( 'log_hooks', () => {
28
+ beforeEach( () => {
29
+ vi.clearAllMocks();
30
+ } );
31
+
32
+ describe( 'activity events', () => {
33
+ const basePayload = {
34
+ id: 'act-1',
35
+ name: 'myWorkflow#myStep',
36
+ kind: 'step',
37
+ workflowId: 'wf-1',
38
+ workflowName: 'myWorkflow'
39
+ };
40
+
41
+ it( 'ACTIVITY_START logs full message and second arg', () => {
42
+ onHandlers[BusEventType.ACTIVITY_START]( basePayload );
43
+
44
+ expect( activityLogMock.info ).toHaveBeenCalledTimes( 1 );
45
+ expect( activityLogMock.info ).toHaveBeenCalledWith(
46
+ 'Started myWorkflow#myStep step',
47
+ {
48
+ event: LifecycleEvent.START,
49
+ activityId: 'act-1',
50
+ activityName: 'myWorkflow#myStep',
51
+ activityKind: 'step',
52
+ workflowId: 'wf-1',
53
+ workflowName: 'myWorkflow'
54
+ }
55
+ );
56
+ } );
57
+
58
+ it( 'ACTIVITY_START does not log when kind is INTERNAL_STEP', () => {
59
+ onHandlers[BusEventType.ACTIVITY_START]( {
60
+ ...basePayload,
61
+ kind: ComponentType.INTERNAL_STEP
62
+ } );
63
+
64
+ expect( activityLogMock.info ).not.toHaveBeenCalled();
65
+ } );
66
+
67
+ it( 'ACTIVITY_END logs full message and second arg', () => {
68
+ onHandlers[BusEventType.ACTIVITY_END]( { ...basePayload, duration: 42 } );
69
+
70
+ expect( activityLogMock.info ).toHaveBeenCalledTimes( 1 );
71
+ expect( activityLogMock.info ).toHaveBeenCalledWith(
72
+ 'Ended myWorkflow#myStep step',
73
+ {
74
+ event: LifecycleEvent.END,
75
+ activityId: 'act-1',
76
+ activityName: 'myWorkflow#myStep',
77
+ activityKind: 'step',
78
+ workflowId: 'wf-1',
79
+ workflowName: 'myWorkflow',
80
+ durationMs: 42
81
+ }
82
+ );
83
+ } );
84
+
85
+ it( 'ACTIVITY_END does not log when kind is INTERNAL_STEP', () => {
86
+ onHandlers[BusEventType.ACTIVITY_END]( {
87
+ ...basePayload,
88
+ kind: ComponentType.INTERNAL_STEP,
89
+ duration: 10
90
+ } );
91
+
92
+ expect( activityLogMock.info ).not.toHaveBeenCalled();
93
+ } );
94
+
95
+ it( 'ACTIVITY_ERROR logs full message and second arg', () => {
96
+ const err = new Error( 'step failed' );
97
+ onHandlers[BusEventType.ACTIVITY_ERROR]( {
98
+ ...basePayload,
99
+ duration: 100,
100
+ error: err
101
+ } );
102
+
103
+ expect( activityLogMock.error ).toHaveBeenCalledTimes( 1 );
104
+ expect( activityLogMock.error ).toHaveBeenCalledWith(
105
+ 'Error myWorkflow#myStep step: Error',
106
+ {
107
+ event: LifecycleEvent.ERROR,
108
+ activityId: 'act-1',
109
+ activityName: 'myWorkflow#myStep',
110
+ activityKind: 'step',
111
+ workflowId: 'wf-1',
112
+ workflowName: 'myWorkflow',
113
+ durationMs: 100,
114
+ error: 'step failed'
115
+ }
116
+ );
117
+ } );
118
+
119
+ it( 'ACTIVITY_ERROR does not log when kind is INTERNAL_STEP', () => {
120
+ onHandlers[BusEventType.ACTIVITY_ERROR]( {
121
+ ...basePayload,
122
+ kind: ComponentType.INTERNAL_STEP,
123
+ duration: 5,
124
+ error: new Error( 'x' )
125
+ } );
126
+
127
+ expect( activityLogMock.error ).not.toHaveBeenCalled();
128
+ } );
129
+ } );
130
+
131
+ describe( 'workflow events', () => {
132
+ const basePayload = { id: 'wf-1', name: 'myWorkflow' };
133
+
134
+ it( 'WORKFLOW_START logs full message and second arg', () => {
135
+ onHandlers[BusEventType.WORKFLOW_START]( basePayload );
136
+
137
+ expect( workflowLogMock.info ).toHaveBeenCalledTimes( 1 );
138
+ expect( workflowLogMock.info ).toHaveBeenCalledWith(
139
+ 'Started myWorkflow workflow',
140
+ {
141
+ event: LifecycleEvent.START,
142
+ workflowId: 'wf-1',
143
+ workflowName: 'myWorkflow'
144
+ }
145
+ );
146
+ } );
147
+
148
+ it( 'WORKFLOW_START does not log when name is WORKFLOW_CATALOG', () => {
149
+ onHandlers[BusEventType.WORKFLOW_START]( {
150
+ id: 'cat-1',
151
+ name: WORKFLOW_CATALOG
152
+ } );
153
+
154
+ expect( workflowLogMock.info ).not.toHaveBeenCalled();
155
+ } );
156
+
157
+ it( 'WORKFLOW_END logs full message and second arg', () => {
158
+ onHandlers[BusEventType.WORKFLOW_END]( {
159
+ ...basePayload,
160
+ duration: 200
161
+ } );
162
+
163
+ expect( workflowLogMock.info ).toHaveBeenCalledTimes( 1 );
164
+ expect( workflowLogMock.info ).toHaveBeenCalledWith(
165
+ 'Ended myWorkflow workflow',
166
+ {
167
+ event: LifecycleEvent.END,
168
+ workflowId: 'wf-1',
169
+ workflowName: 'myWorkflow',
170
+ durationMs: 200
171
+ }
172
+ );
173
+ } );
174
+
175
+ it( 'WORKFLOW_END does not log when name is WORKFLOW_CATALOG', () => {
176
+ onHandlers[BusEventType.WORKFLOW_END]( {
177
+ id: 'cat-1',
178
+ name: WORKFLOW_CATALOG,
179
+ duration: 50
180
+ } );
181
+
182
+ expect( workflowLogMock.info ).not.toHaveBeenCalled();
183
+ } );
184
+
185
+ it( 'WORKFLOW_ERROR logs full message and second arg', () => {
186
+ const err = new TypeError( 'workflow boom' );
187
+ onHandlers[BusEventType.WORKFLOW_ERROR]( {
188
+ ...basePayload,
189
+ duration: 150,
190
+ error: err
191
+ } );
192
+
193
+ expect( workflowLogMock.error ).toHaveBeenCalledTimes( 1 );
194
+ expect( workflowLogMock.error ).toHaveBeenCalledWith(
195
+ 'Error myWorkflow workflow: TypeError',
196
+ {
197
+ event: LifecycleEvent.ERROR,
198
+ workflowId: 'wf-1',
199
+ workflowName: 'myWorkflow',
200
+ durationMs: 150,
201
+ error: 'workflow boom'
202
+ }
203
+ );
204
+ } );
205
+
206
+ it( 'WORKFLOW_ERROR does not log when name is WORKFLOW_CATALOG', () => {
207
+ onHandlers[BusEventType.WORKFLOW_ERROR]( {
208
+ id: 'cat-1',
209
+ name: WORKFLOW_CATALOG,
210
+ duration: 1,
211
+ error: new Error( 'x' )
212
+ } );
213
+
214
+ expect( workflowLogMock.error ).not.toHaveBeenCalled();
215
+ } );
216
+ } );
217
+ } );
@@ -1,109 +1,74 @@
1
- import { LifecycleEvent, WORKFLOW_CATALOG } from '#consts';
2
- import { addEventStart, addEventEnd, addEventError } from '#tracing';
3
- import { createChildLogger } from '#logger';
1
+ import { BusEventType, ComponentType } from '#consts';
2
+ import * as Tracing from '#tracing';
4
3
  import { messageBus } from '#bus';
5
4
 
6
- const log = createChildLogger( 'Workflow' );
7
-
8
- /**
9
- * Adds a workflow trace event
10
- *
11
- * @param {function} method - Trace function to call
12
- * @param {object} workflowInfo - Temporal workflowInfo object
13
- * @param {object} details - The details to attach to the event
14
- */
15
- const addWorkflowEvent = ( method, workflowInfo, details ) => {
16
- const { workflowId: id, workflowType: name, memo: { parentId, executionContext } } = workflowInfo;
17
- if ( name === WORKFLOW_CATALOG ) {
18
- return;
19
- } // ignore internal catalog events
20
- method( { id, kind: 'workflow', name, details, parentId, executionContext } );
21
- };
22
-
23
- /**
24
- * Logs the internal workflow event
25
- *
26
- * @param {LifecycleEvent} event
27
- * @param {Object} workflowInfo
28
- * @returns {void}
29
- */
30
- const logWorkflowEvent = ( event, workflowInfo, error ) => {
31
- const { workflowId, workflowType: workflowName, startTime } = workflowInfo;
32
- // exclude internal catalog
33
- if ( workflowName === WORKFLOW_CATALOG ) {
34
- return;
35
- }
36
-
37
- if ( event === LifecycleEvent.START ) {
38
- log.info( `Started ${workflowName} workflow`, { event, workflowName, workflowId } );
39
- } else if ( event === LifecycleEvent.END ) {
40
- log.info( `Ended ${workflowName} workflow`, { event, workflowName, workflowId, durationMs: Date.now() - startTime.getTime() } );
41
- } else if ( event === LifecycleEvent.ERROR ) {
42
- log.error( `Error ${workflowName} workflow: ${error.message}`, {
43
- event,
44
- workflowName,
45
- workflowId,
46
- durationMs: Date.now() - startTime.getTime(),
47
- error: error.message
48
- } );
49
- }
50
- };
51
-
52
- /**
53
- * Start a trace event with given configuration
54
- *
55
- * @param {function} method - Trace function to call
56
- * @param {object} workflowInfo - Temporal workflowInfo object
57
- * @param {object} options - Trace options, like id, kind, name and details
58
- */
59
- const addEvent = ( method, workflowInfo, options ) => {
60
- const { id, name, kind, details } = options;
61
- const { workflowId, memo: { executionContext } } = workflowInfo;
62
- method( { id, kind, name, details, parentId: workflowId, executionContext } );
63
- };
64
-
65
5
  // This sink allow for sandbox Temporal environment to send trace logs back to the main thread.
66
6
  export const sinks = {
67
- trace: {
68
- addWorkflowEventStart: {
69
- fn: ( workflowInfo, details ) => {
70
- logWorkflowEvent( LifecycleEvent.START, workflowInfo );
71
- addWorkflowEvent( addEventStart, workflowInfo, details );
7
+
8
+ /**
9
+ * Workflow lifecycle sinks
10
+ */
11
+ workflow: {
12
+ start: {
13
+ fn: ( workflowInfo, input ) => {
14
+ const { workflowId: id, workflowType: name, memo: { parentId, executionContext } } = workflowInfo;
15
+ messageBus.emit( BusEventType.WORKFLOW_START, { id, name } );
16
+ if ( executionContext ) { // filters out internal workflows
17
+ Tracing.addEventStart( { id, kind: ComponentType.WORKFLOW, name, details: input, parentId, executionContext } );
18
+ }
72
19
  },
73
20
  callDuringReplay: false
74
21
  },
75
22
 
76
- addWorkflowEventEnd: {
77
- fn: ( workflowInfo, details ) => {
78
- logWorkflowEvent( LifecycleEvent.END, workflowInfo );
79
- addWorkflowEvent( addEventEnd, workflowInfo, details );
23
+ end: {
24
+ fn: ( workflowInfo, output ) => {
25
+ const { workflowId: id, workflowType: name, startTime, memo: { executionContext } } = workflowInfo;
26
+ messageBus.emit( BusEventType.WORKFLOW_END, { id, name, duration: Date.now() - startTime.getTime() } );
27
+ if ( executionContext ) { // filters out internal workflows
28
+ Tracing.addEventEnd( { id, details: output, executionContext } );
29
+ }
80
30
  },
81
31
  callDuringReplay: false
82
32
  },
83
33
 
84
- addWorkflowEventError: {
34
+ error: {
85
35
  fn: ( workflowInfo, error ) => {
86
- logWorkflowEvent( LifecycleEvent.ERROR, workflowInfo, error );
87
- addWorkflowEvent( addEventError, workflowInfo, error );
88
- messageBus.emit( 'workflow:error', { error, workflowName: workflowInfo.workflowType } );
36
+ const { workflowId: id, workflowType: name, startTime, memo: { executionContext } } = workflowInfo;
37
+ messageBus.emit( BusEventType.WORKFLOW_ERROR, { id, name, error, duration: Date.now() - startTime.getTime() } );
38
+ if ( executionContext ) { // filters out internal workflows
39
+ Tracing.addEventError( { id, details: error, executionContext } );
40
+ }
89
41
  },
90
42
  callDuringReplay: false
91
- },
43
+ }
44
+ },
92
45
 
93
- addEventStart: {
94
- fn: ( ...args ) => addEvent( addEventStart, ...args ),
46
+ /**
47
+ * Generic trace sinks
48
+ */
49
+ trace: {
50
+ start: {
51
+ fn: ( workflowInfo, { id, name, kind, details } ) => {
52
+ const { memo: { executionContext, parentId } } = workflowInfo;
53
+ Tracing.addEventStart( { id, kind, name, details, parentId, executionContext } );
54
+ },
95
55
  callDuringReplay: false
96
56
  },
97
57
 
98
- addEventEnd: {
99
- fn: ( ...args ) => addEvent( addEventEnd, ...args ),
58
+ end: {
59
+ fn: ( workflowInfo, { id, details } ) => {
60
+ const { memo: { executionContext } } = workflowInfo;
61
+ Tracing.addEventEnd( { id, details, executionContext } );
62
+ },
100
63
  callDuringReplay: false
101
64
  },
102
65
 
103
- addEventError: {
104
- fn: ( ...args ) => addEvent( addEventError, ...args ),
66
+ error: {
67
+ fn: ( workflowInfo, { id, details } ) => {
68
+ const { memo: { executionContext } } = workflowInfo;
69
+ Tracing.addEventError( { id, details, executionContext } );
70
+ },
105
71
  callDuringReplay: false
106
-
107
72
  }
108
73
  }
109
74
  };