@outputai/core 0.15.1-dev.46e7530.0 → 0.15.1-next.2032ff6.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 +12 -4
- package/src/helpers/fetch.js +21 -9
- package/src/helpers/redact.js +2 -1
- package/src/http/config.js +10 -0
- package/src/http/cost.d.ts +20 -0
- package/src/http/cost.js +41 -0
- package/src/http/fetch/events.js +10 -0
- package/src/http/fetch/index.d.ts +26 -0
- package/src/http/fetch/index.js +78 -0
- package/src/http/fetch/logger.d.ts +10 -0
- package/src/http/fetch/logger.js +56 -0
- package/src/http/index.d.ts +27 -0
- package/src/http/index.js +9 -0
- package/src/http/ky/index.d.ts +25 -0
- package/src/http/ky/index.js +10 -0
- package/src/http/request_tag.js +30 -0
- package/src/tracing/trace_attribute.d.ts +0 -17
- package/src/tracing/trace_attribute.js +1 -29
- package/src/worker/configs.js +23 -4
- package/src/worker/index.js +16 -47
- package/src/worker/interceptors/activity.js +0 -1
- package/src/worker/interceptors/workflow.js +0 -1
- package/src/worker/telemetry.js +122 -2
- package/src/worker/worker_options.js +43 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/core",
|
|
3
|
-
"version": "0.15.1-
|
|
3
|
+
"version": "0.15.1-next.2032ff6.0",
|
|
4
4
|
"description": "The core module of the output framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"./credentials": {
|
|
31
31
|
"types": "./dist/credentials/index.d.ts",
|
|
32
32
|
"import": "./dist/credentials/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./http": {
|
|
35
|
+
"types": "./src/http/index.d.ts",
|
|
36
|
+
"import": "./src/http/index.js"
|
|
33
37
|
}
|
|
34
38
|
},
|
|
35
39
|
"files": [
|
|
@@ -63,15 +67,18 @@
|
|
|
63
67
|
"json-stream-stringify": "3.1.7",
|
|
64
68
|
"redis": "5.12.1",
|
|
65
69
|
"stacktrace-parser": "0.1.11",
|
|
66
|
-
"undici": "8.11.0",
|
|
67
70
|
"winston": "3.19.0"
|
|
68
71
|
},
|
|
69
72
|
"peerDependencies": {
|
|
70
|
-
"zod": ">=4.3 <5"
|
|
73
|
+
"zod": ">=4.3 <5",
|
|
74
|
+
"ky": ">=2 <3",
|
|
75
|
+
"undici": ">=8 <9"
|
|
71
76
|
},
|
|
72
77
|
"devDependencies": {
|
|
73
78
|
"@types/js-yaml": "4.0.9",
|
|
74
|
-
"zod": "4.6.5"
|
|
79
|
+
"zod": "4.6.5",
|
|
80
|
+
"ky": "2.0.2",
|
|
81
|
+
"undici": "8.11.0"
|
|
75
82
|
},
|
|
76
83
|
"license": "Apache-2.0",
|
|
77
84
|
"publishConfig": {
|
|
@@ -83,6 +90,7 @@
|
|
|
83
90
|
"#credentials/*": "./dist/credentials/*.js",
|
|
84
91
|
"#errors": "./src/errors.js",
|
|
85
92
|
"#logger": "./src/logger/index.js",
|
|
93
|
+
"#runtime_logger": "./src/interface/logger.js",
|
|
86
94
|
"#hooks/*": "./src/hooks/*.js",
|
|
87
95
|
"#helpers/*": "./src/helpers/*.js",
|
|
88
96
|
"#tracing": "./src/tracing/internal_interface.js",
|
package/src/helpers/fetch.js
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
import { FatalError } from '#errors';
|
|
2
2
|
import { redactHeaders } from './redact.js';
|
|
3
3
|
|
|
4
|
+
const jsonMatcher = /^application\/(?:json|[^;\s]+?\+json)(?:\s*;.*)?$/i;
|
|
5
|
+
const textMatcher = /^(?:text\/|application\/(?:xml|[^;\s]+?\+xml|x-www-form-urlencoded|javascript)(?:\s*;|$))/i;
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
|
-
* Consume the body of a Response according it its content-type and returns it
|
|
8
|
+
* Consume the body of a Response according it its content-type and returns it:
|
|
9
|
+
* - JSON content-type: parsed JSON, or the raw text if the body is empty or not valid JSON
|
|
10
|
+
* - text-like content-type: the text as-is
|
|
11
|
+
* - anything else: the body bytes as a base64 string
|
|
6
12
|
* @param {Response} response
|
|
7
|
-
* @returns {string|object|
|
|
13
|
+
* @returns {string|object|null} The response body content
|
|
8
14
|
*/
|
|
9
|
-
const consumeBody = async response => {
|
|
10
|
-
const
|
|
11
|
-
const contentType = ( headers['content-type'] ?? '' ).trim().toLowerCase();
|
|
12
|
-
const jsonMatcher = /^application\/(?:json|[^;\s]+?\+json)(?:\s*;.*)?$/i;
|
|
15
|
+
export const consumeBody = async response => {
|
|
16
|
+
const contentType = ( response.headers.get( 'content-type' ) ?? '' ).trim();
|
|
13
17
|
if ( jsonMatcher.test( contentType ) ) {
|
|
14
|
-
|
|
18
|
+
const text = await response.text();
|
|
19
|
+
if ( text.length === 0 ) {
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse( text );
|
|
24
|
+
} catch {
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
15
27
|
}
|
|
16
|
-
if (
|
|
28
|
+
if ( textMatcher.test( contentType ) ) {
|
|
17
29
|
return response.text();
|
|
18
30
|
}
|
|
19
31
|
return response.arrayBuffer().then( buf => Buffer.from( buf ).toString( 'base64' ) );
|
|
@@ -33,7 +45,7 @@ export const serializeResponse = async ( response, { includeHeaders = false, inc
|
|
|
33
45
|
status: response.status,
|
|
34
46
|
statusText: response.statusText,
|
|
35
47
|
ok: response.ok,
|
|
36
|
-
...( includeHeaders && { headers: redactHeaders(
|
|
48
|
+
...( includeHeaders && { headers: redactHeaders( response.headers ) } ),
|
|
37
49
|
...( includeBody && { body: await consumeBody( response ) } )
|
|
38
50
|
} );
|
|
39
51
|
|
package/src/helpers/redact.js
CHANGED
|
@@ -43,7 +43,8 @@ export const redactHeaders = headers => {
|
|
|
43
43
|
wordEndMatcher( 'key' )
|
|
44
44
|
];
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
const plainHeaders = Object.fromEntries( headers );
|
|
47
|
+
return Object.entries( plainHeaders ).reduce( ( redacted, [ key, value ] ) => {
|
|
47
48
|
const lowKey = key.toLowerCase();
|
|
48
49
|
const isSensitive = !ignoreHeaders.has( lowKey ) && sensitiveHeadersPatterns.some( rx => rx.test( lowKey ) );
|
|
49
50
|
return Object.assign( redacted, { [key]: isSensitive ? '[REDACTED]' : value } );
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for HTTP client behavior
|
|
3
|
+
*/
|
|
4
|
+
export const config = {
|
|
5
|
+
/**
|
|
6
|
+
* Whether to log verbose HTTP information (headers and bodies)
|
|
7
|
+
* Controlled by OUTPUT_TRACE_HTTP_VERBOSE environment variable
|
|
8
|
+
*/
|
|
9
|
+
logVerbose: [ '1', 'true' ].includes( process.env.OUTPUT_TRACE_HTTP_VERBOSE )
|
|
10
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { KyResponse } from 'ky';
|
|
2
|
+
import { Attribute } from '#trace_attribute';
|
|
3
|
+
|
|
4
|
+
/** Trace attribute (`http:request:cost`) holding the cost attached to an HTTP request with `addRequestCost` */
|
|
5
|
+
export declare class HTTPRequestCost extends Attribute.BaseAttribute {
|
|
6
|
+
static TYPE: 'http:request:cost';
|
|
7
|
+
type: typeof HTTPRequestCost.TYPE;
|
|
8
|
+
url: string;
|
|
9
|
+
requestId: string;
|
|
10
|
+
total: number;
|
|
11
|
+
constructor( url: string, requestId: string, total: number );
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Attach cost information to the trace of an HTTP Request using the response
|
|
16
|
+
*
|
|
17
|
+
* @param response - The response of the HTTP Request to attach the information
|
|
18
|
+
* @param value - The price of the HTTP request
|
|
19
|
+
*/
|
|
20
|
+
export declare function addRequestCost( response: KyResponse | Response, value: number ): void;
|
package/src/http/cost.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { stepEventBus } from '#bus';
|
|
2
|
+
import { requestIdSymbol } from './request_tag.js';
|
|
3
|
+
import { Logger } from '#runtime_logger';
|
|
4
|
+
import { addEventActionWithContext, EventAction } from '#tracing';
|
|
5
|
+
import { BaseAttribute } from '#trace_attribute';
|
|
6
|
+
|
|
7
|
+
export class HTTPRequestCost extends BaseAttribute {
|
|
8
|
+
static TYPE = 'http:request:cost';
|
|
9
|
+
url;
|
|
10
|
+
requestId;
|
|
11
|
+
total = 0;
|
|
12
|
+
|
|
13
|
+
constructor( url, requestId, total ) {
|
|
14
|
+
super( HTTPRequestCost.TYPE );
|
|
15
|
+
this.url = url;
|
|
16
|
+
this.requestId = requestId;
|
|
17
|
+
this.total = total;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Attach cost information to the trace of an HTTP Request using the response
|
|
23
|
+
*
|
|
24
|
+
* @param {Response} response - The response of the HTTP Request to attach the information
|
|
25
|
+
* @param {number} value - The price of the HTTP request
|
|
26
|
+
* @returns {void}
|
|
27
|
+
*/
|
|
28
|
+
export const addRequestCost = ( response, value ) => {
|
|
29
|
+
const eventId = Reflect.get( response, requestIdSymbol );
|
|
30
|
+
if ( !eventId ) {
|
|
31
|
+
Logger.warn(
|
|
32
|
+
'addRequestCost(): The "response" argument did not originate from @outputai/core/http, no costs were added.',
|
|
33
|
+
{ namespace: 'HTTP' }
|
|
34
|
+
);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const attribute = new HTTPRequestCost( response.url, eventId, value );
|
|
39
|
+
addEventActionWithContext( EventAction.ADD_ATTR, { id: eventId, details: attribute } );
|
|
40
|
+
stepEventBus.emit( 'sdk:cost:http:request', attribute );
|
|
41
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { stepEventBus } from '#bus';
|
|
2
|
+
|
|
3
|
+
export const emitError = ( { requestId, method, url, status, durationMs } ) =>
|
|
4
|
+
stepEventBus.emit( 'sdk:http:request', { requestId, method, url, status, durationMs, outcome: 'error' } );
|
|
5
|
+
|
|
6
|
+
export const emitSuccess = ( { requestId, method, url, status, durationMs } ) =>
|
|
7
|
+
stepEventBus.emit( 'sdk:http:request', { requestId, method, url, status, durationMs, outcome: 'success' } );
|
|
8
|
+
|
|
9
|
+
export const emitFailure = ( { requestId, method, url, durationMs } ) =>
|
|
10
|
+
stepEventBus.emit( 'sdk:http:request', { requestId, method, url, status: undefined, durationMs, outcome: 'failure' } );
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type * as undici from 'undici';
|
|
2
|
+
|
|
3
|
+
type NodeRequestInfo = string | URL | globalThis.Request;
|
|
4
|
+
type NodeRequestInit = globalThis.RequestInit & Pick<undici.RequestInit, 'dispatcher'>;
|
|
5
|
+
|
|
6
|
+
type OutputFetch = {
|
|
7
|
+
( input: NodeRequestInfo, init?: NodeRequestInit ): Promise<Response>;
|
|
8
|
+
( input: undici.RequestInfo, init?: undici.RequestInit ): Promise<Response>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A fetch compliant function, that wraps undici's fetch.
|
|
13
|
+
*
|
|
14
|
+
* Behaves the same as any fetch function except:
|
|
15
|
+
* - Sets a request header called `x-request-trace-id` with a random UUID;
|
|
16
|
+
* - Sends the request, response, error and/or failure to the Trace system;
|
|
17
|
+
* - Emits a `http:request` event on every call (success, error, failure).
|
|
18
|
+
*
|
|
19
|
+
* Node and Undici realms can't be mixed: passing a Node `Request` with an Undici `FormData` body (or vice versa) throws a `TypeError`.
|
|
20
|
+
*
|
|
21
|
+
* @see {@link https://fetch.spec.whatwg.org/}
|
|
22
|
+
* @param input - URL string, URL object or Request object (undici's or Node's)
|
|
23
|
+
* @param init - Request options
|
|
24
|
+
* @returns The HTTP response
|
|
25
|
+
*/
|
|
26
|
+
export declare const outputFetch: OutputFetch;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { logRequest, logResponse, logError, logFailure } from './logger.js';
|
|
3
|
+
import { emitSuccess, emitError, emitFailure } from './events.js';
|
|
4
|
+
import { addRequestIdToResponse } from '../request_tag.js';
|
|
5
|
+
import * as undici from 'undici';
|
|
6
|
+
|
|
7
|
+
/* Ignore HTTP/2. Check: https://github.com/growthxai/output/issues/299 */
|
|
8
|
+
const customDispatcher = new undici.EnvHttpProxyAgent( { allowH2: false } );
|
|
9
|
+
|
|
10
|
+
const createUndiciRequest = ( input, init ) => {
|
|
11
|
+
const isNodeRequest = input instanceof globalThis.Request;
|
|
12
|
+
const hasNodeFormData = init?.body instanceof globalThis.FormData;
|
|
13
|
+
const isUndiciRequest = input instanceof undici.Request;
|
|
14
|
+
const hasUndiciFormData = init?.body instanceof undici.FormData;
|
|
15
|
+
|
|
16
|
+
if ( ( isNodeRequest && hasUndiciFormData ) || ( isUndiciRequest && hasNodeFormData ) ) {
|
|
17
|
+
throw new TypeError( 'Cannot mix Node and Undici Request/FormData realms.' );
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if ( !isNodeRequest && !hasNodeFormData ) {
|
|
21
|
+
return new undici.Request( input, init );
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const request = new globalThis.Request( input, init );
|
|
25
|
+
return new undici.Request( request.url, request );
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A fetch compliant function, that wraps undici's fetch.
|
|
30
|
+
*
|
|
31
|
+
* @param {RequestInfo} input - URL string, URL object or Request object (undici's or Node's)
|
|
32
|
+
* @param {RequestInit} [init] - Request options
|
|
33
|
+
* @returns {Promise<Response>} The HTTP response
|
|
34
|
+
*/
|
|
35
|
+
export const outputFetch = async ( input, init ) => {
|
|
36
|
+
const { dispatcher: inputDispatcher, ...requestInit } = init ?? {};
|
|
37
|
+
|
|
38
|
+
// Creates a Request object with the many shapes RequestInfo can have
|
|
39
|
+
const base = createUndiciRequest( input, requestInit );
|
|
40
|
+
|
|
41
|
+
// Creates a headers object with the many shapes Request.Headers can have (object, array, Headers)
|
|
42
|
+
const headers = new undici.Headers( base.headers );
|
|
43
|
+
|
|
44
|
+
const requestId = randomUUID();
|
|
45
|
+
headers.set( 'x-request-trace-id', requestId );
|
|
46
|
+
const request = new undici.Request( base, { headers } );
|
|
47
|
+
|
|
48
|
+
const method = request.method;
|
|
49
|
+
const url = request.url;
|
|
50
|
+
const startedAt = Date.now();
|
|
51
|
+
|
|
52
|
+
await logRequest( { requestId, request } );
|
|
53
|
+
|
|
54
|
+
// this allows for users not only to override the dispatcher but also to define it as undefined and remove it altogether.
|
|
55
|
+
const dispatcher = Object.hasOwn( init ?? {}, 'dispatcher' ) ? inputDispatcher : customDispatcher;
|
|
56
|
+
try {
|
|
57
|
+
const response = await undici.fetch( request, dispatcher ? { dispatcher } : undefined );
|
|
58
|
+
const durationMs = Date.now() - startedAt;
|
|
59
|
+
const { status } = response;
|
|
60
|
+
|
|
61
|
+
// This enriches the response of the request id, so it is identifiable later.
|
|
62
|
+
addRequestIdToResponse( response, requestId );
|
|
63
|
+
|
|
64
|
+
if ( status > 399 ) {
|
|
65
|
+
await logError( { requestId, response } );
|
|
66
|
+
emitError( { requestId, method, url, status, durationMs } );
|
|
67
|
+
return response;
|
|
68
|
+
}
|
|
69
|
+
await logResponse( { requestId, response } );
|
|
70
|
+
emitSuccess( { requestId, method, url, status, durationMs } );
|
|
71
|
+
return response;
|
|
72
|
+
} catch ( error ) {
|
|
73
|
+
const durationMs = Date.now() - startedAt;
|
|
74
|
+
logFailure( { requestId, error } );
|
|
75
|
+
emitFailure( { requestId, method, url, durationMs } );
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Attribute } from '#trace_attribute';
|
|
2
|
+
|
|
3
|
+
/** Trace attribute (`http:request:count`) added to every HTTP request made with `outputFetch` or `createKyClient` */
|
|
4
|
+
export declare class HTTPRequestCount extends Attribute.BaseAttribute {
|
|
5
|
+
static TYPE: 'http:request:count';
|
|
6
|
+
type: typeof HTTPRequestCount.TYPE;
|
|
7
|
+
url: string;
|
|
8
|
+
requestId: string;
|
|
9
|
+
constructor( url: string, requestId: string );
|
|
10
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { addEventActionWithContext, EventAction } from '#tracing';
|
|
2
|
+
import { BaseAttribute } from '#trace_attribute';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { redactHeaders } from '#helpers/redact';
|
|
5
|
+
import { consumeBody } from '#helpers/fetch';
|
|
6
|
+
|
|
7
|
+
export class HTTPRequestCount extends BaseAttribute {
|
|
8
|
+
static TYPE = 'http:request:count';
|
|
9
|
+
url;
|
|
10
|
+
requestId;
|
|
11
|
+
|
|
12
|
+
constructor( url, requestId ) {
|
|
13
|
+
super( HTTPRequestCount.TYPE );
|
|
14
|
+
this.url = url;
|
|
15
|
+
this.requestId = requestId;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sends the trace start event for an http request
|
|
21
|
+
*
|
|
22
|
+
* @param options
|
|
23
|
+
* @param options.requestId - id of the request
|
|
24
|
+
* @param options.request - The HTTP Request object
|
|
25
|
+
*/
|
|
26
|
+
export const logRequest = async ( { requestId, request } ) => {
|
|
27
|
+
addEventActionWithContext( EventAction.START, {
|
|
28
|
+
id: requestId, kind: 'http', name: 'request', details: {
|
|
29
|
+
method: request.method,
|
|
30
|
+
url: request.url,
|
|
31
|
+
...( config.logVerbose && { headers: redactHeaders( request.headers ), body: await consumeBody( request.clone() ) } )
|
|
32
|
+
}
|
|
33
|
+
} );
|
|
34
|
+
addEventActionWithContext( EventAction.ADD_ATTR, { id: requestId, details: new HTTPRequestCount( request.url, requestId ) } );
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const logError = async ( { requestId, response } ) =>
|
|
38
|
+
addEventActionWithContext( EventAction.ERROR, {
|
|
39
|
+
id: requestId, details: {
|
|
40
|
+
status: response.status,
|
|
41
|
+
statusText: response.statusText,
|
|
42
|
+
...( config.logVerbose && { headers: redactHeaders( response.headers ), body: await consumeBody( response.clone() ) } )
|
|
43
|
+
}
|
|
44
|
+
} );
|
|
45
|
+
|
|
46
|
+
export const logResponse = async ( { requestId, response } ) =>
|
|
47
|
+
addEventActionWithContext( EventAction.END, {
|
|
48
|
+
id: requestId, details: {
|
|
49
|
+
status: response.status,
|
|
50
|
+
statusText: response.statusText,
|
|
51
|
+
...( config.logVerbose && { headers: redactHeaders( response.headers ), body: await consumeBody( response.clone() ) } )
|
|
52
|
+
}
|
|
53
|
+
} );
|
|
54
|
+
|
|
55
|
+
export const logFailure = ( { requestId, error } ) =>
|
|
56
|
+
addEventActionWithContext( EventAction.ERROR, { id: requestId, details: error } );
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Payload of the `http:request` event */
|
|
2
|
+
export type HttpRequestEvent = {
|
|
3
|
+
requestId: string;
|
|
4
|
+
method: string;
|
|
5
|
+
url: string;
|
|
6
|
+
status: number | undefined;
|
|
7
|
+
durationMs: number;
|
|
8
|
+
outcome: 'success' | 'error' | 'failure';
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Payload of the `cost:http:request` event */
|
|
12
|
+
export type HttpRequestCostEvent = {
|
|
13
|
+
type: 'http:request:cost';
|
|
14
|
+
requestId: string;
|
|
15
|
+
url: string;
|
|
16
|
+
total: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export { outputFetch } from './fetch/index.js';
|
|
20
|
+
export { HTTPRequestCount } from './fetch/logger.js';
|
|
21
|
+
export { createKyClient } from './ky/index.js';
|
|
22
|
+
|
|
23
|
+
export { addRequestCost, HTTPRequestCost } from './cost.js';
|
|
24
|
+
|
|
25
|
+
/** Re-export ky library for convenience. */
|
|
26
|
+
export * as ky from 'ky';
|
|
27
|
+
export * as undici from 'undici';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { outputFetch } from './fetch/index.js';
|
|
2
|
+
export { HTTPRequestCount } from './fetch/logger.js';
|
|
3
|
+
export { createKyClient } from './ky/index.js';
|
|
4
|
+
|
|
5
|
+
export { addRequestCost, HTTPRequestCost } from './cost.js';
|
|
6
|
+
|
|
7
|
+
/** Re-export ky library for convenience. */
|
|
8
|
+
export * as ky from 'ky';
|
|
9
|
+
export * as undici from 'undici';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { KyInstance, Options } from 'ky';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a ky client.
|
|
5
|
+
*
|
|
6
|
+
* This client uses a custom fetch that introduces hooks to integrate with Output.ai tracing.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { createKyClient } from '@outputai/core/http';
|
|
11
|
+
*
|
|
12
|
+
* const client = createKyClient({
|
|
13
|
+
* prefix: 'https://api.example.com',
|
|
14
|
+
* timeout: 30000,
|
|
15
|
+
* retry: { limit: 3 }
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* const response = await client.get('users/1');
|
|
19
|
+
* const data = await response.json();
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @param options - The ky options to extend the base client.
|
|
23
|
+
* @returns A ky instance extended with Output.ai tracing hooks.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createKyClient( options?: Options ): KyInstance;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import ky from 'ky';
|
|
2
|
+
import { outputFetch } from '../fetch/index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a ky client that uses `outputFetch`.
|
|
6
|
+
*
|
|
7
|
+
* @param {import('ky').Options} options - The ky options to extend the base client.
|
|
8
|
+
* @returns {import('ky').KyInstance} A ky instance extended with Output.ai tracing hooks.
|
|
9
|
+
*/
|
|
10
|
+
export const createKyClient = ( options = {} ) => ky.create( { fetch: outputFetch, ...options } );
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol used to store request id in the response object.
|
|
3
|
+
*/
|
|
4
|
+
export const requestIdSymbol = Symbol( 'request_id' );
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tag a response in place with its request id so downstream code (e.g.
|
|
8
|
+
* `addRequestCost`) can correlate. Stores the id under a private symbol AND
|
|
9
|
+
* patches `clone()` so the tag propagates to clones — ky clones the response
|
|
10
|
+
* before invoking `afterResponse` hooks, and undici headers are immutable on
|
|
11
|
+
* received responses, so a symbol re-attached inside `clone()` is the only
|
|
12
|
+
* path that survives.
|
|
13
|
+
*
|
|
14
|
+
* @param {Response} response
|
|
15
|
+
* @param {string} requestId
|
|
16
|
+
*/
|
|
17
|
+
export const addRequestIdToResponse = ( response, requestId ) => {
|
|
18
|
+
Object.defineProperty( response, requestIdSymbol, { value: requestId, enumerable: false, configurable: false, writable: false } );
|
|
19
|
+
const originalClone = response.clone.bind( response );
|
|
20
|
+
Object.defineProperty( response, 'clone', {
|
|
21
|
+
value: function clone() {
|
|
22
|
+
const cloned = originalClone();
|
|
23
|
+
addRequestIdToResponse( cloned, requestId );
|
|
24
|
+
return cloned;
|
|
25
|
+
},
|
|
26
|
+
enumerable: false,
|
|
27
|
+
configurable: true,
|
|
28
|
+
writable: true
|
|
29
|
+
} );
|
|
30
|
+
};
|
|
@@ -4,22 +4,5 @@ export declare namespace Attribute {
|
|
|
4
4
|
constructor( type: string );
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
export class HTTPRequestCount extends BaseAttribute {
|
|
8
|
-
static TYPE: 'http:request:count';
|
|
9
|
-
type: typeof HTTPRequestCount.TYPE;
|
|
10
|
-
url: string;
|
|
11
|
-
requestId: string;
|
|
12
|
-
constructor( url: string, requestId: string );
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export class HTTPRequestCost extends BaseAttribute {
|
|
16
|
-
static TYPE: 'http:request:cost';
|
|
17
|
-
type: typeof HTTPRequestCost.TYPE;
|
|
18
|
-
url: string;
|
|
19
|
-
requestId: string;
|
|
20
|
-
total: number;
|
|
21
|
-
constructor( url: string, requestId: string, total: number );
|
|
22
|
-
}
|
|
23
|
-
|
|
24
7
|
export type Instance = BaseAttribute;
|
|
25
8
|
}
|
|
@@ -9,37 +9,9 @@ export class BaseAttribute {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
class HTTPRequestCount extends BaseAttribute {
|
|
13
|
-
static TYPE = 'http:request:count';
|
|
14
|
-
url;
|
|
15
|
-
requestId;
|
|
16
|
-
|
|
17
|
-
constructor( url, requestId ) {
|
|
18
|
-
super( HTTPRequestCount.TYPE );
|
|
19
|
-
this.url = url;
|
|
20
|
-
this.requestId = requestId;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
class HTTPRequestCost extends BaseAttribute {
|
|
25
|
-
static TYPE = 'http:request:cost';
|
|
26
|
-
url;
|
|
27
|
-
requestId;
|
|
28
|
-
total = 0;
|
|
29
|
-
|
|
30
|
-
constructor( url, requestId, total ) {
|
|
31
|
-
super( HTTPRequestCost.TYPE );
|
|
32
|
-
this.url = url;
|
|
33
|
-
this.requestId = requestId;
|
|
34
|
-
this.total = total;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
12
|
/**
|
|
39
13
|
* Types of ADD_ATTR attributes
|
|
40
14
|
*/
|
|
41
15
|
export const Attribute = {
|
|
42
|
-
BaseAttribute
|
|
43
|
-
HTTPRequestCost,
|
|
44
|
-
HTTPRequestCount
|
|
16
|
+
BaseAttribute
|
|
45
17
|
};
|
package/src/worker/configs.js
CHANGED
|
@@ -22,13 +22,31 @@ const envVarSchema = z.object( {
|
|
|
22
22
|
// Worker concurrency — tune these via env vars to adjust for your workload.
|
|
23
23
|
// Each step (API, LLM, etc.) call is one activity. Lower this to reduce memory pressure.
|
|
24
24
|
TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_EXECUTIONS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 40 ) ),
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
/*
|
|
26
|
+
Workflow tasks that may run at once. These are short state transitions, so this is rarely the
|
|
27
|
+
throughput limit - activity concurrency above is. Left unset so @temporalio/worker applies its
|
|
28
|
+
own default of 40; the startup 'Worker sizing' log reports it as workflowTaskSlots.
|
|
29
|
+
https://typescript.temporal.io/api/interfaces/worker.WorkerOptions#maxconcurrentworkflowtaskexecutions
|
|
30
|
+
*/
|
|
31
|
+
TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_EXECUTIONS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().optional() ),
|
|
32
|
+
/*
|
|
33
|
+
LRU cache for sticky workflow execution, holding each workflow's state until it completes or is
|
|
34
|
+
evicted. Left unset so @temporalio/worker sizes it from the isolate's heap limit, which a flat
|
|
35
|
+
value here cannot track. That formula assumes roughly 600 cached workflows per GB, so set this
|
|
36
|
+
where payloads are heavier - the startup 'Worker sizing' log reports what it resolved to.
|
|
37
|
+
https://typescript.temporal.io/api/interfaces/worker.WorkerOptions#maxcachedworkflows
|
|
38
|
+
*/
|
|
39
|
+
TEMPORAL_MAX_CACHED_WORKFLOWS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().optional() ),
|
|
29
40
|
// How aggressively the worker pulls tasks from Temporal.
|
|
30
41
|
TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_POLLS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 5 ) ),
|
|
31
42
|
TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_POLLS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 5 ) ),
|
|
43
|
+
/*
|
|
44
|
+
Threads running workflow sandboxes. Each is a V8 isolate and NODE_OPTIONS heap caps apply per
|
|
45
|
+
isolate, so this multiplies the heap the process can commit - the startup 'Worker sizing'
|
|
46
|
+
log reports the total. Left unset so @temporalio/worker picks: 1 with reuseV8Context, 2 without.
|
|
47
|
+
https://typescript.temporal.io/api/interfaces/worker.WorkerOptions#workflowthreadpoolsize
|
|
48
|
+
*/
|
|
49
|
+
TEMPORAL_WORKFLOW_THREAD_POOL_SIZE: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().optional() ),
|
|
32
50
|
// JSON-encoded Temporal Worker tuner options.
|
|
33
51
|
TEMPORAL_WORKER_TUNER: workerTunerEnvSchema,
|
|
34
52
|
// Activity configs
|
|
@@ -64,6 +82,7 @@ export const maxConcurrentWorkflowTaskExecutions = envVars.TEMPORAL_MAX_CONCURRE
|
|
|
64
82
|
export const maxCachedWorkflows = envVars.TEMPORAL_MAX_CACHED_WORKFLOWS;
|
|
65
83
|
export const maxConcurrentActivityTaskPolls = envVars.TEMPORAL_MAX_CONCURRENT_ACTIVITY_TASK_POLLS;
|
|
66
84
|
export const maxConcurrentWorkflowTaskPolls = envVars.TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_POLLS;
|
|
85
|
+
export const workflowThreadPoolSize = envVars.TEMPORAL_WORKFLOW_THREAD_POOL_SIZE;
|
|
67
86
|
export const workerTuner = envVars.TEMPORAL_WORKER_TUNER;
|
|
68
87
|
export const namespace = envVars.TEMPORAL_NAMESPACE;
|
|
69
88
|
export const taskQueue = envVars.OUTPUT_CATALOG_ID;
|
package/src/worker/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Worker, NativeConnection } from '@temporalio/worker';
|
|
2
|
-
import
|
|
2
|
+
import { address, apiKey, namespace, taskQueue, grpcProxy, hookFlushTimeoutMs } from './configs.js';
|
|
3
3
|
import { loadActivities } from './loader/activities.js';
|
|
4
4
|
import { loadWorkflows } from './loader/workflows.js';
|
|
5
5
|
import { loadHooks } from './loader/hooks.js';
|
|
@@ -15,7 +15,7 @@ import { CatalogPublisher } from './catalog_workflow/catalog_publisher.js';
|
|
|
15
15
|
import { mainEventBus } from '#bus';
|
|
16
16
|
import { flushPendingHooks } from '#hooks/pending_hooks';
|
|
17
17
|
import { BusEventType } from '#consts';
|
|
18
|
-
import { setupTelemetry } from './telemetry.js';
|
|
18
|
+
import { setupTelemetry, logIsolateSizing } from './telemetry.js';
|
|
19
19
|
import { TemporalConnectionMonitor } from './connection_monitor.js';
|
|
20
20
|
import { bindGlobalFunctions } from './global_functions.js';
|
|
21
21
|
import { setupClientConfig } from '#temporal/client';
|
|
@@ -24,28 +24,12 @@ import { setupTemporalLogger } from './temporal_logger.js';
|
|
|
24
24
|
import { WorkerRunner } from './worker_runner.js';
|
|
25
25
|
import { shutdownServices } from './shutdown.js';
|
|
26
26
|
import { init as initCredentials } from '#credentials/init';
|
|
27
|
+
import { buildWorkerOptions } from './worker_options.js';
|
|
27
28
|
|
|
28
29
|
import './log_hooks.js';
|
|
29
30
|
|
|
30
31
|
const log = createChildLogger( 'Worker' );
|
|
31
32
|
|
|
32
|
-
const {
|
|
33
|
-
address,
|
|
34
|
-
apiKey,
|
|
35
|
-
namespace,
|
|
36
|
-
taskQueue,
|
|
37
|
-
grpcProxy,
|
|
38
|
-
maxConcurrentWorkflowTaskExecutions,
|
|
39
|
-
maxConcurrentActivityTaskExecutions,
|
|
40
|
-
maxCachedWorkflows,
|
|
41
|
-
maxConcurrentActivityTaskPolls,
|
|
42
|
-
maxConcurrentWorkflowTaskPolls,
|
|
43
|
-
shutdownForceTime,
|
|
44
|
-
shutdownGraceTime,
|
|
45
|
-
hookFlushTimeoutMs,
|
|
46
|
-
workerTuner
|
|
47
|
-
} = configs;
|
|
48
|
-
|
|
49
33
|
const services = {
|
|
50
34
|
connection: null,
|
|
51
35
|
connectionMonitor: null,
|
|
@@ -113,34 +97,19 @@ const execute = async () => {
|
|
|
113
97
|
await run( () => services.catalogPublisher.run() );
|
|
114
98
|
|
|
115
99
|
log.info( 'Creating Temporal worker...' );
|
|
116
|
-
const worker = await run( () => {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// tuner isn't compatible with concurrent task executions configs
|
|
130
|
-
...( workerTuner ? {
|
|
131
|
-
tuner: workerTuner
|
|
132
|
-
} : {
|
|
133
|
-
maxConcurrentWorkflowTaskExecutions,
|
|
134
|
-
maxConcurrentActivityTaskExecutions
|
|
135
|
-
} ),
|
|
136
|
-
maxCachedWorkflows,
|
|
137
|
-
maxConcurrentActivityTaskPolls,
|
|
138
|
-
maxConcurrentWorkflowTaskPolls,
|
|
139
|
-
bundlerOptions: { webpackConfigHook },
|
|
140
|
-
...( shutdownForceTime !== undefined && { shutdownForceTime } ),
|
|
141
|
-
...( shutdownGraceTime !== undefined && { shutdownGraceTime } )
|
|
142
|
-
} );
|
|
143
|
-
} );
|
|
100
|
+
const worker = await run( () => Worker.create( {
|
|
101
|
+
connection,
|
|
102
|
+
namespace,
|
|
103
|
+
taskQueue,
|
|
104
|
+
workflowsPath,
|
|
105
|
+
activities,
|
|
106
|
+
sinks,
|
|
107
|
+
interceptors: initInterceptors( { activities, workflows } ),
|
|
108
|
+
bundlerOptions: { webpackConfigHook },
|
|
109
|
+
...buildWorkerOptions()
|
|
110
|
+
} ) );
|
|
111
|
+
|
|
112
|
+
run( () => logIsolateSizing( { worker } ) );
|
|
144
113
|
|
|
145
114
|
log.info( 'Setting up telemetry...' );
|
|
146
115
|
run( () => setupTelemetry( { worker } ) );
|
|
@@ -103,7 +103,6 @@ export class ActivityExecutionInterceptor {
|
|
|
103
103
|
const nonRetryable = error instanceof FatalError || inheritsFromAnyNamedType( error, activityInfo.retryPolicy?.nonRetryableErrorTypes ?? [] );
|
|
104
104
|
throw ApplicationFailure.fromError( unwrappedError, {
|
|
105
105
|
nonRetryable,
|
|
106
|
-
cause: unwrappedError,
|
|
107
106
|
details: [ { error: serializeError( unwrappedError, { dropKeys: [ 'stack' ] } ) } ]
|
|
108
107
|
} );
|
|
109
108
|
} finally {
|
|
@@ -89,7 +89,6 @@ class WorkflowExecutionInterceptor {
|
|
|
89
89
|
const unwrappedError = error instanceof TransparentFatalError ? error.cause : error;
|
|
90
90
|
sinks.workflow.error( serializeError( unwrappedError ) );
|
|
91
91
|
throw ApplicationFailure.fromError( unwrappedError, {
|
|
92
|
-
cause: unwrappedError,
|
|
93
92
|
nonRetryable: true,
|
|
94
93
|
details: [ { error: serializeError( unwrappedError, { dropKeys: [ 'stack' ] } ) } ]
|
|
95
94
|
} );
|
package/src/worker/telemetry.js
CHANGED
|
@@ -1,21 +1,141 @@
|
|
|
1
|
+
import { totalmem } from 'node:os';
|
|
2
|
+
import { getHeapStatistics } from 'node:v8';
|
|
1
3
|
import { serializeError } from '#helpers/error_serializer';
|
|
2
4
|
import { createChildLogger } from '#logger';
|
|
3
5
|
import { workerTelemetryIntervalMs } from './configs.js';
|
|
4
6
|
|
|
5
7
|
const log = createChildLogger( 'Telemetry' );
|
|
6
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the memory limit to compare the worker against: the container's limit, or the
|
|
11
|
+
* machine's total memory when there is none.
|
|
12
|
+
*
|
|
13
|
+
* `process.constrainedMemory()` returns a sentinel near 2^64 rather than 0 when the process is
|
|
14
|
+
* unconstrained, so values at or above the machine's memory are treated as no limit.
|
|
15
|
+
*
|
|
16
|
+
* @returns {number} Bytes.
|
|
17
|
+
*/
|
|
18
|
+
const resolveMemoryLimit = () => {
|
|
19
|
+
const constrainedMemory = process.constrainedMemory();
|
|
20
|
+
const machineMemory = totalmem();
|
|
21
|
+
return constrainedMemory > 0 && constrainedMemory < machineMemory ? constrainedMemory : machineMemory;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Log the worker's isolate and cache sizing. Warn when the projected
|
|
26
|
+
* heap exceeds the memory limit, or when workflow task slots exceed
|
|
27
|
+
* the cache.
|
|
28
|
+
*
|
|
29
|
+
* The worker runs one isolate on the main thread and one per workflow
|
|
30
|
+
* thread. A NODE_OPTIONS heap cap applies to each isolate, not to the
|
|
31
|
+
* process, so on a 4 GB container `--max-old-space-size-percentage=85`
|
|
32
|
+
* allows each isolate ~3.4 GB and two isolates can commit well over
|
|
33
|
+
* the container's memory.
|
|
34
|
+
*
|
|
35
|
+
* The cache and the task slots are sized independently:
|
|
36
|
+
* `@temporalio/worker` derives `maxCachedWorkflows` from the heap limit
|
|
37
|
+
* but fixes `workflowTaskSlots` at 40. On a small container the slots
|
|
38
|
+
* can exceed the cache, which causes tasks to evict each other's
|
|
39
|
+
* workflows and forces history replays.
|
|
40
|
+
*
|
|
41
|
+
* Logged fields are read from `worker.options`, so each is the resolved
|
|
42
|
+
* value rather than the requested one:
|
|
43
|
+
*
|
|
44
|
+
* - `workflowThreadPoolSize`: threads running workflow sandboxes.
|
|
45
|
+
* Defaults to 1 with `reuseV8Context`, 2 without. Ignored under
|
|
46
|
+
* `debugMode`.
|
|
47
|
+
* - `reuseV8Context`: whether workflows share one VM context per thread
|
|
48
|
+
* or each get a new one. Enabled by default. Upstream estimates
|
|
49
|
+
* roughly 600 cached workflows per GB with it and 250 without.
|
|
50
|
+
* - `isolateCount`: `1 + workflowThreadPoolSize`, the multiplier on the
|
|
51
|
+
* per-isolate heap cap. 1 under `debugMode`, which runs workflows in
|
|
52
|
+
* the main isolate and spawns no workflow threads.
|
|
53
|
+
* - `heapSizeLimit`: the main isolate's V8 heap ceiling, as resolved
|
|
54
|
+
* from NODE_OPTIONS.
|
|
55
|
+
* - `projectedCommittedHeap`: `isolateCount * heapSizeLimit`, the heap
|
|
56
|
+
* the process can commit if every isolate fills. Only the main
|
|
57
|
+
* thread's limit is readable, so this assumes the workflow threads
|
|
58
|
+
* have the same one.
|
|
59
|
+
* - `memoryLimit`: the limit the projection is compared against. The
|
|
60
|
+
* container's limit, or the machine's total memory when unconstrained.
|
|
61
|
+
* - `maxCachedWorkflows`: sticky cache size. Derived from
|
|
62
|
+
* `heapSizeLimit` when TEMPORAL_MAX_CACHED_WORKFLOWS is unset.
|
|
63
|
+
* - `workflowTaskSlots`: workflow tasks that may run at once, from the
|
|
64
|
+
* tuner. Undefined with a resource-based tuner, which sizes slots at
|
|
65
|
+
* runtime.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} params Parameters.
|
|
68
|
+
* @param {import('@temporalio/worker').Worker} params.worker The created Temporal worker.
|
|
69
|
+
*/
|
|
70
|
+
export const logIsolateSizing = ( { worker } ) => {
|
|
71
|
+
const { workflowThreadPoolSize, reuseV8Context, maxCachedWorkflows, tuner, debugMode } = worker.options;
|
|
72
|
+
// debugMode runs workflows in the main isolate and spawns no workflow threads.
|
|
73
|
+
const isolateCount = debugMode ? 1 : 1 + workflowThreadPoolSize;
|
|
74
|
+
const heapSizeLimit = getHeapStatistics().heap_size_limit;
|
|
75
|
+
const memoryLimit = resolveMemoryLimit();
|
|
76
|
+
const workflowTaskSlots = tuner?.workflowTaskSlotSupplier?.numSlots;
|
|
77
|
+
const projectedCommittedHeap = isolateCount * heapSizeLimit;
|
|
78
|
+
|
|
79
|
+
log.info( 'Worker sizing', {
|
|
80
|
+
workflowThreadPoolSize,
|
|
81
|
+
reuseV8Context,
|
|
82
|
+
isolateCount,
|
|
83
|
+
heapSizeLimit,
|
|
84
|
+
projectedCommittedHeap,
|
|
85
|
+
memoryLimit,
|
|
86
|
+
maxCachedWorkflows,
|
|
87
|
+
workflowTaskSlots
|
|
88
|
+
} );
|
|
89
|
+
|
|
90
|
+
if ( projectedCommittedHeap > memoryLimit ) {
|
|
91
|
+
log.warn( 'Worker isolates may commit more heap than the host allows', { isolateCount, heapSizeLimit, projectedCommittedHeap, memoryLimit } );
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if ( workflowTaskSlots > maxCachedWorkflows ) {
|
|
95
|
+
log.warn( 'Worker runs more workflow tasks than it can cache, forcing replays', { workflowTaskSlots, maxCachedWorkflows } );
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Start an interval that logs worker status and memory every
|
|
101
|
+
* OUTPUT_WORKER_TELEMETRY_INTERVAL_MS. Disabled when it is 0.
|
|
102
|
+
*
|
|
103
|
+
* Each record holds the Temporal worker's `status` and a `memory`
|
|
104
|
+
* block. The memory fields mix process-wide and main-isolate scopes:
|
|
105
|
+
*
|
|
106
|
+
* - `availableMemory`: memory the process can still allocate, within
|
|
107
|
+
* the container limit.
|
|
108
|
+
* - `memoryLimit`: the container's limit, or the machine's total memory
|
|
109
|
+
* when unconstrained.
|
|
110
|
+
* - `memoryUsage`: raw `process.memoryUsage()`. `rss` is process-wide;
|
|
111
|
+
* `heapTotal` and `heapUsed` cover only the main isolate.
|
|
112
|
+
* - `nonMainHeap`: `memoryUsage.rss - memoryUsage.heapUsed`, the
|
|
113
|
+
* resident memory outside the main isolate's live objects: workflow
|
|
114
|
+
* thread heaps, Rust core allocations, buffers, and heap pages V8 has
|
|
115
|
+
* committed but not filled. An upper bound on the workflow thread
|
|
116
|
+
* heap, not a measurement of it. Cached workflow memory is counted
|
|
117
|
+
* here, so it grows with the cache while `heapUsed` stays flat.
|
|
118
|
+
* - `heapSizeLimit`: the main isolate's V8 heap ceiling. Constant, but
|
|
119
|
+
* included in each sample so records are self-contained.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} params Parameters.
|
|
122
|
+
* @param {import('@temporalio/worker').Worker} params.worker The created Temporal worker.
|
|
123
|
+
*/
|
|
7
124
|
export const setupTelemetry = ( { worker } ) => {
|
|
8
125
|
if ( workerTelemetryIntervalMs <= 0 ) {
|
|
9
126
|
return;
|
|
10
127
|
}
|
|
11
128
|
setInterval( () => {
|
|
12
129
|
try {
|
|
130
|
+
const memoryUsage = process.memoryUsage();
|
|
13
131
|
log.info( 'Worker', {
|
|
14
132
|
status: worker.getStatus(),
|
|
15
133
|
memory: {
|
|
16
134
|
availableMemory: process.availableMemory(),
|
|
17
|
-
|
|
18
|
-
memoryUsage
|
|
135
|
+
memoryLimit: resolveMemoryLimit(),
|
|
136
|
+
memoryUsage,
|
|
137
|
+
nonMainHeap: memoryUsage.rss - memoryUsage.heapUsed,
|
|
138
|
+
heapSizeLimit: getHeapStatistics().heap_size_limit
|
|
19
139
|
}
|
|
20
140
|
} );
|
|
21
141
|
} catch ( error ) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as configs from './configs.js';
|
|
2
|
+
import { createChildLogger } from '#logger';
|
|
3
|
+
|
|
4
|
+
const log = createChildLogger( 'Worker' );
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
maxConcurrentWorkflowTaskExecutions,
|
|
8
|
+
maxConcurrentActivityTaskExecutions,
|
|
9
|
+
maxCachedWorkflows,
|
|
10
|
+
maxConcurrentActivityTaskPolls,
|
|
11
|
+
maxConcurrentWorkflowTaskPolls,
|
|
12
|
+
workflowThreadPoolSize,
|
|
13
|
+
shutdownForceTime,
|
|
14
|
+
shutdownGraceTime,
|
|
15
|
+
workerTuner
|
|
16
|
+
} = configs;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Builds the concurrency, cache and shutdown options passed to `Worker.create`.
|
|
20
|
+
* Unset values are omitted so @temporalio/worker applies its own defaults.
|
|
21
|
+
* @returns {object} partial Temporal worker options
|
|
22
|
+
*/
|
|
23
|
+
export const buildWorkerOptions = () => {
|
|
24
|
+
if ( workerTuner ) {
|
|
25
|
+
log.info( 'Using worker tuner options', { ...workerTuner } );
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
// tuner isn't compatible with concurrent task executions configs
|
|
29
|
+
...( workerTuner ? {
|
|
30
|
+
tuner: workerTuner
|
|
31
|
+
} : {
|
|
32
|
+
...( maxConcurrentWorkflowTaskExecutions !== undefined && { maxConcurrentWorkflowTaskExecutions } ),
|
|
33
|
+
maxConcurrentActivityTaskExecutions
|
|
34
|
+
} ),
|
|
35
|
+
...( maxCachedWorkflows !== undefined && { maxCachedWorkflows } ),
|
|
36
|
+
maxConcurrentActivityTaskPolls,
|
|
37
|
+
maxConcurrentWorkflowTaskPolls,
|
|
38
|
+
// Omitted when unset so @temporalio/worker applies its own default, which depends on reuseV8Context.
|
|
39
|
+
...( workflowThreadPoolSize !== undefined && { workflowThreadPoolSize } ),
|
|
40
|
+
...( shutdownForceTime !== undefined && { shutdownForceTime } ),
|
|
41
|
+
...( shutdownGraceTime !== undefined && { shutdownGraceTime } )
|
|
42
|
+
};
|
|
43
|
+
};
|