@outputai/core 0.6.1-next.383b24b.0 → 0.6.1-next.d3c9b1f.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/logger/development.js +61 -0
- package/src/logger/development.spec.js +70 -0
- package/src/logger/index.js +14 -0
- package/src/logger/index.spec.js +27 -0
- package/src/logger/production.js +15 -0
- package/src/logger/production.spec.js +52 -0
- package/src/worker/configs.js +2 -0
- package/src/worker/configs.spec.js +25 -0
- package/src/worker/index.js +4 -1
- package/src/worker/index.spec.js +4 -0
- package/src/worker/setup_telemetry.js +19 -0
- package/src/worker/setup_telemetry.spec.js +80 -0
- package/src/logger.js +0 -73
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/core",
|
|
3
|
-
"version": "0.6.1-next.
|
|
3
|
+
"version": "0.6.1-next.d3c9b1f.0",
|
|
4
4
|
"description": "The core module of the output framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"#bus": "./src/bus.js",
|
|
66
66
|
"#consts": "./src/consts.js",
|
|
67
67
|
"#errors": "./src/errors.js",
|
|
68
|
-
"#logger": "./src/logger.js",
|
|
68
|
+
"#logger": "./src/logger/index.js",
|
|
69
69
|
"#utils": "./src/utils/index.js",
|
|
70
70
|
"#internal_utils/*": "./src/internal_utils/*.js",
|
|
71
71
|
"#tracing": "./src/tracing/internal_interface.js",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { format, transports } from 'winston';
|
|
2
|
+
import { isPlainObject, shuffleArray } from '#utils';
|
|
3
|
+
|
|
4
|
+
/** Available colors enum */
|
|
5
|
+
const Color = {
|
|
6
|
+
Blue: '033',
|
|
7
|
+
Green: '030',
|
|
8
|
+
Orange: '208',
|
|
9
|
+
Turquoise: '045',
|
|
10
|
+
Purple: '129',
|
|
11
|
+
Yellow: '184'
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Recursively format object as friendly JSON: { name: "foo", count: 5 }
|
|
16
|
+
*
|
|
17
|
+
* @param {any} v - The input value
|
|
18
|
+
* @returns {string} Formatted result
|
|
19
|
+
*/
|
|
20
|
+
export const formatJson = v => {
|
|
21
|
+
if ( isPlainObject( v ) ) {
|
|
22
|
+
const entries = Object.entries( v );
|
|
23
|
+
return entries.length === 0 ? '{}' :
|
|
24
|
+
`{ ${entries.map( ( [ k, v ] ) => `${k}: ${formatJson( v )}` ).join( ', ' )} }`;
|
|
25
|
+
}
|
|
26
|
+
if ( Array.isArray( v ) ) {
|
|
27
|
+
return v.length === 0 ? '[]' : `[ ${v.map( p => formatJson( p ) ).join( ', ' )} ]`;
|
|
28
|
+
}
|
|
29
|
+
if ( typeof v === 'string' ) {
|
|
30
|
+
return JSON.stringify( v );
|
|
31
|
+
}
|
|
32
|
+
return v;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** This instance color schema */
|
|
36
|
+
const COLORS = shuffleArray( Object.values( Color ) );
|
|
37
|
+
|
|
38
|
+
/** Stores all assigned colors per namespace. */
|
|
39
|
+
const assignedColors = new Map();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get the previous assigned color for this value or if not present, assign a new one and store it
|
|
43
|
+
* @param {string} v - A text value
|
|
44
|
+
* @returns {string} The color
|
|
45
|
+
* */
|
|
46
|
+
const getColor = v =>
|
|
47
|
+
assignedColors.get( v ) ?? assignedColors.set( v, COLORS[assignedColors.size % COLORS.length] ).get( v );
|
|
48
|
+
|
|
49
|
+
export const options = {
|
|
50
|
+
level: 'debug',
|
|
51
|
+
transports: [ new transports.Console() ],
|
|
52
|
+
format: format.combine(
|
|
53
|
+
format.colorize(),
|
|
54
|
+
format.metadata(),
|
|
55
|
+
format.printf( ( { level, message, metadata } ) => {
|
|
56
|
+
const { namespace, ...fields } = metadata;
|
|
57
|
+
const jsonText = Object.keys( fields ).length > 0 ? formatJson( fields ) : null;
|
|
58
|
+
return `[${level}] \x1b[38;5;${getColor( namespace )}m${namespace}: ${message}\x1b[0m${jsonText ? ' ' + jsonText : ''}`;
|
|
59
|
+
} )
|
|
60
|
+
)
|
|
61
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const LEVEL = Symbol.for( 'level' );
|
|
4
|
+
const MESSAGE = Symbol.for( 'message' );
|
|
5
|
+
|
|
6
|
+
vi.mock( '#utils', () => ( {
|
|
7
|
+
isPlainObject: v => Object.prototype.toString.call( v ) === '[object Object]',
|
|
8
|
+
shuffleArray: v => v
|
|
9
|
+
} ) );
|
|
10
|
+
|
|
11
|
+
const loadDevelopmentLogger = async () => {
|
|
12
|
+
vi.resetModules();
|
|
13
|
+
return import( './development.js' );
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe( 'logger/development', () => {
|
|
17
|
+
describe( 'formatJson', () => {
|
|
18
|
+
it( 'formats nested plain objects and arrays', async () => {
|
|
19
|
+
const { formatJson } = await loadDevelopmentLogger();
|
|
20
|
+
|
|
21
|
+
expect( formatJson( {
|
|
22
|
+
name: 'foo',
|
|
23
|
+
count: 5,
|
|
24
|
+
nested: { ok: true },
|
|
25
|
+
list: [ 1, 'two', { three: 3 } ]
|
|
26
|
+
} ) ).toBe( '{ name: "foo", count: 5, nested: { ok: true }, list: [ 1, "two", { three: 3 } ] }' );
|
|
27
|
+
} );
|
|
28
|
+
|
|
29
|
+
it( 'formats empty objects and arrays', async () => {
|
|
30
|
+
const { formatJson } = await loadDevelopmentLogger();
|
|
31
|
+
|
|
32
|
+
expect( formatJson( { emptyObject: {}, emptyArray: [] } ) ).toBe( '{ emptyObject: {}, emptyArray: [] }' );
|
|
33
|
+
} );
|
|
34
|
+
|
|
35
|
+
it( 'escapes string values', async () => {
|
|
36
|
+
const { formatJson } = await loadDevelopmentLogger();
|
|
37
|
+
|
|
38
|
+
expect( formatJson( { quote: 'hello "world"' } ) ).toBe( '{ quote: "hello \\"world\\"" }' );
|
|
39
|
+
} );
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
describe( 'options.format', () => {
|
|
43
|
+
it( 'formats level, namespace, message, and metadata fields', async () => {
|
|
44
|
+
const { options } = await loadDevelopmentLogger();
|
|
45
|
+
const info = options.format.transform( {
|
|
46
|
+
[LEVEL]: 'info',
|
|
47
|
+
level: 'info',
|
|
48
|
+
message: 'Worker',
|
|
49
|
+
namespace: 'Telemetry',
|
|
50
|
+
status: { runState: 'RUNNING' },
|
|
51
|
+
memory: { heapUsed: 123 }
|
|
52
|
+
} );
|
|
53
|
+
|
|
54
|
+
expect( info[MESSAGE] ).toMatch( /^\[\x1b\[[\d;]+minfo\x1b\[[\d;]+m\] \x1b\[38;5;033mTelemetry: Worker\x1b\[0m /u );
|
|
55
|
+
expect( info[MESSAGE] ).toContain( '{ status: { runState: "RUNNING" }, memory: { heapUsed: 123 } }' );
|
|
56
|
+
} );
|
|
57
|
+
|
|
58
|
+
it( 'does not append metadata text when only namespace is present', async () => {
|
|
59
|
+
const { options } = await loadDevelopmentLogger();
|
|
60
|
+
const info = options.format.transform( {
|
|
61
|
+
[LEVEL]: 'debug',
|
|
62
|
+
level: 'debug',
|
|
63
|
+
message: 'Loading config...',
|
|
64
|
+
namespace: 'Worker'
|
|
65
|
+
} );
|
|
66
|
+
|
|
67
|
+
expect( info[MESSAGE] ).toMatch( /^\[\x1b\[[\d;]+mdebug\x1b\[[\d;]+m\] \x1b\[38;5;033mWorker: Loading config\.\.\.\x1b\[0m$/u );
|
|
68
|
+
} );
|
|
69
|
+
} );
|
|
70
|
+
} );
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import winston from 'winston';
|
|
2
|
+
|
|
3
|
+
const { options } = await import( process.env.NODE_ENV === 'production' ? './production.js' : './development.js' );
|
|
4
|
+
|
|
5
|
+
// creates the root winston logger
|
|
6
|
+
const logger = winston.createLogger( options );
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a child logger with a specific namespace
|
|
10
|
+
*
|
|
11
|
+
* @param {string} namespace - The namespace for this logger (e.g., 'Scanner', 'Tracing')
|
|
12
|
+
* @returns {winston.Logger} Child logger instance with namespace metadata
|
|
13
|
+
*/
|
|
14
|
+
export const createChildLogger = namespace => logger.child( { namespace } );
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const loadLogger = async nodeEnv => {
|
|
4
|
+
vi.resetModules();
|
|
5
|
+
vi.stubEnv( 'NODE_ENV', nodeEnv );
|
|
6
|
+
return import( './index.js' );
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe( 'logger/index', () => {
|
|
10
|
+
afterEach( () => {
|
|
11
|
+
vi.unstubAllEnvs();
|
|
12
|
+
} );
|
|
13
|
+
|
|
14
|
+
it( 'loads the development logger options', async () => {
|
|
15
|
+
const { createChildLogger } = await loadLogger( 'development' );
|
|
16
|
+
const log = createChildLogger( 'Test' );
|
|
17
|
+
|
|
18
|
+
expect( typeof log.info ).toBe( 'function' );
|
|
19
|
+
} );
|
|
20
|
+
|
|
21
|
+
it( 'loads the production logger options', async () => {
|
|
22
|
+
const { createChildLogger } = await loadLogger( 'production' );
|
|
23
|
+
const log = createChildLogger( 'Test' );
|
|
24
|
+
|
|
25
|
+
expect( typeof log.info ).toBe( 'function' );
|
|
26
|
+
} );
|
|
27
|
+
} );
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { transports, format } from 'winston';
|
|
2
|
+
|
|
3
|
+
export const options = {
|
|
4
|
+
level: 'info',
|
|
5
|
+
transports: [ new transports.Console() ],
|
|
6
|
+
defaultMeta: {
|
|
7
|
+
service: 'output-worker',
|
|
8
|
+
environment: 'production'
|
|
9
|
+
},
|
|
10
|
+
format: format.combine(
|
|
11
|
+
format.timestamp( { format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' } ),
|
|
12
|
+
format.errors( { stack: true } ),
|
|
13
|
+
format.json()
|
|
14
|
+
)
|
|
15
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { options } from './production.js';
|
|
3
|
+
|
|
4
|
+
const LEVEL = Symbol.for( 'level' );
|
|
5
|
+
const MESSAGE = Symbol.for( 'message' );
|
|
6
|
+
|
|
7
|
+
describe( 'logger/production', () => {
|
|
8
|
+
it( 'uses info level and default production metadata', () => {
|
|
9
|
+
expect( options.level ).toBe( 'info' );
|
|
10
|
+
expect( options.defaultMeta ).toEqual( {
|
|
11
|
+
service: 'output-worker',
|
|
12
|
+
environment: 'production'
|
|
13
|
+
} );
|
|
14
|
+
} );
|
|
15
|
+
|
|
16
|
+
it( 'formats logs as JSON with timestamp and metadata fields', () => {
|
|
17
|
+
const info = options.format.transform( {
|
|
18
|
+
[LEVEL]: 'info',
|
|
19
|
+
level: 'info',
|
|
20
|
+
message: 'Worker',
|
|
21
|
+
namespace: 'Telemetry',
|
|
22
|
+
status: { runState: 'RUNNING' }
|
|
23
|
+
} );
|
|
24
|
+
|
|
25
|
+
const output = JSON.parse( info[MESSAGE] );
|
|
26
|
+
|
|
27
|
+
expect( output ).toMatchObject( {
|
|
28
|
+
level: 'info',
|
|
29
|
+
message: 'Worker',
|
|
30
|
+
namespace: 'Telemetry',
|
|
31
|
+
status: { runState: 'RUNNING' }
|
|
32
|
+
} );
|
|
33
|
+
expect( output.timestamp ).toEqual( expect.stringMatching( /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/u ) );
|
|
34
|
+
} );
|
|
35
|
+
|
|
36
|
+
it( 'includes error stack when formatting Error messages', () => {
|
|
37
|
+
const error = new Error( 'boom' );
|
|
38
|
+
const info = options.format.transform( {
|
|
39
|
+
[LEVEL]: 'error',
|
|
40
|
+
level: 'error',
|
|
41
|
+
message: error
|
|
42
|
+
} );
|
|
43
|
+
|
|
44
|
+
const output = JSON.parse( info[MESSAGE] );
|
|
45
|
+
|
|
46
|
+
expect( output ).toMatchObject( {
|
|
47
|
+
level: 'error',
|
|
48
|
+
message: 'boom'
|
|
49
|
+
} );
|
|
50
|
+
expect( output.stack ).toContain( 'Error: boom' );
|
|
51
|
+
} );
|
|
52
|
+
} );
|
package/src/worker/configs.js
CHANGED
|
@@ -7,6 +7,7 @@ const coalesceEmptyString = v => v === '' ? undefined : v;
|
|
|
7
7
|
|
|
8
8
|
const envVarSchema = z.object( {
|
|
9
9
|
OUTPUT_CATALOG_ID: z.string().regex( /^[a-z0-9_.@-]+$/i ),
|
|
10
|
+
OUTPUT_WORKER_TELEMETRY_INTERVAL_MS: z.preprocess( coalesceEmptyString, z.coerce.number().int().nonnegative().default( 0 ) ),
|
|
10
11
|
TEMPORAL_ADDRESS: z.string().default( 'localhost:7233' ),
|
|
11
12
|
TEMPORAL_API_KEY: z.string().optional(),
|
|
12
13
|
TEMPORAL_NAMESPACE: z.string().optional().default( 'default' ),
|
|
@@ -51,6 +52,7 @@ export const maxConcurrentWorkflowTaskPolls = envVars.TEMPORAL_MAX_CONCURRENT_WO
|
|
|
51
52
|
export const namespace = envVars.TEMPORAL_NAMESPACE;
|
|
52
53
|
export const taskQueue = envVars.OUTPUT_CATALOG_ID;
|
|
53
54
|
export const catalogId = envVars.OUTPUT_CATALOG_ID;
|
|
55
|
+
export const workerTelemetryIntervalMs = envVars.OUTPUT_WORKER_TELEMETRY_INTERVAL_MS;
|
|
54
56
|
export const activityHeartbeatIntervalMs = envVars.OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS;
|
|
55
57
|
export const activityHeartbeatEnabled = envVars.OUTPUT_ACTIVITY_HEARTBEAT_ENABLED;
|
|
56
58
|
export const processFailureShutdownDelay = envVars.OUTPUT_PROCESS_FAILURE_SHUTDOWN_DELAY;
|
|
@@ -10,6 +10,7 @@ const CONFIG_KEYS = [
|
|
|
10
10
|
'TEMPORAL_MAX_CACHED_WORKFLOWS',
|
|
11
11
|
'TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_POLLS',
|
|
12
12
|
'TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_POLLS',
|
|
13
|
+
'OUTPUT_WORKER_TELEMETRY_INTERVAL_MS',
|
|
13
14
|
'OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS',
|
|
14
15
|
'OUTPUT_ACTIVITY_HEARTBEAT_ENABLED'
|
|
15
16
|
];
|
|
@@ -61,6 +62,7 @@ describe( 'worker/configs', () => {
|
|
|
61
62
|
expect( configs.maxCachedWorkflows ).toBe( 1000 );
|
|
62
63
|
expect( configs.maxConcurrentActivityTaskPolls ).toBe( 5 );
|
|
63
64
|
expect( configs.maxConcurrentWorkflowTaskPolls ).toBe( 5 );
|
|
65
|
+
expect( configs.workerTelemetryIntervalMs ).toBe( 0 );
|
|
64
66
|
expect( configs.activityHeartbeatIntervalMs ).toBe( 2 * 60 * 1000 );
|
|
65
67
|
expect( configs.activityHeartbeatEnabled ).toBe( true );
|
|
66
68
|
expect( configs.taskQueue ).toBe( 'test-catalog' );
|
|
@@ -74,11 +76,19 @@ describe( 'worker/configs', () => {
|
|
|
74
76
|
expect( configs.maxConcurrentActivityTaskExecutions ).toBe( 40 );
|
|
75
77
|
} );
|
|
76
78
|
|
|
79
|
+
it( 'treats empty string for worker telemetry interval as default', async () => {
|
|
80
|
+
setEnv( { OUTPUT_WORKER_TELEMETRY_INTERVAL_MS: '' } );
|
|
81
|
+
const configs = await loadConfigs();
|
|
82
|
+
|
|
83
|
+
expect( configs.workerTelemetryIntervalMs ).toBe( 0 );
|
|
84
|
+
} );
|
|
85
|
+
|
|
77
86
|
it( 'parses custom numeric env vars', async () => {
|
|
78
87
|
setEnv( {
|
|
79
88
|
TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_EXECUTIONS: '10',
|
|
80
89
|
TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_EXECUTIONS: '50',
|
|
81
90
|
TEMPORAL_MAX_CACHED_WORKFLOWS: '500',
|
|
91
|
+
OUTPUT_WORKER_TELEMETRY_INTERVAL_MS: '30000',
|
|
82
92
|
OUTPUT_ACTIVITY_HEARTBEAT_INTERVAL_MS: '60000'
|
|
83
93
|
} );
|
|
84
94
|
const configs = await loadConfigs();
|
|
@@ -86,9 +96,24 @@ describe( 'worker/configs', () => {
|
|
|
86
96
|
expect( configs.maxConcurrentActivityTaskExecutions ).toBe( 10 );
|
|
87
97
|
expect( configs.maxConcurrentWorkflowTaskExecutions ).toBe( 50 );
|
|
88
98
|
expect( configs.maxCachedWorkflows ).toBe( 500 );
|
|
99
|
+
expect( configs.workerTelemetryIntervalMs ).toBe( 30000 );
|
|
89
100
|
expect( configs.activityHeartbeatIntervalMs ).toBe( 60000 );
|
|
90
101
|
} );
|
|
91
102
|
|
|
103
|
+
it( 'allows zero for worker telemetry interval', async () => {
|
|
104
|
+
setEnv( { OUTPUT_WORKER_TELEMETRY_INTERVAL_MS: '0' } );
|
|
105
|
+
const configs = await loadConfigs();
|
|
106
|
+
|
|
107
|
+
expect( configs.workerTelemetryIntervalMs ).toBe( 0 );
|
|
108
|
+
} );
|
|
109
|
+
|
|
110
|
+
it( 'throws when worker telemetry interval is negative', async () => {
|
|
111
|
+
setEnv( { OUTPUT_WORKER_TELEMETRY_INTERVAL_MS: '-1' } );
|
|
112
|
+
vi.resetModules();
|
|
113
|
+
|
|
114
|
+
await expect( import( './configs.js' ) ).rejects.toThrow();
|
|
115
|
+
} );
|
|
116
|
+
|
|
92
117
|
it( 'throws when optional number is zero or negative', async () => {
|
|
93
118
|
setEnv( { TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_EXECUTIONS: '0' } );
|
|
94
119
|
vi.resetModules();
|
package/src/worker/index.js
CHANGED
|
@@ -11,9 +11,10 @@ import { registerShutdown } from './shutdown.js';
|
|
|
11
11
|
import { startCatalog } from './start_catalog.js';
|
|
12
12
|
import { bootstrapFetchProxy } from './proxy.js';
|
|
13
13
|
import { messageBus } from '#bus';
|
|
14
|
-
import './log_hooks.js';
|
|
15
14
|
import { BusEventType } from '#consts';
|
|
16
15
|
import { hashSourceCode } from './loader_tools.js';
|
|
16
|
+
import { setupTelemetry } from './setup_telemetry.js';
|
|
17
|
+
import './log_hooks.js';
|
|
17
18
|
|
|
18
19
|
const log = createChildLogger( 'Worker' );
|
|
19
20
|
|
|
@@ -84,6 +85,8 @@ const callerDir = process.argv[2];
|
|
|
84
85
|
|
|
85
86
|
registerShutdown( { worker, log } );
|
|
86
87
|
|
|
88
|
+
setupTelemetry( { worker } );
|
|
89
|
+
|
|
87
90
|
log.info( 'Running worker...' );
|
|
88
91
|
await Promise.all( [ worker.run(), startCatalog( { connection, namespace, catalog, catalogHash } ) ] );
|
|
89
92
|
|
package/src/worker/index.spec.js
CHANGED
|
@@ -62,6 +62,9 @@ vi.mock( './proxy.js', () => ( { bootstrapFetchProxy: bootstrapFetchProxyMock }
|
|
|
62
62
|
const registerShutdownMock = vi.fn();
|
|
63
63
|
vi.mock( './shutdown.js', () => ( { registerShutdown: registerShutdownMock } ) );
|
|
64
64
|
|
|
65
|
+
const setupTelemetryMock = vi.fn();
|
|
66
|
+
vi.mock( './setup_telemetry.js', () => ( { setupTelemetry: setupTelemetryMock } ) );
|
|
67
|
+
|
|
65
68
|
vi.mock( './log_hooks.js', () => ( {} ) );
|
|
66
69
|
|
|
67
70
|
const runState = { resolve: null };
|
|
@@ -129,6 +132,7 @@ describe( 'worker/index', () => {
|
|
|
129
132
|
} ) );
|
|
130
133
|
expect( initInterceptorsMock ).toHaveBeenCalledWith( { activities: {}, workflows: [], connection: mockConnection } );
|
|
131
134
|
expect( registerShutdownMock ).toHaveBeenCalledWith( { worker: mockWorker, log: mockLog } );
|
|
135
|
+
expect( setupTelemetryMock ).toHaveBeenCalledWith( { worker: mockWorker } );
|
|
132
136
|
expect( startCatalogMock ).toHaveBeenCalledWith( {
|
|
133
137
|
connection: mockConnection,
|
|
134
138
|
namespace: configValues.namespace,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createChildLogger } from '#logger';
|
|
2
|
+
import { workerTelemetryIntervalMs } from './configs.js';
|
|
3
|
+
|
|
4
|
+
const log = createChildLogger( 'Telemetry' );
|
|
5
|
+
|
|
6
|
+
export const setupTelemetry = ( { worker } ) => {
|
|
7
|
+
if ( workerTelemetryIntervalMs > 0 ) {
|
|
8
|
+
setInterval( () => {
|
|
9
|
+
log.info( 'Worker', {
|
|
10
|
+
status: worker.getStatus(),
|
|
11
|
+
memory: {
|
|
12
|
+
availableMemory: process.availableMemory(),
|
|
13
|
+
constrainedMemory: process.constrainedMemory(),
|
|
14
|
+
memoryUsage: process.memoryUsage()
|
|
15
|
+
}
|
|
16
|
+
} );
|
|
17
|
+
}, workerTelemetryIntervalMs ).unref();
|
|
18
|
+
}
|
|
19
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const configMock = vi.hoisted( () => ( { workerTelemetryIntervalMs: 0 } ) );
|
|
4
|
+
const logMock = vi.hoisted( () => ( { info: vi.fn() } ) );
|
|
5
|
+
const createChildLoggerMock = vi.hoisted( () => vi.fn( () => logMock ) );
|
|
6
|
+
|
|
7
|
+
vi.mock( './configs.js', () => ( {
|
|
8
|
+
get workerTelemetryIntervalMs() {
|
|
9
|
+
return configMock.workerTelemetryIntervalMs;
|
|
10
|
+
}
|
|
11
|
+
} ) );
|
|
12
|
+
|
|
13
|
+
vi.mock( '#logger', () => ( { createChildLogger: createChildLoggerMock } ) );
|
|
14
|
+
|
|
15
|
+
const loadSetupTelemetry = async () => {
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
return import( './setup_telemetry.js' );
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const mockSetInterval = unrefMock =>
|
|
21
|
+
vi.spyOn( globalThis, 'setInterval' ).mockReturnValue( { unref: unrefMock } );
|
|
22
|
+
|
|
23
|
+
describe( 'worker/setup_telemetry', () => {
|
|
24
|
+
const availableMemoryMock = vi.fn();
|
|
25
|
+
const constrainedMemoryMock = vi.fn();
|
|
26
|
+
const memoryUsageMock = vi.fn();
|
|
27
|
+
const unrefMock = vi.fn();
|
|
28
|
+
|
|
29
|
+
beforeEach( () => {
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
configMock.workerTelemetryIntervalMs = 0;
|
|
32
|
+
|
|
33
|
+
availableMemoryMock.mockReturnValue( 1_000 );
|
|
34
|
+
constrainedMemoryMock.mockReturnValue( 2_000 );
|
|
35
|
+
memoryUsageMock.mockReturnValue( { heapUsed: 300 } );
|
|
36
|
+
|
|
37
|
+
vi.spyOn( process, 'availableMemory' ).mockImplementation( availableMemoryMock );
|
|
38
|
+
vi.spyOn( process, 'constrainedMemory' ).mockImplementation( constrainedMemoryMock );
|
|
39
|
+
vi.spyOn( process, 'memoryUsage' ).mockImplementation( memoryUsageMock );
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
afterEach( () => {
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
} );
|
|
45
|
+
|
|
46
|
+
it( 'does not create an interval when telemetry interval is disabled', async () => {
|
|
47
|
+
const setIntervalMock = mockSetInterval( unrefMock );
|
|
48
|
+
const { setupTelemetry } = await loadSetupTelemetry();
|
|
49
|
+
|
|
50
|
+
setupTelemetry( { worker: { getStatus: vi.fn() } } );
|
|
51
|
+
|
|
52
|
+
expect( setIntervalMock ).not.toHaveBeenCalled();
|
|
53
|
+
expect( logMock.info ).not.toHaveBeenCalled();
|
|
54
|
+
} );
|
|
55
|
+
|
|
56
|
+
it( 'logs worker status and memory on the configured interval', async () => {
|
|
57
|
+
const setIntervalMock = mockSetInterval( unrefMock );
|
|
58
|
+
configMock.workerTelemetryIntervalMs = 5_000;
|
|
59
|
+
const worker = { getStatus: vi.fn().mockReturnValue( { runState: 'RUNNING' } ) };
|
|
60
|
+
const { setupTelemetry } = await loadSetupTelemetry();
|
|
61
|
+
|
|
62
|
+
setupTelemetry( { worker } );
|
|
63
|
+
|
|
64
|
+
expect( createChildLoggerMock ).toHaveBeenCalledWith( 'Telemetry' );
|
|
65
|
+
expect( setIntervalMock ).toHaveBeenCalledWith( expect.any( Function ), 5_000 );
|
|
66
|
+
expect( unrefMock ).toHaveBeenCalled();
|
|
67
|
+
|
|
68
|
+
const [ callback ] = setIntervalMock.mock.calls[0];
|
|
69
|
+
callback();
|
|
70
|
+
|
|
71
|
+
expect( logMock.info ).toHaveBeenCalledWith( 'Worker', {
|
|
72
|
+
status: { runState: 'RUNNING' },
|
|
73
|
+
memory: {
|
|
74
|
+
availableMemory: 1_000,
|
|
75
|
+
constrainedMemory: 2_000,
|
|
76
|
+
memoryUsage: { heapUsed: 300 }
|
|
77
|
+
}
|
|
78
|
+
} );
|
|
79
|
+
} );
|
|
80
|
+
} );
|
package/src/logger.js
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import winston from 'winston';
|
|
2
|
-
import { shuffleArray } from '#utils';
|
|
3
|
-
|
|
4
|
-
const isProduction = process.env.NODE_ENV === 'production';
|
|
5
|
-
|
|
6
|
-
const levels = {
|
|
7
|
-
error: 0,
|
|
8
|
-
warn: 1,
|
|
9
|
-
info: 2,
|
|
10
|
-
http: 3,
|
|
11
|
-
debug: 4
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const colors = shuffleArray( [
|
|
15
|
-
'033', // blue
|
|
16
|
-
'030', // green
|
|
17
|
-
'208', // orange
|
|
18
|
-
'045', // turquoise
|
|
19
|
-
'129', // purple
|
|
20
|
-
'184' // yellow
|
|
21
|
-
] );
|
|
22
|
-
const assignedColors = new Map();
|
|
23
|
-
|
|
24
|
-
// Format metadata as friendly JSON: "{ name: "foo", count: 5 }"
|
|
25
|
-
const formatMeta = obj => {
|
|
26
|
-
const entries = Object.entries( obj );
|
|
27
|
-
if ( !entries.length ) {
|
|
28
|
-
return '';
|
|
29
|
-
}
|
|
30
|
-
return ' { ' + entries.map( ( [ k, v ] ) => `${k}: ${JSON.stringify( v )}` ).join( ', ' ) + ' }';
|
|
31
|
-
};
|
|
32
|
-
// Distribute the namespace in a map and assign it the next available color
|
|
33
|
-
const getColor = v =>
|
|
34
|
-
assignedColors.has( v ) ? assignedColors.get( v ) : assignedColors.set( v, colors[assignedColors.size % colors.length] ).get( v );
|
|
35
|
-
|
|
36
|
-
// Colorize a text using the namespace string
|
|
37
|
-
const colorizeByNamespace = ( namespace, text ) => `\x1b[38;5;${getColor( namespace )}m${text}\x1b[0m`;
|
|
38
|
-
|
|
39
|
-
// Development format: colorized with namespace prefix
|
|
40
|
-
const devFormat = winston.format.combine(
|
|
41
|
-
winston.format.colorize(),
|
|
42
|
-
winston.format.printf( ( { level, message, namespace, service: _, environment: __, ...rest } ) => {
|
|
43
|
-
const ns = 'Core' + ( namespace ? `.${namespace}` : '' );
|
|
44
|
-
const meta = formatMeta( rest );
|
|
45
|
-
return `[${level}] ${colorizeByNamespace( ns, `${namespace}: ${message}` )}${meta}`;
|
|
46
|
-
} )
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
// Production format: structured JSON
|
|
50
|
-
const prodFormat = winston.format.combine(
|
|
51
|
-
winston.format.timestamp( { format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' } ),
|
|
52
|
-
winston.format.errors( { stack: true } ),
|
|
53
|
-
winston.format.json()
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
export const logger = winston.createLogger( {
|
|
57
|
-
levels,
|
|
58
|
-
level: isProduction ? 'info' : 'debug',
|
|
59
|
-
format: isProduction ? prodFormat : devFormat,
|
|
60
|
-
defaultMeta: {
|
|
61
|
-
service: 'output-worker',
|
|
62
|
-
environment: process.env.NODE_ENV || 'development'
|
|
63
|
-
},
|
|
64
|
-
transports: [ new winston.transports.Console() ]
|
|
65
|
-
} );
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Creates a child logger with a specific namespace
|
|
69
|
-
*
|
|
70
|
-
* @param {string} namespace - The namespace for this logger (e.g., 'Scanner', 'Tracing')
|
|
71
|
-
* @returns {winston.Logger} Child logger instance with namespace metadata
|
|
72
|
-
*/
|
|
73
|
-
export const createChildLogger = namespace => logger.child( { namespace } );
|