@outputai/core 0.15.1-dev.46e7530.0 → 0.15.1-next.15a30ef.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/core",
3
- "version": "0.15.1-dev.46e7530.0",
3
+ "version": "0.15.1-next.15a30ef.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",
@@ -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|undefined|null} The response body content
13
+ * @returns {string|object|null} The response body content
8
14
  */
9
- const consumeBody = async response => {
10
- const headers = Object.fromEntries( response.headers ) ?? {};
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
- return response.json();
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 ( contentType.startsWith( 'text/' ) ) {
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( Object.fromEntries( response.headers ) ) } ),
48
+ ...( includeHeaders && { headers: redactHeaders( response.headers ) } ),
37
49
  ...( includeBody && { body: await consumeBody( response ) } )
38
50
  } );
39
51
 
@@ -43,7 +43,8 @@ export const redactHeaders = headers => {
43
43
  wordEndMatcher( 'key' )
44
44
  ];
45
45
 
46
- return Object.entries( headers ).reduce( ( redacted, [ key, value ] ) => {
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;
@@ -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
  };
@@ -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
- // Workflows are lightweight state machines — this can be high.
26
- TEMPORAL_MAX_CONCURRENT_WORKFLOW_TASK_EXECUTIONS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 200 ) ),
27
- // LRU cache for sticky workflow execution. Lower values free memory faster after surges.
28
- TEMPORAL_MAX_CACHED_WORKFLOWS: z.preprocess( coalesceEmptyString, z.coerce.number().int().positive().default( 1000 ) ),
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;
@@ -1,5 +1,5 @@
1
1
  import { Worker, NativeConnection } from '@temporalio/worker';
2
- import * as configs from './configs.js';
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
- if ( workerTuner ) {
118
- log.info( 'Using worker tuner options', { ...workerTuner } );
119
- }
120
-
121
- return Worker.create( {
122
- connection,
123
- namespace,
124
- taskQueue,
125
- workflowsPath,
126
- activities,
127
- sinks,
128
- interceptors: initInterceptors( { activities, workflows } ),
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
  } );
@@ -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
- constrainedMemory: process.constrainedMemory(),
18
- memoryUsage: process.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
+ };