@webpieces/core-util 0.4.752 → 0.4.754
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 +1 -1
- package/src/http/ApiCallInfo.d.ts +6 -1
- package/src/http/ApiCallInfo.js +5 -1
- package/src/http/ApiCallInfo.js.map +1 -1
- package/src/http/CallDeadline.d.ts +7 -0
- package/src/http/CallDeadline.js +14 -0
- package/src/http/CallDeadline.js.map +1 -1
- package/src/http/LogApiCall.d.ts +9 -3
- package/src/http/LogApiCall.js +45 -8
- package/src/http/LogApiCall.js.map +1 -1
- package/src/ipc/IpcConnection.d.ts +8 -1
- package/src/ipc/IpcConnection.js +61 -8
- package/src/ipc/IpcConnection.js.map +1 -1
- package/src/logging/LogChunker.d.ts +3 -3
- package/src/logging/LogChunker.js +12 -10
- package/src/logging/LogChunker.js.map +1 -1
package/package.json
CHANGED
|
@@ -13,6 +13,7 @@ import { ApiMethodInfo } from './ApiMethodInfo';
|
|
|
13
13
|
* - `jsonPayload.api.result="failure"` — failed exchanges only
|
|
14
14
|
* - `jsonPayload.api.durationMs>1000` — slow calls, either side
|
|
15
15
|
* - `jsonPayload.api.responseSize>100000` — the fat responses (the ones that get chunked)
|
|
16
|
+
* - `jsonPayload.api.responseCount>100` — responses containing many logical items
|
|
16
17
|
* - `jsonPayload.api:*` — "API traffic only" (tracing + the recorder)
|
|
17
18
|
*
|
|
18
19
|
* IMPORTANT: the field names here (and on the nested {@link ApiMethodInfo}) ARE the GCP field names —
|
|
@@ -70,6 +71,8 @@ export declare class ApiCallInfo {
|
|
|
70
71
|
/** Bytes of the serialized response. RESPONSE tag only, and only when the call succeeded —
|
|
71
72
|
* a thrown error produced no response body to measure. Total size, pre-chunking. */
|
|
72
73
|
readonly responseSize?: number | undefined;
|
|
74
|
+
/** Number of logical items in a successful response. RESPONSE tag only, opt-in per call. */
|
|
75
|
+
readonly responseCount?: number | undefined;
|
|
73
76
|
constructor(
|
|
74
77
|
/** The call identity (side, apiClass, methodName, controllerName) — surfaces nested under
|
|
75
78
|
* `jsonPayload.api.method`. */
|
|
@@ -100,5 +103,7 @@ export declare class ApiCallInfo {
|
|
|
100
103
|
requestSize?: number | undefined,
|
|
101
104
|
/** Bytes of the serialized response. RESPONSE tag only, and only when the call succeeded —
|
|
102
105
|
* a thrown error produced no response body to measure. Total size, pre-chunking. */
|
|
103
|
-
responseSize?: number | undefined
|
|
106
|
+
responseSize?: number | undefined,
|
|
107
|
+
/** Number of logical items in a successful response. RESPONSE tag only, opt-in per call. */
|
|
108
|
+
responseCount?: number | undefined);
|
|
104
109
|
}
|
package/src/http/ApiCallInfo.js
CHANGED
|
@@ -8,6 +8,7 @@ class ApiCallInfo {
|
|
|
8
8
|
durationMs;
|
|
9
9
|
requestSize;
|
|
10
10
|
responseSize;
|
|
11
|
+
responseCount;
|
|
11
12
|
constructor(
|
|
12
13
|
/** The call identity (side, apiClass, methodName, controllerName) — surfaces nested under
|
|
13
14
|
* `jsonPayload.api.method`. */
|
|
@@ -38,13 +39,16 @@ class ApiCallInfo {
|
|
|
38
39
|
requestSize,
|
|
39
40
|
/** Bytes of the serialized response. RESPONSE tag only, and only when the call succeeded —
|
|
40
41
|
* a thrown error produced no response body to measure. Total size, pre-chunking. */
|
|
41
|
-
responseSize
|
|
42
|
+
responseSize,
|
|
43
|
+
/** Number of logical items in a successful response. RESPONSE tag only, opt-in per call. */
|
|
44
|
+
responseCount) {
|
|
42
45
|
this.method = method;
|
|
43
46
|
this.type = type;
|
|
44
47
|
this.result = result;
|
|
45
48
|
this.durationMs = durationMs;
|
|
46
49
|
this.requestSize = requestSize;
|
|
47
50
|
this.responseSize = responseSize;
|
|
51
|
+
this.responseCount = responseCount;
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
exports.ApiCallInfo = ApiCallInfo;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ApiCallInfo.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ApiCallInfo.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"ApiCallInfo.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/ApiCallInfo.ts"],"names":[],"mappings":";;;AA8CA,MAAa,WAAW;IAIP;IACA;IAEA;IAYA;IAUA;IAGA;IAEA;IAjCb;IACI;oCACgC;IACvB,MAAqB,EACrB,IAAa;IACtB,sDAAsD;IAC7C,MAAkB;IAC3B;;;;;;;;;;OAUG;IACM,UAAmB;IAC5B;;;;;;;;OAQG;IACM,WAAoB;IAC7B;yFACqF;IAC5E,YAAqB;IAC9B,4FAA4F;IACnF,aAAsB;QA9BtB,WAAM,GAAN,MAAM,CAAe;QACrB,SAAI,GAAJ,IAAI,CAAS;QAEb,WAAM,GAAN,MAAM,CAAY;QAYlB,eAAU,GAAV,UAAU,CAAS;QAUnB,gBAAW,GAAX,WAAW,CAAS;QAGpB,iBAAY,GAAZ,YAAY,CAAS;QAErB,kBAAa,GAAb,aAAa,CAAS;IAChC,CAAC;CACP;AApCD,kCAoCC","sourcesContent":["import { ApiMethodInfo } from './ApiMethodInfo';\n\n/**\n * ApiCallInfo - the structured tag stamped into RequestContext around every API call\n * (by {@link LogApiCallImpl}), so ANY log line emitted during the call inherits a filterable\n * `api` object rather than only the req/resp text lines.\n *\n * The node logging backends (winston/bunyan) read this struct out of context via\n * `RequestContext.buildStructuredLogFields()` and emit it AS AN OBJECT under `jsonPayload.api`,\n * which unlocks GCP Cloud Logging filters like:\n * - `jsonPayload.api.method.side=\"client\"` — every outbound call this process made\n * - `jsonPayload.api.method.side=\"server\"` — every inbound call it handled\n * - `jsonPayload.api.method.apiClass=\"SaveApi\"` — one logical method, BOTH sides (client + server)\n * - `jsonPayload.api.result=\"failure\"` — failed exchanges only\n * - `jsonPayload.api.durationMs>1000` — slow calls, either side\n * - `jsonPayload.api.responseSize>100000` — the fat responses (the ones that get chunked)\n * - `jsonPayload.api.responseCount>100` — responses containing many logical items\n * - `jsonPayload.api:*` — \"API traffic only\" (tracing + the recorder)\n *\n * IMPORTANT: the field names here (and on the nested {@link ApiMethodInfo}) ARE the GCP field names —\n * rename a field and the filter renames with it. The identity lives NESTED under `api.method`\n * (`api.method.{side,apiClass,methodName,controllerName}`); `api.type` and `api.result` sit at the top.\n *\n * NOTE: the request `httpMethod`/`path` are NOT here — an inbound request stamps them as the separate\n * top-level logged keys `jsonPayload.httpMethod` / `jsonPayload.requestPath` (see\n * {@link WebpiecesCoreHeaders} + `RequestContextHeaders.fillFromRequest`). Outbound client calls have\n * no inbound path, so they carry only the `api` identity.\n *\n * Per-hop only: the underlying `API_CALL_INFO` ContextKey is NOT transferred over the wire, so a\n * downstream server stamps its own `side:'server'` rather than inheriting the caller's `side:'client'`.\n *\n * Per CLAUDE.md: data-only structures are classes, not interfaces.\n */\n\n/** Which half of the exchange this tag describes: the outgoing 'request' or the returning 'response'. */\nexport type ApiType = 'request' | 'response';\n\n/**\n * Response outcome. 'success' covers 2xx AND user errors (400/401/403/404/266 — a successfully\n * handled \"you made a mistake\"); 'failure' is a genuine server error. See {@link LogApiCallImpl.isUserError}.\n */\nexport type ApiResult = 'success' | 'failure';\n\n/** Re-exported from {@link ApiMethodInfo} (its true home) so existing `ApiSide` imports keep working. */\nexport type { ApiSide } from './ApiMethodInfo';\n\nexport class ApiCallInfo {\n constructor(\n /** The call identity (side, apiClass, methodName, controllerName) — surfaces nested under\n * `jsonPayload.api.method`. */\n readonly method: ApiMethodInfo,\n readonly type: ApiType,\n /** Response only — undefined on the 'request' tag. */\n readonly result?: ApiResult,\n /**\n * Wall-clock milliseconds the call took. RESPONSE tag only (undefined on 'request') — a\n * request has no duration yet. Present on BOTH the success and failure paths, so\n * `jsonPayload.api.durationMs>1000 AND api.result=\"failure\"` finds slow failures.\n *\n * There is deliberately no `statusCode` beside this. LogApiCall runs deep in the stack over\n * in-process calls, pubsub handlers, and cloud-task enqueues — none of which have an HTTP\n * status — and business logic must not know about HTTP. `result` (see {@link ApiResult}) is\n * the transport-neutral outcome, exactly as {@link LogApiCallImpl.isUserError} classifies by\n * portable Error TYPE rather than by status code.\n */\n readonly durationMs?: number,\n /**\n * Bytes of the serialized request DTO. Stamped on BOTH tags: the 'request' tag reports it as\n * soon as it is known, and the 'response' tag repeats it so one record shows the whole\n * exchange (`api.requestSize` + `api.responseSize` without a join).\n *\n * This is the TOTAL size of the body, measured BEFORE any log chunking — chunking is a\n * transport concern handled by the GCP backends, and a body split across 3 records still\n * reports its one true size here.\n */\n readonly requestSize?: number,\n /** Bytes of the serialized response. RESPONSE tag only, and only when the call succeeded —\n * a thrown error produced no response body to measure. Total size, pre-chunking. */\n readonly responseSize?: number,\n /** Number of logical items in a successful response. RESPONSE tag only, opt-in per call. */\n readonly responseCount?: number,\n ) {}\n}\n"]}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { CallContext } from './CallStrategy';
|
|
2
2
|
/** Transport deadline. The race bounds even transports that ignore cancellation. */
|
|
3
3
|
export declare class CallDeadline {
|
|
4
|
+
/**
|
|
5
|
+
* AbortController.abort(reason) is not portable: React Native's abort-controller 3 signal has
|
|
6
|
+
* neither `reason` nor `throwIfAborted`. Keep the typed failure beside the signal instead.
|
|
7
|
+
*/
|
|
8
|
+
private static readonly failures;
|
|
4
9
|
static validate(timeoutMs: number): void;
|
|
5
10
|
static run<T>(timeoutMs: number, context: CallContext, work: (signal: AbortSignal) => Promise<T>): Promise<T>;
|
|
11
|
+
/** Throw the exact deadline failure without relying on optional AbortSignal extensions. */
|
|
12
|
+
static throwIfAborted(signal: AbortSignal): void;
|
|
6
13
|
}
|
package/src/http/CallDeadline.js
CHANGED
|
@@ -4,6 +4,11 @@ exports.CallDeadline = void 0;
|
|
|
4
4
|
const TimeoutError_1 = require("./TimeoutError");
|
|
5
5
|
/** Transport deadline. The race bounds even transports that ignore cancellation. */
|
|
6
6
|
class CallDeadline {
|
|
7
|
+
/**
|
|
8
|
+
* AbortController.abort(reason) is not portable: React Native's abort-controller 3 signal has
|
|
9
|
+
* neither `reason` nor `throwIfAborted`. Keep the typed failure beside the signal instead.
|
|
10
|
+
*/
|
|
11
|
+
static failures = new WeakMap();
|
|
7
12
|
// webpieces-disable no-function-outside-class -- stateless transport helper
|
|
8
13
|
static validate(timeoutMs) {
|
|
9
14
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
|
|
@@ -18,6 +23,7 @@ class CallDeadline {
|
|
|
18
23
|
const expired = new Promise((_resolve, reject) => {
|
|
19
24
|
timer = setTimeout(() => {
|
|
20
25
|
const error = new TimeoutError_1.TimeoutError(timeoutMs, context);
|
|
26
|
+
CallDeadline.failures.set(controller.signal, error);
|
|
21
27
|
reject(error);
|
|
22
28
|
controller.abort(error);
|
|
23
29
|
}, timeoutMs);
|
|
@@ -34,6 +40,14 @@ class CallDeadline {
|
|
|
34
40
|
clearTimeout(timer);
|
|
35
41
|
}
|
|
36
42
|
}
|
|
43
|
+
/** Throw the exact deadline failure without relying on optional AbortSignal extensions. */
|
|
44
|
+
// webpieces-disable no-function-outside-class -- portable transport helper
|
|
45
|
+
static throwIfAborted(signal) {
|
|
46
|
+
if (!signal.aborted)
|
|
47
|
+
return;
|
|
48
|
+
throw (CallDeadline.failures.get(signal) ??
|
|
49
|
+
new Error('The operation was aborted before it could complete'));
|
|
50
|
+
}
|
|
37
51
|
}
|
|
38
52
|
exports.CallDeadline = CallDeadline;
|
|
39
53
|
//# sourceMappingURL=CallDeadline.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CallDeadline.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/CallDeadline.ts"],"names":[],"mappings":";;;AACA,iDAA8C;AAE9C,oFAAoF;AACpF,MAAa,YAAY;IACrB,4EAA4E;IAC5E,MAAM,CAAC,QAAQ,CAAC,SAAiB;QAC7B,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,SAAS,IAAI,CAAC,IAAI,SAAS,GAAG,aAAa,EAAE,CAAC;YAC7E,MAAM,IAAI,UAAU,CAAC,4DAA4D,CAAC,CAAC;QACvF,CAAC;IACL,CAAC;IAED,4EAA4E;IAC5E,MAAM,CAAC,KAAK,CAAC,GAAG,CACZ,SAAiB,EACjB,OAAoB,EACpB,IAAyC;QAEzC,YAAY,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;QACjC,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;QACzC,IAAI,KAAgD,CAAC;QACrD,MAAM,OAAO,GAAG,IAAI,OAAO,CACvB,CAAC,QAAgC,EAAE,MAA8B,EAAE,EAAE;YACjE,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;gBACpB,MAAM,KAAK,GAAG,IAAI,2BAAY,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;gBACnD,MAAM,CAAC,KAAK,CAAC,CAAC;gBACd,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAC5B,CAAC,EAAE,SAAS,CAAC,CAAC;QAClB,CAAC,CACJ,CAAC;QACF,qFAAqF;QACrF,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,MAAM,OAAO,CAAC,IAAI,CAAC;gBACtB,OAAO;gBACP,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;aACxD,CAAC,CAAC;QACP,CAAC;gBAAS,CAAC;YACP,YAAY,CAAC,KAAK,CAAC,CAAC;QACxB,CAAC;IACL,CAAC;
|
|
1
|
+
{"version":3,"file":"CallDeadline.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/CallDeadline.ts"],"names":[],"mappings":";;;AACA,iDAA8C;AAE9C,oFAAoF;AACpF,MAAa,YAAY;IACrB;;;OAGG;IACK,MAAM,CAAU,QAAQ,GAAG,IAAI,OAAO,EAAsB,CAAC;IAErE,4EAA4E;IAC5E,MAAM,CAAC,QAAQ,CAAC,SAAiB;QAC7B,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,SAAS,IAAI,CAAC,IAAI,SAAS,GAAG,aAAa,EAAE,CAAC;YAC7E,MAAM,IAAI,UAAU,CAAC,4DAA4D,CAAC,CAAC;QACvF,CAAC;IACL,CAAC;IAED,4EAA4E;IAC5E,MAAM,CAAC,KAAK,CAAC,GAAG,CACZ,SAAiB,EACjB,OAAoB,EACpB,IAAyC;QAEzC,YAAY,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;QACjC,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;QACzC,IAAI,KAAgD,CAAC;QACrD,MAAM,OAAO,GAAG,IAAI,OAAO,CACvB,CAAC,QAAgC,EAAE,MAA8B,EAAE,EAAE;YACjE,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;gBACpB,MAAM,KAAK,GAAG,IAAI,2BAAY,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;gBACnD,YAAY,CAAC,QAAQ,CAAC,GAAG,CAAC,UAAU,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;gBACpD,MAAM,CAAC,KAAK,CAAC,CAAC;gBACd,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAC5B,CAAC,EAAE,SAAS,CAAC,CAAC;QAClB,CAAC,CACJ,CAAC;QACF,qFAAqF;QACrF,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,MAAM,OAAO,CAAC,IAAI,CAAC;gBACtB,OAAO;gBACP,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;aACxD,CAAC,CAAC;QACP,CAAC;gBAAS,CAAC;YACP,YAAY,CAAC,KAAK,CAAC,CAAC;QACxB,CAAC;IACL,CAAC;IAED,2FAA2F;IAC3F,2EAA2E;IAC3E,MAAM,CAAC,cAAc,CAAC,MAAmB;QACrC,IAAI,CAAC,MAAM,CAAC,OAAO;YAAE,OAAO;QAC5B,MAAM,CACF,YAAY,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC;YACjC,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAClE,CAAC;IACN,CAAC;;AArDL,oCAsDC","sourcesContent":["import { CallContext } from './CallStrategy';\nimport { TimeoutError } from './TimeoutError';\n\n/** Transport deadline. The race bounds even transports that ignore cancellation. */\nexport class CallDeadline {\n /**\n * AbortController.abort(reason) is not portable: React Native's abort-controller 3 signal has\n * neither `reason` nor `throwIfAborted`. Keep the typed failure beside the signal instead.\n */\n private static readonly failures = new WeakMap<AbortSignal, Error>();\n\n // webpieces-disable no-function-outside-class -- stateless transport helper\n static validate(timeoutMs: number): void {\n if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {\n throw new RangeError('timeoutMs must be positive, finite, and at most 2147483647');\n }\n }\n\n // webpieces-disable no-function-outside-class -- stateless transport helper\n static async run<T>(\n timeoutMs: number,\n context: CallContext,\n work: (signal: AbortSignal) => Promise<T>,\n ): Promise<T> {\n CallDeadline.validate(timeoutMs);\n const controller = new AbortController();\n let timer: ReturnType<typeof setTimeout> | undefined;\n const expired = new Promise<never>(\n (_resolve: (value: never) => void, reject: (error: Error) => void) => {\n timer = setTimeout(() => {\n const error = new TimeoutError(timeoutMs, context);\n CallDeadline.failures.set(controller.signal, error);\n reject(error);\n controller.abort(error);\n }, timeoutMs);\n },\n );\n // webpieces-disable no-unmanaged-exceptions -- release the timer on every settlement\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await Promise.race([\n expired,\n Promise.resolve().then(() => work(controller.signal)),\n ]);\n } finally {\n clearTimeout(timer);\n }\n }\n\n /** Throw the exact deadline failure without relying on optional AbortSignal extensions. */\n // webpieces-disable no-function-outside-class -- portable transport helper\n static throwIfAborted(signal: AbortSignal): void {\n if (!signal.aborted) return;\n throw (\n CallDeadline.failures.get(signal) ??\n new Error('The operation was aborted before it could complete')\n );\n }\n}\n"]}
|
package/src/http/LogApiCall.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ApiMethodInfo } from
|
|
2
|
-
import { ApiCallContext } from
|
|
1
|
+
import { ApiMethodInfo } from './ApiMethodInfo';
|
|
2
|
+
import { ApiCallContext } from './ApiCallContext';
|
|
3
3
|
/**
|
|
4
4
|
* LogApiCallImpl - Generic API call logging utility, used by BOTH server-side (LogApiFilter) and
|
|
5
5
|
* client-side (ProxyClient) for one consistent logging shape across the framework.
|
|
@@ -54,7 +54,13 @@ export declare class LogApiCallImpl {
|
|
|
54
54
|
* it. Cost: only the `[API-*]` req/resp lines carry `api`, not lines emitted mid-call — which is
|
|
55
55
|
* exactly what the GCP filters (`jsonPayload.api.*`) want.
|
|
56
56
|
*/
|
|
57
|
-
execute(methodInfo: ApiMethodInfo, requestDto:
|
|
57
|
+
execute<Q, R>(methodInfo: ApiMethodInfo, requestDto: Q, method: (dto: Q) => Promise<R>, responseCount?: (response: R) => number | undefined): Promise<R>;
|
|
58
|
+
/**
|
|
59
|
+
* Evaluate an opt-in logical-item count without allowing observability code to affect the call.
|
|
60
|
+
* Warnings deliberately contain neither request nor response bodies (nor the invalid value).
|
|
61
|
+
*/
|
|
62
|
+
private selectResponseCount;
|
|
63
|
+
private warnInvalidResponseCount;
|
|
58
64
|
/**
|
|
59
65
|
* Serialize a DTO for the LOG LINE ONLY. With no mask on the call, this is a plain JSON.stringify
|
|
60
66
|
* (byte-for-byte the old behavior, no walk) so existing callers pay nothing. With a mask, it runs
|
package/src/http/LogApiCall.js
CHANGED
|
@@ -68,11 +68,7 @@ class LogApiCallImpl {
|
|
|
68
68
|
* it. Cost: only the `[API-*]` req/resp lines carry `api`, not lines emitted mid-call — which is
|
|
69
69
|
* exactly what the GCP filters (`jsonPayload.api.*`) want.
|
|
70
70
|
*/
|
|
71
|
-
async execute(methodInfo,
|
|
72
|
-
// webpieces-disable no-any-unknown -- DTO types are erased at the api/proxy boundary (matches ProxyClient)
|
|
73
|
-
requestDto,
|
|
74
|
-
// webpieces-disable no-any-unknown -- DTO types are erased at the api/proxy boundary
|
|
75
|
-
method) {
|
|
71
|
+
async execute(methodInfo, requestDto, method, responseCount) {
|
|
76
72
|
const ctx = this.activeContext();
|
|
77
73
|
const key = WebpiecesCoreHeaders_1.WebpiecesCoreHeaders.API_CALL_INFO;
|
|
78
74
|
const side = methodInfo.side;
|
|
@@ -81,8 +77,13 @@ class LogApiCallImpl {
|
|
|
81
77
|
// never across an await, so a single browser global slot can never be clobbered by a concurrent call.
|
|
82
78
|
const stamp = (info, emit) => {
|
|
83
79
|
ctx.set(key, info);
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
// webpieces-disable no-unmanaged-exceptions -- cleanup must run when a logging backend throws
|
|
81
|
+
try {
|
|
82
|
+
emit();
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
ctx.remove(key);
|
|
86
|
+
}
|
|
86
87
|
};
|
|
87
88
|
// Stringify ONCE and reuse for both the log text and the size — a second JSON.stringify of a
|
|
88
89
|
// large DTO purely to measure it would double the cost of the thing we are measuring.
|
|
@@ -102,7 +103,8 @@ class LogApiCallImpl {
|
|
|
102
103
|
const response = await method(requestDto);
|
|
103
104
|
const durationMs = Date.now() - startMs;
|
|
104
105
|
const responseBody = this.serialize(response, methodInfo);
|
|
105
|
-
|
|
106
|
+
const count = this.selectResponseCount(response, responseCount, side, id);
|
|
107
|
+
stamp(new ApiCallInfo_1.ApiCallInfo(methodInfo, 'response', 'success', durationMs, requestSize, this.byteSize(responseBody), count), () => log.info(`[API-${side}-resp-SUCCESS] ${id} response=${responseBody}`));
|
|
106
108
|
return response;
|
|
107
109
|
}
|
|
108
110
|
catch (err) {
|
|
@@ -113,6 +115,41 @@ class LogApiCallImpl {
|
|
|
113
115
|
throw err;
|
|
114
116
|
}
|
|
115
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Evaluate an opt-in logical-item count without allowing observability code to affect the call.
|
|
120
|
+
* Warnings deliberately contain neither request nor response bodies (nor the invalid value).
|
|
121
|
+
*/
|
|
122
|
+
selectResponseCount(response, selector, side, id) {
|
|
123
|
+
if (!selector) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an observability callback must not fail the API call
|
|
127
|
+
try {
|
|
128
|
+
const count = selector(response);
|
|
129
|
+
if (count === undefined ||
|
|
130
|
+
(Number.isFinite(count) && Number.isInteger(count) && count >= 0)) {
|
|
131
|
+
return count;
|
|
132
|
+
}
|
|
133
|
+
this.warnInvalidResponseCount(side, id, 'returned an invalid value');
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
const error = (0, errorUtils_1.toError)(err);
|
|
137
|
+
void error;
|
|
138
|
+
this.warnInvalidResponseCount(side, id, 'threw');
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
warnInvalidResponseCount(side, id, reason) {
|
|
143
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a warning backend must not replace a successful API response
|
|
144
|
+
try {
|
|
145
|
+
log.warn(`[API-${side}-resp-COUNT-WARN] ${id} responseCount selector ${reason}; omitting responseCount`);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const error = (0, errorUtils_1.toError)(err);
|
|
149
|
+
void error;
|
|
150
|
+
// The API response is authoritative; observability failures are intentionally ignored here.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
116
153
|
/**
|
|
117
154
|
* Serialize a DTO for the LOG LINE ONLY. With no mask on the call, this is a plain JSON.stringify
|
|
118
155
|
* (byte-for-byte the old behavior, no walk) so existing callers pay nothing. With a mask, it runs
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LogApiCall.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/LogApiCall.ts"],"names":[],"mappings":"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","sourcesContent":["import {toError} from \"../lib/errorUtils\";\nimport {LogManager} from \"../logging/LogManager\";\nimport {ApiCallInfo} from \"./ApiCallInfo\";\nimport {ApiMethodInfo} from \"./ApiMethodInfo\";\nimport {ApiCallContext} from \"./ApiCallContext\";\nimport {WebpiecesCoreHeaders} from \"./WebpiecesCoreHeaders\";\nimport {LOG_API_CALL_LOGGER_NAME} from \"./ApiCallLogName\";\nimport {ClientRegistry} from \"./ClientRegistry\";\nimport {WEBPIECES_DEFAULT_FAILURE_CLASSIFIER} from \"./WebpiecesDefaultFailureClassifier\";\n\n// The console backends special-case THIS logger name into a self-describing [API.{side}.{phase}]\n// bracket (see ApiCallLogName) — so the name here and the name they match are the one constant.\nconst log = LogManager.getLogger(LOG_API_CALL_LOGGER_NAME);\n\n/**\n * LogApiCallImpl - Generic API call logging utility, used by BOTH server-side (LogApiFilter) and\n * client-side (ProxyClient) for one consistent logging shape across the framework.\n *\n * TWO things happen around each call:\n * 1. Text lines are emitted (the human-readable `[API-...]` patterns below).\n * 2. A structured {@link ApiCallInfo} tag is stamped into the ambient request context via the\n * {@link ApiCallContext} seam, so EVERY log line emitted during the call (not just the\n * req/resp lines) inherits a filterable `api` object — surfacing in GCP as\n * `jsonPayload.api.{method.{side,apiClass,methodName,controllerName},type,result}`.\n *\n * BROWSER-SAFE: this lives in core-util and runs in the browser bundle (via ProxyClient →\n * BrowserProxyClient), so it MUST NOT import `RequestContext` (Node async_hooks, and a circular dep).\n * It stamps through the {@link ApiCallContext} seam instead, and takes that seam as a REQUIRED\n * CONSTRUCTOR ARGUMENT — there is no process-global holder to install and none to forget. Each\n * environment-specific package constructs its own:\n *\n * LogApiFilter (@webpieces/http-routing) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * NodeProxyClient (@webpieces/http-client-node) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * TaskProxyClient (@webpieces/cloudtasks-client) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * BrowserProxyClient (@webpieces/http-client-browser) -> new LogApiCallImpl(new BrowserApiCallContext())\n *\n * NOT a singleton, deliberately: a shared instance would need a shared context, which is the global\n * this constructor replaced. Construct one where you know which environment you are in.\n *\n * Logging format patterns:\n * - [API-{side}-req] ClassName.methodName request={...}\n * - [API-{side}-resp-SUCCESS] ClassName.methodName response={...}\n * - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} (user errors)\n * - [API-{side}-resp-FAIL] ClassName.methodName error={...} (server errors)\n */\nexport class LogApiCallImpl {\n\n /**\n * @param ctx - the environment's {@link ApiCallContext}. REQUIRED, with no default: that is what\n * turns \"nobody bootstrapped the context\" into a compile error instead of a throw on the first\n * real call in production.\n */\n constructor(private readonly ctx: ApiCallContext) {}\n\n /**\n * Execute an API call with logging + `api` context-tagging around it.\n *\n * @param methodInfo - The transport-neutral call identity (side, apiClass, methodName,\n * controllerName?). `apiClass` is what matches a client call to its server handler in the logs.\n * @param requestDto - The request DTO (external multi-param callers synthesize a small object)\n * @param method - The method to execute\n *\n * Correlation fields (requestId, tenantId, ...) are NOT stamped here — a logging BACKEND owns that,\n * reading RequestContext on every record. What IS stamped here is the per-call `api` tag, and only\n * for the SYNCHRONOUS span of each log line: set → log → remove. Because the tag is never held across\n * `await method(...)`, a concurrent browser call (single-threaded, one global slot) can never clobber\n * it. Cost: only the `[API-*]` req/resp lines carry `api`, not lines emitted mid-call — which is\n * exactly what the GCP filters (`jsonPayload.api.*`) want.\n */\n public async execute(\n methodInfo: ApiMethodInfo,\n // webpieces-disable no-any-unknown -- DTO types are erased at the api/proxy boundary (matches ProxyClient)\n requestDto: any,\n // webpieces-disable no-any-unknown -- DTO types are erased at the api/proxy boundary\n method: (dto: any) => Promise<any>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the api/proxy boundary\n ): Promise<any> {\n const ctx = this.activeContext();\n const key = WebpiecesCoreHeaders.API_CALL_INFO;\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n // set → emit → remove, as ONE synchronous span: the tag is live only while the logger reads it,\n // never across an await, so a single browser global slot can never be clobbered by a concurrent call.\n const stamp = (info: ApiCallInfo, emit: () => void): void => {\n ctx.set(key, info);\n emit();\n ctx.remove(key);\n };\n\n // Stringify ONCE and reuse for both the log text and the size — a second JSON.stringify of a\n // large DTO purely to measure it would double the cost of the thing we are measuring.\n // Only take the field-masking hit when this call declared sensitive fields; otherwise the plain\n // JSON.stringify fast path, unchanged for every existing caller.\n const requestBody = this.serialize(requestDto, methodInfo);\n const requestSize = this.byteSize(requestBody);\n // Declared out here so the catch below can read it too. Reassigned just before the call, so\n // the number times ONLY the call and not our own request-logging.\n let startMs = Date.now();\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- LogApiCall logs errors before re-throwing to caller\n try {\n stamp(new ApiCallInfo(methodInfo, 'request', undefined, undefined, requestSize), () =>\n log.info(`[API-${side}-req] ${id} request=${requestBody}`));\n\n if(!requestDto)\n throw new Error(`Request cannot be null and was from ${id}`);\n\n startMs = Date.now();\n const response = await method(requestDto);\n const durationMs = Date.now() - startMs;\n\n const responseBody = this.serialize(response, methodInfo);\n stamp(\n new ApiCallInfo(\n methodInfo, 'response', 'success', durationMs, requestSize, this.byteSize(responseBody),\n ),\n () => log.info(`[API-${side}-resp-SUCCESS] ${id} response=${responseBody}`));\n\n return response;\n } catch (err: unknown) {\n const error = toError(err);\n // Duration comes off the SAME start as the success path, so a slow failure (a timeout, a\n // hung dependency) reports its real cost rather than nothing.\n this.logFailure(error, methodInfo, Date.now() - startMs, requestSize, stamp);\n throw err;\n }\n }\n\n /**\n * Serialize a DTO for the LOG LINE ONLY. With no mask on the call, this is a plain JSON.stringify\n * (byte-for-byte the old behavior, no walk) so existing callers pay nothing. With a mask, it runs\n * {@link MaskSpec.stringify}, which produces a masked STRING without ever mutating the DTO — so the\n * object handed to the transport, and thus the value ON THE WIRE, is unchanged.\n */\n private serialize(\n // webpieces-disable no-any-unknown -- DTO types are erased at the api/proxy boundary (matches execute)\n dto: unknown,\n methodInfo: ApiMethodInfo,\n ): string | undefined {\n return methodInfo.mask ? methodInfo.mask.stringify(dto) : JSON.stringify(dto);\n }\n\n /**\n * The ApiCallContext to stamp into. It cannot be MISSING (it is a constructor argument), but it\n * can be INACTIVE — a Node context used outside any `RequestContext.run(...)` scope. That throws:\n * an api call with nowhere to tag is a bug.\n */\n private activeContext(): ApiCallContext {\n const ctx = this.ctx;\n if (!ctx.isActive()) {\n throw new Error(\n 'LogApiCall requires an ACTIVE ApiCallContext. On a Node server, run inside a ' +\n 'RequestContext.run(...) scope — a server filter opens one per request, and a ' +\n 'non-webpieces host must open one around the work that calls a webpieces client. ' +\n '(A BrowserApiCallContext is always active, so this can only be the Node side.)',\n );\n }\n return ctx;\n }\n\n /**\n * Tag + log a thrown call. There is no responseSize — a throw produced no response body to measure.\n */\n private logFailure(\n error: Error,\n methodInfo: ApiMethodInfo,\n durationMs: number,\n requestSize: number | undefined,\n stamp: (info: ApiCallInfo, emit: () => void) => void,\n ): void {\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n const errorType = error.constructor.name;\n // Pluggable classification (ClientRegistry): a per-apiClass EXTERNAL-client classifier wins,\n // else the app default, else the webpieces built-in — which is side-dependent (a 4xx the SERVER\n // raised is a handled non-failure; the same 4xx a CLIENT receives means its call FAILED; 266 is\n // never a failure either side). `isUser` = \"treat as non-failure (OTHER / result:'success')\".\n const isUser = !ClientRegistry.classifyFailure(error, methodInfo);\n\n stamp(\n new ApiCallInfo(methodInfo, 'response', isUser ? 'success' : 'failure', durationMs, requestSize),\n () => isUser\n ? log.warn(`[API-${side}-resp-OTHER] ${id} errorType=${errorType}`)\n : log.error(`[API-${side}-resp-FAIL] ${id} errorType=${errorType} error=${error.message}`));\n }\n\n /**\n * UTF-8 byte size without platform-specific encoding globals: LogApiCall runs in the\n * browser bundle. Undefined in, undefined out — a `Promise<void>` method has no body to measure,\n * and a 0 there would be a lie (JSON.stringify(undefined) returns undefined, not '').\n */\n private byteSize(serialized: string | undefined): number | undefined {\n if (serialized === undefined) {\n return undefined;\n }\n let bytes = 0;\n for (const character of serialized) {\n const code = character.codePointAt(0)!;\n bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;\n }\n return bytes;\n }\n\n /**\n * Is this error a NON-failure for HEALTH/METRICS — the process working CORRECTLY (log OTHER, api\n * result:'success') — rather than a real failure to surface (log FAIL, result:'failure')?\n *\n * BACK-COMPAT SHIM: the canonical logic now lives in {@link WebpiecesDefaultFailureClassifier}\n * (the webpieces built-in tier), and the LIVE classification path is\n * {@link ClientRegistry.classifyFailure} (per-apiClass → app default → built-in). This method\n * delegates to the built-in so existing callers/tests keep the exact old behavior; it does NOT\n * consult registered classifiers. `apiClass`/`methodName` are irrelevant to the built-in (it reads\n * only `side`), hence the empty strings.\n *\n * @param error - The already-normalized error (callers pass toError(err), never a raw catch value)\n * @param server - True when this side is the SERVER handling an inbound call; false for a CLIENT's outbound call\n * @returns true if this should be treated as a non-failure (OTHER / result:'success')\n */\n isUserError(error: Error, server: boolean): boolean {\n return !WEBPIECES_DEFAULT_FAILURE_CLASSIFIER.isFailure(\n error,\n new ApiMethodInfo(server ? 'server' : 'client', '', ''),\n );\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"LogApiCall.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/LogApiCall.ts"],"names":[],"mappings":";;;AAAA,kDAA4C;AAC5C,sDAAmD;AACnD,+CAA4C;AAC5C,mDAAgD;AAEhD,iEAA8D;AAC9D,qDAA4D;AAC5D,qDAAkD;AAClD,2FAA2F;AAE3F,iGAAiG;AACjG,gGAAgG;AAChG,MAAM,GAAG,GAAG,uBAAU,CAAC,SAAS,CAAC,yCAAwB,CAAC,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAa,cAAc;IAMM;IAL7B;;;;OAIG;IACH,YAA6B,GAAmB;QAAnB,QAAG,GAAH,GAAG,CAAgB;IAAG,CAAC;IAEpD;;;;;;;;;;;;;;OAcG;IACI,KAAK,CAAC,OAAO,CAChB,UAAyB,EACzB,UAAa,EACb,MAA8B,EAC9B,aAAmD;QAEnD,MAAM,GAAG,GAAG,IAAI,CAAC,aAAa,EAAE,CAAC;QACjC,MAAM,GAAG,GAAG,2CAAoB,CAAC,aAAa,CAAC;QAC/C,MAAM,IAAI,GAAG,UAAU,CAAC,IAAI,CAAC;QAC7B,MAAM,EAAE,GAAG,GAAG,UAAU,CAAC,QAAQ,IAAI,UAAU,CAAC,UAAU,EAAE,CAAC;QAC7D,gGAAgG;QAChG,sGAAsG;QACtG,MAAM,KAAK,GAAG,CAAC,IAAiB,EAAE,IAAgB,EAAQ,EAAE;YACxD,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YACnB,8FAA8F;YAC9F,IAAI,CAAC;gBACD,IAAI,EAAE,CAAC;YACX,CAAC;oBAAS,CAAC;gBACP,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACpB,CAAC;QACL,CAAC,CAAC;QAEF,6FAA6F;QAC7F,sFAAsF;QACtF,gGAAgG;QAChG,iEAAiE;QACjE,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;QAC3D,MAAM,WAAW,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAC/C,4FAA4F;QAC5F,kEAAkE;QAClE,IAAI,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAEzB,qHAAqH;QACrH,IAAI,CAAC;YACD,KAAK,CAAC,IAAI,yBAAW,CAAC,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,WAAW,CAAC,EAAE,GAAG,EAAE,CAClF,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,SAAS,EAAE,YAAY,WAAW,EAAE,CAAC,CAC7D,CAAC;YAEF,IAAI,CAAC,UAAU;gBAAE,MAAM,IAAI,KAAK,CAAC,uCAAuC,EAAE,EAAE,CAAC,CAAC;YAE9E,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YACrB,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,CAAC;YAC1C,MAAM,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;YAExC,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;YAC1D,MAAM,KAAK,GAAG,IAAI,CAAC,mBAAmB,CAAC,QAAQ,EAAE,aAAa,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;YAC1E,KAAK,CACD,IAAI,yBAAW,CACX,UAAU,EACV,UAAU,EACV,SAAS,EACT,UAAU,EACV,WAAW,EACX,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,EAC3B,KAAK,CACR,EACD,GAAG,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,kBAAkB,EAAE,aAAa,YAAY,EAAE,CAAC,CAC9E,CAAC;YAEF,OAAO,QAAQ,CAAC;QACpB,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,oBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,yFAAyF;YACzF,8DAA8D;YAC9D,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,EAAE,WAAW,EAAE,KAAK,CAAC,CAAC;YAC7E,MAAM,GAAG,CAAC;QACd,CAAC;IACL,CAAC;IAED;;;OAGG;IACK,mBAAmB,CACvB,QAAW,EACX,QAA2D,EAC3D,IAAY,EACZ,EAAU;QAEV,IAAI,CAAC,QAAQ,EAAE,CAAC;YACZ,OAAO,SAAS,CAAC;QACrB,CAAC;QAED,sHAAsH;QACtH,IAAI,CAAC;YACD,MAAM,KAAK,GAAG,QAAQ,CAAC,QAAQ,CAAC,CAAC;YACjC,IACI,KAAK,KAAK,SAAS;gBACnB,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,EACnE,CAAC;gBACC,OAAO,KAAK,CAAC;YACjB,CAAC;YACD,IAAI,CAAC,wBAAwB,CAAC,IAAI,EAAE,EAAE,EAAE,2BAA2B,CAAC,CAAC;QACzE,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,oBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,IAAI,CAAC,wBAAwB,CAAC,IAAI,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC;QACrD,CAAC;QACD,OAAO,SAAS,CAAC;IACrB,CAAC;IAEO,wBAAwB,CAAC,IAAY,EAAE,EAAU,EAAE,MAAc;QACrE,8HAA8H;QAC9H,IAAI,CAAC;YACD,GAAG,CAAC,IAAI,CACJ,QAAQ,IAAI,qBAAqB,EAAE,2BAA2B,MAAM,0BAA0B,CACjG,CAAC;QACN,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,oBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,4FAA4F;QAChG,CAAC;IACL,CAAC;IAED;;;;;OAKG;IACK,SAAS;IACb,uGAAuG;IACvG,GAAY,EACZ,UAAyB;QAEzB,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IAClF,CAAC;IAED;;;;OAIG;IACK,aAAa;QACjB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC;QACrB,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,EAAE,CAAC;YAClB,MAAM,IAAI,KAAK,CACX,+EAA+E;gBAC3E,+EAA+E;gBAC/E,kFAAkF;gBAClF,gFAAgF,CACvF,CAAC;QACN,CAAC;QACD,OAAO,GAAG,CAAC;IACf,CAAC;IAED;;OAEG;IACK,UAAU,CACd,KAAY,EACZ,UAAyB,EACzB,UAAkB,EAClB,WAA+B,EAC/B,KAAoD;QAEpD,MAAM,IAAI,GAAG,UAAU,CAAC,IAAI,CAAC;QAC7B,MAAM,EAAE,GAAG,GAAG,UAAU,CAAC,QAAQ,IAAI,UAAU,CAAC,UAAU,EAAE,CAAC;QAC7D,MAAM,SAAS,GAAG,KAAK,CAAC,WAAW,CAAC,IAAI,CAAC;QACzC,6FAA6F;QAC7F,gGAAgG;QAChG,gGAAgG;QAChG,8FAA8F;QAC9F,MAAM,MAAM,GAAG,CAAC,+BAAc,CAAC,eAAe,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;QAElE,KAAK,CACD,IAAI,yBAAW,CACX,UAAU,EACV,UAAU,EACV,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,EAC9B,UAAU,EACV,WAAW,CACd,EACD,GAAG,EAAE,CACD,MAAM;YACF,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,gBAAgB,EAAE,cAAc,SAAS,EAAE,CAAC;YACnE,CAAC,CAAC,GAAG,CAAC,KAAK,CACL,QAAQ,IAAI,eAAe,EAAE,cAAc,SAAS,UAAU,KAAK,CAAC,OAAO,EAAE,CAChF,CACd,CAAC;IACN,CAAC;IAED;;;;OAIG;IACK,QAAQ,CAAC,UAA8B;QAC3C,IAAI,UAAU,KAAK,SAAS,EAAE,CAAC;YAC3B,OAAO,SAAS,CAAC;QACrB,CAAC;QACD,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;YACjC,MAAM,IAAI,GAAG,SAAS,CAAC,WAAW,CAAC,CAAC,CAAE,CAAC;YACvC,KAAK,IAAI,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAC3E,CAAC;QACD,OAAO,KAAK,CAAC;IACjB,CAAC;IAED;;;;;;;;;;;;;;OAcG;IACH,WAAW,CAAC,KAAY,EAAE,MAAe;QACrC,OAAO,CAAC,wEAAoC,CAAC,SAAS,CAClD,KAAK,EACL,IAAI,6BAAa,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,EAAE,EAAE,EAAE,EAAE,CAAC,CAC1D,CAAC;IACN,CAAC;CACJ;AAnPD,wCAmPC","sourcesContent":["import { toError } from '../lib/errorUtils';\nimport { LogManager } from '../logging/LogManager';\nimport { ApiCallInfo } from './ApiCallInfo';\nimport { ApiMethodInfo } from './ApiMethodInfo';\nimport { ApiCallContext } from './ApiCallContext';\nimport { WebpiecesCoreHeaders } from './WebpiecesCoreHeaders';\nimport { LOG_API_CALL_LOGGER_NAME } from './ApiCallLogName';\nimport { ClientRegistry } from './ClientRegistry';\nimport { WEBPIECES_DEFAULT_FAILURE_CLASSIFIER } from './WebpiecesDefaultFailureClassifier';\n\n// The console backends special-case THIS logger name into a self-describing [API.{side}.{phase}]\n// bracket (see ApiCallLogName) — so the name here and the name they match are the one constant.\nconst log = LogManager.getLogger(LOG_API_CALL_LOGGER_NAME);\n\n/**\n * LogApiCallImpl - Generic API call logging utility, used by BOTH server-side (LogApiFilter) and\n * client-side (ProxyClient) for one consistent logging shape across the framework.\n *\n * TWO things happen around each call:\n * 1. Text lines are emitted (the human-readable `[API-...]` patterns below).\n * 2. A structured {@link ApiCallInfo} tag is stamped into the ambient request context via the\n * {@link ApiCallContext} seam, so EVERY log line emitted during the call (not just the\n * req/resp lines) inherits a filterable `api` object — surfacing in GCP as\n * `jsonPayload.api.{method.{side,apiClass,methodName,controllerName},type,result}`.\n *\n * BROWSER-SAFE: this lives in core-util and runs in the browser bundle (via ProxyClient →\n * BrowserProxyClient), so it MUST NOT import `RequestContext` (Node async_hooks, and a circular dep).\n * It stamps through the {@link ApiCallContext} seam instead, and takes that seam as a REQUIRED\n * CONSTRUCTOR ARGUMENT — there is no process-global holder to install and none to forget. Each\n * environment-specific package constructs its own:\n *\n * LogApiFilter (@webpieces/http-routing) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * NodeProxyClient (@webpieces/http-client-node) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * TaskProxyClient (@webpieces/cloudtasks-client) -> new LogApiCallImpl(new RequestContextApiCallContext())\n * BrowserProxyClient (@webpieces/http-client-browser) -> new LogApiCallImpl(new BrowserApiCallContext())\n *\n * NOT a singleton, deliberately: a shared instance would need a shared context, which is the global\n * this constructor replaced. Construct one where you know which environment you are in.\n *\n * Logging format patterns:\n * - [API-{side}-req] ClassName.methodName request={...}\n * - [API-{side}-resp-SUCCESS] ClassName.methodName response={...}\n * - [API-{side}-resp-OTHER] ClassName.methodName errorType={...} (user errors)\n * - [API-{side}-resp-FAIL] ClassName.methodName error={...} (server errors)\n */\nexport class LogApiCallImpl {\n /**\n * @param ctx - the environment's {@link ApiCallContext}. REQUIRED, with no default: that is what\n * turns \"nobody bootstrapped the context\" into a compile error instead of a throw on the first\n * real call in production.\n */\n constructor(private readonly ctx: ApiCallContext) {}\n\n /**\n * Execute an API call with logging + `api` context-tagging around it.\n *\n * @param methodInfo - The transport-neutral call identity (side, apiClass, methodName,\n * controllerName?). `apiClass` is what matches a client call to its server handler in the logs.\n * @param requestDto - The request DTO (external multi-param callers synthesize a small object)\n * @param method - The method to execute\n *\n * Correlation fields (requestId, tenantId, ...) are NOT stamped here — a logging BACKEND owns that,\n * reading RequestContext on every record. What IS stamped here is the per-call `api` tag, and only\n * for the SYNCHRONOUS span of each log line: set → log → remove. Because the tag is never held across\n * `await method(...)`, a concurrent browser call (single-threaded, one global slot) can never clobber\n * it. Cost: only the `[API-*]` req/resp lines carry `api`, not lines emitted mid-call — which is\n * exactly what the GCP filters (`jsonPayload.api.*`) want.\n */\n public async execute<Q, R>(\n methodInfo: ApiMethodInfo,\n requestDto: Q,\n method: (dto: Q) => Promise<R>,\n responseCount?: (response: R) => number | undefined,\n ): Promise<R> {\n const ctx = this.activeContext();\n const key = WebpiecesCoreHeaders.API_CALL_INFO;\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n // set → emit → remove, as ONE synchronous span: the tag is live only while the logger reads it,\n // never across an await, so a single browser global slot can never be clobbered by a concurrent call.\n const stamp = (info: ApiCallInfo, emit: () => void): void => {\n ctx.set(key, info);\n // webpieces-disable no-unmanaged-exceptions -- cleanup must run when a logging backend throws\n try {\n emit();\n } finally {\n ctx.remove(key);\n }\n };\n\n // Stringify ONCE and reuse for both the log text and the size — a second JSON.stringify of a\n // large DTO purely to measure it would double the cost of the thing we are measuring.\n // Only take the field-masking hit when this call declared sensitive fields; otherwise the plain\n // JSON.stringify fast path, unchanged for every existing caller.\n const requestBody = this.serialize(requestDto, methodInfo);\n const requestSize = this.byteSize(requestBody);\n // Declared out here so the catch below can read it too. Reassigned just before the call, so\n // the number times ONLY the call and not our own request-logging.\n let startMs = Date.now();\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- LogApiCall logs errors before re-throwing to caller\n try {\n stamp(new ApiCallInfo(methodInfo, 'request', undefined, undefined, requestSize), () =>\n log.info(`[API-${side}-req] ${id} request=${requestBody}`),\n );\n\n if (!requestDto) throw new Error(`Request cannot be null and was from ${id}`);\n\n startMs = Date.now();\n const response = await method(requestDto);\n const durationMs = Date.now() - startMs;\n\n const responseBody = this.serialize(response, methodInfo);\n const count = this.selectResponseCount(response, responseCount, side, id);\n stamp(\n new ApiCallInfo(\n methodInfo,\n 'response',\n 'success',\n durationMs,\n requestSize,\n this.byteSize(responseBody),\n count,\n ),\n () => log.info(`[API-${side}-resp-SUCCESS] ${id} response=${responseBody}`),\n );\n\n return response;\n } catch (err: unknown) {\n const error = toError(err);\n // Duration comes off the SAME start as the success path, so a slow failure (a timeout, a\n // hung dependency) reports its real cost rather than nothing.\n this.logFailure(error, methodInfo, Date.now() - startMs, requestSize, stamp);\n throw err;\n }\n }\n\n /**\n * Evaluate an opt-in logical-item count without allowing observability code to affect the call.\n * Warnings deliberately contain neither request nor response bodies (nor the invalid value).\n */\n private selectResponseCount<R>(\n response: R,\n selector: ((response: R) => number | undefined) | undefined,\n side: string,\n id: string,\n ): number | undefined {\n if (!selector) {\n return undefined;\n }\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an observability callback must not fail the API call\n try {\n const count = selector(response);\n if (\n count === undefined ||\n (Number.isFinite(count) && Number.isInteger(count) && count >= 0)\n ) {\n return count;\n }\n this.warnInvalidResponseCount(side, id, 'returned an invalid value');\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n this.warnInvalidResponseCount(side, id, 'threw');\n }\n return undefined;\n }\n\n private warnInvalidResponseCount(side: string, id: string, reason: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a warning backend must not replace a successful API response\n try {\n log.warn(\n `[API-${side}-resp-COUNT-WARN] ${id} responseCount selector ${reason}; omitting responseCount`,\n );\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // The API response is authoritative; observability failures are intentionally ignored here.\n }\n }\n\n /**\n * Serialize a DTO for the LOG LINE ONLY. With no mask on the call, this is a plain JSON.stringify\n * (byte-for-byte the old behavior, no walk) so existing callers pay nothing. With a mask, it runs\n * {@link MaskSpec.stringify}, which produces a masked STRING without ever mutating the DTO — so the\n * object handed to the transport, and thus the value ON THE WIRE, is unchanged.\n */\n private serialize(\n // webpieces-disable no-any-unknown -- DTO types are erased at the api/proxy boundary (matches execute)\n dto: unknown,\n methodInfo: ApiMethodInfo,\n ): string | undefined {\n return methodInfo.mask ? methodInfo.mask.stringify(dto) : JSON.stringify(dto);\n }\n\n /**\n * The ApiCallContext to stamp into. It cannot be MISSING (it is a constructor argument), but it\n * can be INACTIVE — a Node context used outside any `RequestContext.run(...)` scope. That throws:\n * an api call with nowhere to tag is a bug.\n */\n private activeContext(): ApiCallContext {\n const ctx = this.ctx;\n if (!ctx.isActive()) {\n throw new Error(\n 'LogApiCall requires an ACTIVE ApiCallContext. On a Node server, run inside a ' +\n 'RequestContext.run(...) scope — a server filter opens one per request, and a ' +\n 'non-webpieces host must open one around the work that calls a webpieces client. ' +\n '(A BrowserApiCallContext is always active, so this can only be the Node side.)',\n );\n }\n return ctx;\n }\n\n /**\n * Tag + log a thrown call. There is no responseSize — a throw produced no response body to measure.\n */\n private logFailure(\n error: Error,\n methodInfo: ApiMethodInfo,\n durationMs: number,\n requestSize: number | undefined,\n stamp: (info: ApiCallInfo, emit: () => void) => void,\n ): void {\n const side = methodInfo.side;\n const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;\n const errorType = error.constructor.name;\n // Pluggable classification (ClientRegistry): a per-apiClass EXTERNAL-client classifier wins,\n // else the app default, else the webpieces built-in — which is side-dependent (a 4xx the SERVER\n // raised is a handled non-failure; the same 4xx a CLIENT receives means its call FAILED; 266 is\n // never a failure either side). `isUser` = \"treat as non-failure (OTHER / result:'success')\".\n const isUser = !ClientRegistry.classifyFailure(error, methodInfo);\n\n stamp(\n new ApiCallInfo(\n methodInfo,\n 'response',\n isUser ? 'success' : 'failure',\n durationMs,\n requestSize,\n ),\n () =>\n isUser\n ? log.warn(`[API-${side}-resp-OTHER] ${id} errorType=${errorType}`)\n : log.error(\n `[API-${side}-resp-FAIL] ${id} errorType=${errorType} error=${error.message}`,\n ),\n );\n }\n\n /**\n * UTF-8 byte size without platform-specific encoding globals: LogApiCall runs in the\n * browser bundle. Undefined in, undefined out — a `Promise<void>` method has no body to measure,\n * and a 0 there would be a lie (JSON.stringify(undefined) returns undefined, not '').\n */\n private byteSize(serialized: string | undefined): number | undefined {\n if (serialized === undefined) {\n return undefined;\n }\n let bytes = 0;\n for (const character of serialized) {\n const code = character.codePointAt(0)!;\n bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;\n }\n return bytes;\n }\n\n /**\n * Is this error a NON-failure for HEALTH/METRICS — the process working CORRECTLY (log OTHER, api\n * result:'success') — rather than a real failure to surface (log FAIL, result:'failure')?\n *\n * BACK-COMPAT SHIM: the canonical logic now lives in {@link WebpiecesDefaultFailureClassifier}\n * (the webpieces built-in tier), and the LIVE classification path is\n * {@link ClientRegistry.classifyFailure} (per-apiClass → app default → built-in). This method\n * delegates to the built-in so existing callers/tests keep the exact old behavior; it does NOT\n * consult registered classifiers. `apiClass`/`methodName` are irrelevant to the built-in (it reads\n * only `side`), hence the empty strings.\n *\n * @param error - The already-normalized error (callers pass toError(err), never a raw catch value)\n * @param server - True when this side is the SERVER handling an inbound call; false for a CLIENT's outbound call\n * @returns true if this should be treated as a non-failure (OTHER / result:'success')\n */\n isUserError(error: Error, server: boolean): boolean {\n return !WEBPIECES_DEFAULT_FAILURE_CLASSIFIER.isFailure(\n error,\n new ApiMethodInfo(server ? 'server' : 'client', '', ''),\n );\n }\n}\n"]}
|
|
@@ -21,13 +21,16 @@ export declare class IpcConnectionOptions {
|
|
|
21
21
|
readonly scheduler: IpcScheduler;
|
|
22
22
|
readonly errors: IpcErrorOwner;
|
|
23
23
|
readonly maxMessageCharacters: number;
|
|
24
|
-
|
|
24
|
+
readonly settlementHistoryLimit: number;
|
|
25
|
+
readonly connectionId: string;
|
|
26
|
+
constructor(timeoutMs: number, nextId: () => string, scheduler: IpcScheduler, errors: IpcErrorOwner, maxMessageCharacters?: number, settlementHistoryLimit?: number, connectionId?: string);
|
|
25
27
|
}
|
|
26
28
|
/** One connection owns both directions, one dispatcher, and all pending-call lifetimes. */
|
|
27
29
|
export declare class IpcConnection {
|
|
28
30
|
private readonly transport;
|
|
29
31
|
private readonly options;
|
|
30
32
|
private readonly pending;
|
|
33
|
+
private readonly settlements;
|
|
31
34
|
private readonly activeRequests;
|
|
32
35
|
private readonly usedIds;
|
|
33
36
|
private handler?;
|
|
@@ -43,6 +46,10 @@ export declare class IpcConnection {
|
|
|
43
46
|
private receive;
|
|
44
47
|
private dispatch;
|
|
45
48
|
private settle;
|
|
49
|
+
private remember;
|
|
50
|
+
private lateReplyError;
|
|
51
|
+
private correlationError;
|
|
52
|
+
private correlationFields;
|
|
46
53
|
private fail;
|
|
47
54
|
}
|
|
48
55
|
export declare class IpcErrors {
|
package/src/ipc/IpcConnection.js
CHANGED
|
@@ -20,21 +20,37 @@ class IpcConnectionOptions {
|
|
|
20
20
|
scheduler;
|
|
21
21
|
errors;
|
|
22
22
|
maxMessageCharacters;
|
|
23
|
-
|
|
23
|
+
settlementHistoryLimit;
|
|
24
|
+
connectionId;
|
|
25
|
+
constructor(timeoutMs, nextId, scheduler, errors, maxMessageCharacters = 1_000_000, settlementHistoryLimit = 100, connectionId = 'unspecified') {
|
|
24
26
|
this.timeoutMs = timeoutMs;
|
|
25
27
|
this.nextId = nextId;
|
|
26
28
|
this.scheduler = scheduler;
|
|
27
29
|
this.errors = errors;
|
|
28
30
|
this.maxMessageCharacters = maxMessageCharacters;
|
|
31
|
+
this.settlementHistoryLimit = settlementHistoryLimit;
|
|
32
|
+
this.connectionId = connectionId;
|
|
29
33
|
if (!Number.isFinite(timeoutMs) ||
|
|
30
34
|
timeoutMs <= 0 ||
|
|
31
35
|
!Number.isSafeInteger(maxMessageCharacters) ||
|
|
32
|
-
maxMessageCharacters < 1
|
|
36
|
+
maxMessageCharacters < 1 ||
|
|
37
|
+
!Number.isSafeInteger(settlementHistoryLimit) ||
|
|
38
|
+
settlementHistoryLimit < 1) {
|
|
33
39
|
throw new errors_1.InternalError('IPC timeout and message limit must be positive');
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
42
|
}
|
|
37
43
|
exports.IpcConnectionOptions = IpcConnectionOptions;
|
|
44
|
+
class SettlementRecord {
|
|
45
|
+
context;
|
|
46
|
+
reason;
|
|
47
|
+
settledAt;
|
|
48
|
+
constructor(context, reason, settledAt) {
|
|
49
|
+
this.context = context;
|
|
50
|
+
this.reason = reason;
|
|
51
|
+
this.settledAt = settledAt;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
38
54
|
class PendingCall {
|
|
39
55
|
context;
|
|
40
56
|
resolve;
|
|
@@ -51,6 +67,7 @@ class IpcConnection {
|
|
|
51
67
|
transport;
|
|
52
68
|
options;
|
|
53
69
|
pending = new Map();
|
|
70
|
+
settlements = new Map();
|
|
54
71
|
activeRequests = new Set();
|
|
55
72
|
usedIds = new Set();
|
|
56
73
|
handler;
|
|
@@ -85,11 +102,11 @@ class IpcConnection {
|
|
|
85
102
|
const pending = new PendingCall(request.context, resolve, reject);
|
|
86
103
|
this.pending.set(request.context.callId, pending);
|
|
87
104
|
pending.cancelTimer = this.options.scheduler.schedule(() => {
|
|
88
|
-
this.settle(request.context.callId, new TimeoutError_1.TimeoutError(this.options.timeoutMs, new CallStrategy_1.CallContext(request.apiId, request.methodId)));
|
|
105
|
+
this.settle(request.context.callId, new TimeoutError_1.TimeoutError(this.options.timeoutMs, new CallStrategy_1.CallContext(request.apiId, request.methodId)), 'expired');
|
|
89
106
|
}, this.options.timeoutMs);
|
|
90
107
|
// This owner observes every async send rejection; a send failure is never a timeout.
|
|
91
108
|
void this.send(request).catch((error) => {
|
|
92
|
-
this.settle(request.context.callId, new IpcTransportError('IPC send failed', IpcErrors.normalize(error)));
|
|
109
|
+
this.settle(request.context.callId, new IpcTransportError('IPC send failed', IpcErrors.normalize(error)), 'rejected');
|
|
93
110
|
});
|
|
94
111
|
});
|
|
95
112
|
}
|
|
@@ -124,13 +141,14 @@ class IpcConnection {
|
|
|
124
141
|
const pending = this.pending.get(message.context.callId);
|
|
125
142
|
// Late or unsolicited replies are reported, never resolve a different call or replay work.
|
|
126
143
|
if (!pending) {
|
|
127
|
-
this.options.errors.report(
|
|
144
|
+
this.options.errors.report(this.lateReplyError(message));
|
|
128
145
|
return;
|
|
129
146
|
}
|
|
130
147
|
if (!IpcProtocol_1.IpcProtocol.sameContext(pending.context, message.context))
|
|
131
|
-
throw
|
|
148
|
+
throw this.correlationError(message, pending.context);
|
|
132
149
|
this.pending.delete(message.context.callId);
|
|
133
150
|
pending.cancelTimer();
|
|
151
|
+
this.remember(pending.context, 'resolved');
|
|
134
152
|
pending.resolve(message);
|
|
135
153
|
}
|
|
136
154
|
catch (err) {
|
|
@@ -157,20 +175,55 @@ class IpcConnection {
|
|
|
157
175
|
}
|
|
158
176
|
await this.send(reply);
|
|
159
177
|
}
|
|
160
|
-
settle(id, error) {
|
|
178
|
+
settle(id, error, reason) {
|
|
161
179
|
const pending = this.pending.get(id);
|
|
162
180
|
if (!pending)
|
|
163
181
|
return;
|
|
164
182
|
this.pending.delete(id);
|
|
165
183
|
pending.cancelTimer();
|
|
184
|
+
this.remember(pending.context, reason);
|
|
166
185
|
pending.reject(error);
|
|
167
186
|
}
|
|
187
|
+
remember(context, reason) {
|
|
188
|
+
this.settlements.delete(context.callId);
|
|
189
|
+
this.settlements.set(context.callId, new SettlementRecord(context, reason, Date.now()));
|
|
190
|
+
while (this.settlements.size > this.options.settlementHistoryLimit) {
|
|
191
|
+
const oldest = this.settlements.keys().next().value;
|
|
192
|
+
if (typeof oldest !== 'string')
|
|
193
|
+
return;
|
|
194
|
+
this.settlements.delete(oldest);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
lateReplyError(reply) {
|
|
198
|
+
const previous = this.settlements.get(reply.context.callId);
|
|
199
|
+
let classification = 'unknown';
|
|
200
|
+
if (previous) {
|
|
201
|
+
classification = IpcProtocol_1.IpcProtocol.sameContext(previous.context, reply.context)
|
|
202
|
+
? previous.reason === 'expired'
|
|
203
|
+
? 'expired'
|
|
204
|
+
: 'duplicate-settled'
|
|
205
|
+
: 'correlation-mismatched';
|
|
206
|
+
}
|
|
207
|
+
const settlement = previous
|
|
208
|
+
? ` terminal=${previous.reason} ageMs=${String(Math.max(0, Date.now() - previous.settledAt))}`
|
|
209
|
+
: '';
|
|
210
|
+
return new errors_1.InternalError(`Late or unknown IPC reply classification=${classification}${settlement} ${this.correlationFields(reply.context)}`);
|
|
211
|
+
}
|
|
212
|
+
correlationError(reply, expected) {
|
|
213
|
+
return new errors_1.InternalError(`IPC reply correlation mismatch classification=correlation-mismatched ` +
|
|
214
|
+
`${this.correlationFields(reply.context)} expectedTxId=${JSON.stringify(expected.txId)}`);
|
|
215
|
+
}
|
|
216
|
+
correlationFields(context) {
|
|
217
|
+
return (`callId=${JSON.stringify(context.callId)} txId=${JSON.stringify(context.txId)} ` +
|
|
218
|
+
`parentCallId=${JSON.stringify(context.parentCallId ?? null)} ` +
|
|
219
|
+
`connectionId=${JSON.stringify(this.options.connectionId)}`);
|
|
220
|
+
}
|
|
168
221
|
fail(error) {
|
|
169
222
|
if (this.closed)
|
|
170
223
|
return;
|
|
171
224
|
this.closed = error;
|
|
172
225
|
for (const id of this.pending.keys())
|
|
173
|
-
this.settle(id, error);
|
|
226
|
+
this.settle(id, error, 'rejected');
|
|
174
227
|
this.options.errors.report(error);
|
|
175
228
|
}
|
|
176
229
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"IpcConnection.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/ipc/IpcConnection.ts"],"names":[],"mappings":"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","sourcesContent":["import { toError } from '../lib/errorUtils';\nimport { TimeoutError } from '../http/TimeoutError';\nimport { CallContext } from '../http/CallStrategy';\nimport { ApiErrorCodec, EndpointNotFoundError, InternalError } from '../errors';\nimport {\n IpcCallContext,\n IpcFailure,\n IpcMessage,\n IpcReply,\n IpcRequest,\n IpcProtocol,\n} from './IpcProtocol';\n\n/** A local connection failed, distinct from a remote implementation's semantic ApiError. */\nexport class IpcTransportError extends Error {\n constructor(message: string, cause?: Error) {\n super(message, { cause });\n this.name = 'IpcTransportError';\n }\n}\n\n/** Implement this at the host transport boundary; no vendor or platform declarations leak out. */\nexport interface IpcTransport {\n send(message: string): Promise<void> | void;\n subscribe(\n receive: (message: string) => void,\n failed: (error: Error) => void,\n closed: (error: Error) => void,\n ): () => void;\n close(): void;\n}\nexport interface IpcScheduler {\n schedule(callback: () => void, delayMs: number): () => void;\n}\nexport interface IpcErrorOwner {\n report(error: Error): void;\n}\nexport class IpcConnectionOptions {\n constructor(\n readonly timeoutMs: number,\n readonly nextId: () => string,\n readonly scheduler: IpcScheduler,\n readonly errors: IpcErrorOwner,\n readonly maxMessageCharacters = 1_000_000,\n ) {\n if (\n !Number.isFinite(timeoutMs) ||\n timeoutMs <= 0 ||\n !Number.isSafeInteger(maxMessageCharacters) ||\n maxMessageCharacters < 1\n ) {\n throw new InternalError('IPC timeout and message limit must be positive');\n }\n }\n}\n\nclass PendingCall {\n cancelTimer: () => void = () => {};\n constructor(\n readonly context: IpcCallContext,\n readonly resolve: (reply: IpcReply) => void,\n readonly reject: (error: Error) => void,\n ) {}\n}\n\n/** One connection owns both directions, one dispatcher, and all pending-call lifetimes. */\nexport class IpcConnection {\n private readonly pending = new Map<string, PendingCall>();\n private readonly activeRequests = new Set<string>();\n private readonly usedIds = new Set<string>();\n private handler?: (request: IpcRequest) => Promise<IpcReply>;\n private closed?: Error;\n private readonly unsubscribe: () => void;\n\n constructor(\n private readonly transport: IpcTransport,\n private readonly options: IpcConnectionOptions,\n ) {\n this.unsubscribe = transport.subscribe(\n (message) => this.receive(message),\n (error) => this.fail(new IpcTransportError('IPC transport failed', error)),\n (error) => this.fail(new IpcTransportError('IPC connection closed', error)),\n );\n }\n\n /** Install exactly one factory dispatcher BEFORE exposing this connection to the peer. */\n setHandler(handler: (request: IpcRequest) => Promise<IpcReply>): void {\n if (this.handler) throw new InternalError('An IPC connection already has a dispatcher');\n if (this.closed) throw this.closed;\n this.handler = handler;\n }\n\n newContext(parent?: IpcCallContext): IpcCallContext {\n const id = this.options.nextId();\n if (this.usedIds.has(id))\n throw new InternalError('IPC identity generator reused a call ID');\n this.usedIds.add(id);\n return new IpcCallContext(parent?.txId ?? id, id, parent?.callId);\n }\n\n request(request: IpcRequest): Promise<IpcReply> {\n if (this.closed) return Promise.reject(this.closed);\n if (this.pending.has(request.context.callId))\n return Promise.reject(new InternalError('Duplicate pending IPC call'));\n return new Promise<IpcReply>((resolve, reject) => {\n const pending = new PendingCall(request.context, resolve, reject);\n this.pending.set(request.context.callId, pending);\n pending.cancelTimer = this.options.scheduler.schedule(() => {\n this.settle(\n request.context.callId,\n new TimeoutError(\n this.options.timeoutMs,\n new CallContext(request.apiId, request.methodId),\n ),\n );\n }, this.options.timeoutMs);\n // This owner observes every async send rejection; a send failure is never a timeout.\n void this.send(request).catch((error) => {\n this.settle(\n request.context.callId,\n new IpcTransportError('IPC send failed', IpcErrors.normalize(error)),\n );\n });\n });\n }\n\n dispose(): void {\n this.fail(new IpcTransportError('IPC connection disposed'));\n this.unsubscribe();\n this.transport.close();\n }\n\n private async send(message: IpcMessage): Promise<void> {\n if (this.closed) throw this.closed;\n const json = JSON.stringify(message);\n if (json.length > this.options.maxMessageCharacters)\n throw new InternalError('IPC message exceeds configured limit');\n await this.transport.send(json);\n }\n\n private receive(json: string): void {\n if (this.closed) return;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- transport boundary rejects pending calls or encodes a failure that the client throws\n try {\n if (json.length > this.options.maxMessageCharacters)\n throw new InternalError('IPC message exceeds configured limit');\n const message = IpcProtocol.parse(json);\n if (message.type === 'request') {\n if (this.activeRequests.has(message.context.callId))\n throw new InternalError('Duplicate active IPC request');\n this.activeRequests.add(message.context.callId);\n void this.dispatch(message).catch((error) => this.fail(IpcErrors.normalize(error)));\n return;\n }\n const pending = this.pending.get(message.context.callId);\n // Late or unsolicited replies are reported, never resolve a different call or replay work.\n if (!pending) {\n this.options.errors.report(new InternalError('Late or unknown IPC reply'));\n return;\n }\n if (!IpcProtocol.sameContext(pending.context, message.context))\n throw new InternalError('IPC reply correlation mismatch');\n this.pending.delete(message.context.callId);\n pending.cancelTimer();\n pending.resolve(message);\n } catch (err: unknown) {\n const error = toError(err);\n this.fail(IpcErrors.normalize(error));\n }\n }\n\n private async dispatch(request: IpcRequest): Promise<void> {\n let reply: IpcReply;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- transport boundary rejects pending calls or encodes a failure that the client throws\n try {\n if (!this.handler) throw new EndpointNotFoundError('IPC dispatcher is not installed');\n reply = await this.handler(request);\n if (!IpcProtocol.sameContext(request.context, reply.context))\n throw new InternalError('IPC handler reply correlation mismatch');\n } catch (err: unknown) {\n const error = toError(err);\n reply = new IpcFailure(request.context, ApiErrorCodec.encode(error));\n } finally {\n this.activeRequests.delete(request.context.callId);\n }\n await this.send(reply);\n }\n\n private settle(id: string, error: Error): void {\n const pending = this.pending.get(id);\n if (!pending) return;\n this.pending.delete(id);\n pending.cancelTimer();\n pending.reject(error);\n }\n\n private fail(error: Error): void {\n if (this.closed) return;\n this.closed = error;\n for (const id of this.pending.keys()) this.settle(id, error);\n this.options.errors.report(error);\n }\n}\n\nexport class IpcErrors {\n // webpieces-disable no-function-outside-class -- portable stateless IPC primitive; no platform DI container exists on this boundary; webpieces-disable no-any-unknown -- untrusted IPC data is schema-validated; generic dispatch cannot assume a DTO type before validation\n static normalize(value: unknown): Error {\n return value instanceof Error\n ? value\n : new InternalError('IPC failed with a non-Error value');\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"IpcConnection.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/ipc/IpcConnection.ts"],"names":[],"mappings":"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","sourcesContent":["import { toError } from '../lib/errorUtils';\nimport { TimeoutError } from '../http/TimeoutError';\nimport { CallContext } from '../http/CallStrategy';\nimport { ApiErrorCodec, EndpointNotFoundError, InternalError } from '../errors';\nimport {\n IpcCallContext,\n IpcFailure,\n IpcMessage,\n IpcReply,\n IpcRequest,\n IpcProtocol,\n} from './IpcProtocol';\n\n/** A local connection failed, distinct from a remote implementation's semantic ApiError. */\nexport class IpcTransportError extends Error {\n constructor(message: string, cause?: Error) {\n super(message, { cause });\n this.name = 'IpcTransportError';\n }\n}\n\n/** Implement this at the host transport boundary; no vendor or platform declarations leak out. */\nexport interface IpcTransport {\n send(message: string): Promise<void> | void;\n subscribe(\n receive: (message: string) => void,\n failed: (error: Error) => void,\n closed: (error: Error) => void,\n ): () => void;\n close(): void;\n}\nexport interface IpcScheduler {\n schedule(callback: () => void, delayMs: number): () => void;\n}\nexport interface IpcErrorOwner {\n report(error: Error): void;\n}\nexport class IpcConnectionOptions {\n constructor(\n readonly timeoutMs: number,\n readonly nextId: () => string,\n readonly scheduler: IpcScheduler,\n readonly errors: IpcErrorOwner,\n readonly maxMessageCharacters = 1_000_000,\n readonly settlementHistoryLimit = 100,\n readonly connectionId = 'unspecified',\n ) {\n if (\n !Number.isFinite(timeoutMs) ||\n timeoutMs <= 0 ||\n !Number.isSafeInteger(maxMessageCharacters) ||\n maxMessageCharacters < 1 ||\n !Number.isSafeInteger(settlementHistoryLimit) ||\n settlementHistoryLimit < 1\n ) {\n throw new InternalError('IPC timeout and message limit must be positive');\n }\n }\n}\n\ntype SettlementReason = 'resolved' | 'expired' | 'rejected';\nclass SettlementRecord {\n constructor(\n readonly context: IpcCallContext,\n readonly reason: SettlementReason,\n readonly settledAt: number,\n ) {}\n}\n\nclass PendingCall {\n cancelTimer: () => void = () => {};\n constructor(\n readonly context: IpcCallContext,\n readonly resolve: (reply: IpcReply) => void,\n readonly reject: (error: Error) => void,\n ) {}\n}\n\n/** One connection owns both directions, one dispatcher, and all pending-call lifetimes. */\nexport class IpcConnection {\n private readonly pending = new Map<string, PendingCall>();\n private readonly settlements = new Map<string, SettlementRecord>();\n private readonly activeRequests = new Set<string>();\n private readonly usedIds = new Set<string>();\n private handler?: (request: IpcRequest) => Promise<IpcReply>;\n private closed?: Error;\n private readonly unsubscribe: () => void;\n\n constructor(\n private readonly transport: IpcTransport,\n private readonly options: IpcConnectionOptions,\n ) {\n this.unsubscribe = transport.subscribe(\n (message) => this.receive(message),\n (error) => this.fail(new IpcTransportError('IPC transport failed', error)),\n (error) => this.fail(new IpcTransportError('IPC connection closed', error)),\n );\n }\n\n /** Install exactly one factory dispatcher BEFORE exposing this connection to the peer. */\n setHandler(handler: (request: IpcRequest) => Promise<IpcReply>): void {\n if (this.handler) throw new InternalError('An IPC connection already has a dispatcher');\n if (this.closed) throw this.closed;\n this.handler = handler;\n }\n\n newContext(parent?: IpcCallContext): IpcCallContext {\n const id = this.options.nextId();\n if (this.usedIds.has(id))\n throw new InternalError('IPC identity generator reused a call ID');\n this.usedIds.add(id);\n return new IpcCallContext(parent?.txId ?? id, id, parent?.callId);\n }\n\n request(request: IpcRequest): Promise<IpcReply> {\n if (this.closed) return Promise.reject(this.closed);\n if (this.pending.has(request.context.callId))\n return Promise.reject(new InternalError('Duplicate pending IPC call'));\n return new Promise<IpcReply>((resolve, reject) => {\n const pending = new PendingCall(request.context, resolve, reject);\n this.pending.set(request.context.callId, pending);\n pending.cancelTimer = this.options.scheduler.schedule(() => {\n this.settle(\n request.context.callId,\n new TimeoutError(\n this.options.timeoutMs,\n new CallContext(request.apiId, request.methodId),\n ),\n 'expired',\n );\n }, this.options.timeoutMs);\n // This owner observes every async send rejection; a send failure is never a timeout.\n void this.send(request).catch((error) => {\n this.settle(\n request.context.callId,\n new IpcTransportError('IPC send failed', IpcErrors.normalize(error)),\n 'rejected',\n );\n });\n });\n }\n\n dispose(): void {\n this.fail(new IpcTransportError('IPC connection disposed'));\n this.unsubscribe();\n this.transport.close();\n }\n\n private async send(message: IpcMessage): Promise<void> {\n if (this.closed) throw this.closed;\n const json = JSON.stringify(message);\n if (json.length > this.options.maxMessageCharacters)\n throw new InternalError('IPC message exceeds configured limit');\n await this.transport.send(json);\n }\n\n private receive(json: string): void {\n if (this.closed) return;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- transport boundary rejects pending calls or encodes a failure that the client throws\n try {\n if (json.length > this.options.maxMessageCharacters)\n throw new InternalError('IPC message exceeds configured limit');\n const message = IpcProtocol.parse(json);\n if (message.type === 'request') {\n if (this.activeRequests.has(message.context.callId))\n throw new InternalError('Duplicate active IPC request');\n this.activeRequests.add(message.context.callId);\n void this.dispatch(message).catch((error) => this.fail(IpcErrors.normalize(error)));\n return;\n }\n const pending = this.pending.get(message.context.callId);\n // Late or unsolicited replies are reported, never resolve a different call or replay work.\n if (!pending) {\n this.options.errors.report(this.lateReplyError(message));\n return;\n }\n if (!IpcProtocol.sameContext(pending.context, message.context))\n throw this.correlationError(message, pending.context);\n this.pending.delete(message.context.callId);\n pending.cancelTimer();\n this.remember(pending.context, 'resolved');\n pending.resolve(message);\n } catch (err: unknown) {\n const error = toError(err);\n this.fail(IpcErrors.normalize(error));\n }\n }\n\n private async dispatch(request: IpcRequest): Promise<void> {\n let reply: IpcReply;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- transport boundary rejects pending calls or encodes a failure that the client throws\n try {\n if (!this.handler) throw new EndpointNotFoundError('IPC dispatcher is not installed');\n reply = await this.handler(request);\n if (!IpcProtocol.sameContext(request.context, reply.context))\n throw new InternalError('IPC handler reply correlation mismatch');\n } catch (err: unknown) {\n const error = toError(err);\n reply = new IpcFailure(request.context, ApiErrorCodec.encode(error));\n } finally {\n this.activeRequests.delete(request.context.callId);\n }\n await this.send(reply);\n }\n\n private settle(id: string, error: Error, reason: SettlementReason): void {\n const pending = this.pending.get(id);\n if (!pending) return;\n this.pending.delete(id);\n pending.cancelTimer();\n this.remember(pending.context, reason);\n pending.reject(error);\n }\n\n private remember(context: IpcCallContext, reason: SettlementReason): void {\n this.settlements.delete(context.callId);\n this.settlements.set(context.callId, new SettlementRecord(context, reason, Date.now()));\n while (this.settlements.size > this.options.settlementHistoryLimit) {\n const oldest = this.settlements.keys().next().value;\n if (typeof oldest !== 'string') return;\n this.settlements.delete(oldest);\n }\n }\n\n private lateReplyError(reply: IpcReply): InternalError {\n const previous = this.settlements.get(reply.context.callId);\n let classification = 'unknown';\n if (previous) {\n classification = IpcProtocol.sameContext(previous.context, reply.context)\n ? previous.reason === 'expired'\n ? 'expired'\n : 'duplicate-settled'\n : 'correlation-mismatched';\n }\n const settlement = previous\n ? ` terminal=${previous.reason} ageMs=${String(Math.max(0, Date.now() - previous.settledAt))}`\n : '';\n return new InternalError(\n `Late or unknown IPC reply classification=${classification}${settlement} ${this.correlationFields(reply.context)}`,\n );\n }\n\n private correlationError(reply: IpcReply, expected: IpcCallContext): InternalError {\n return new InternalError(\n `IPC reply correlation mismatch classification=correlation-mismatched ` +\n `${this.correlationFields(reply.context)} expectedTxId=${JSON.stringify(expected.txId)}`,\n );\n }\n\n private correlationFields(context: IpcCallContext): string {\n return (\n `callId=${JSON.stringify(context.callId)} txId=${JSON.stringify(context.txId)} ` +\n `parentCallId=${JSON.stringify(context.parentCallId ?? null)} ` +\n `connectionId=${JSON.stringify(this.options.connectionId)}`\n );\n }\n\n private fail(error: Error): void {\n if (this.closed) return;\n this.closed = error;\n for (const id of this.pending.keys()) this.settle(id, error, 'rejected');\n this.options.errors.report(error);\n }\n}\n\nexport class IpcErrors {\n // webpieces-disable no-function-outside-class -- portable stateless IPC primitive; no platform DI container exists on this boundary; webpieces-disable no-any-unknown -- untrusted IPC data is schema-validated; generic dispatch cannot assume a DTO type before validation\n static normalize(value: unknown): Error {\n return value instanceof Error\n ? value\n : new InternalError('IPC failed with a non-Error value');\n }\n}\n"]}
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
* only applied to Google-generated audit logs, never to user-written entries. We mirror its field
|
|
20
20
|
* shape ({@link LogChunkInfo}) but must do the work ourselves.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
* `Buffer`.
|
|
22
|
+
* PORTABLE: lives in core-util, which ships in browser and React Native bundles — so byte counts
|
|
23
|
+
* are calculated directly without relying on either `TextEncoder` or Node's `Buffer`.
|
|
24
24
|
*
|
|
25
25
|
* Singleton, mirroring `RequestContext`: use the exported {@link LogChunker}, not `new`.
|
|
26
26
|
*/
|
|
@@ -76,7 +76,6 @@ export declare class ChunkBudgets {
|
|
|
76
76
|
constructor(firstBudget: number, secondBudget: number);
|
|
77
77
|
}
|
|
78
78
|
export declare class LogChunkerImpl {
|
|
79
|
-
private readonly encoder;
|
|
80
79
|
/**
|
|
81
80
|
* Plain UTF-8 byte length — for measuring text that is ALREADY in its final serialized form
|
|
82
81
|
* (e.g. winston's fully-rendered JSON line), where no further escaping will happen.
|
|
@@ -137,6 +136,7 @@ export declare class LogChunkerImpl {
|
|
|
137
136
|
newUid(): string;
|
|
138
137
|
/** Bytes this code point occupies once JSON-escaped inside a string value. */
|
|
139
138
|
private escapedCost;
|
|
139
|
+
private utf8Cost;
|
|
140
140
|
}
|
|
141
141
|
/**
|
|
142
142
|
* The process-wide {@link LogChunkerImpl} singleton — mirrors the `RequestContext` export pattern.
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
* only applied to Google-generated audit logs, never to user-written entries. We mirror its field
|
|
21
21
|
* shape ({@link LogChunkInfo}) but must do the work ourselves.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
* `Buffer`.
|
|
23
|
+
* PORTABLE: lives in core-util, which ships in browser and React Native bundles — so byte counts
|
|
24
|
+
* are calculated directly without relying on either `TextEncoder` or Node's `Buffer`.
|
|
25
25
|
*
|
|
26
26
|
* Singleton, mirroring `RequestContext`: use the exported {@link LogChunker}, not `new`.
|
|
27
27
|
*/
|
|
@@ -96,13 +96,15 @@ class ChunkBudgets {
|
|
|
96
96
|
}
|
|
97
97
|
exports.ChunkBudgets = ChunkBudgets;
|
|
98
98
|
class LogChunkerImpl {
|
|
99
|
-
encoder = new TextEncoder();
|
|
100
99
|
/**
|
|
101
100
|
* Plain UTF-8 byte length — for measuring text that is ALREADY in its final serialized form
|
|
102
101
|
* (e.g. winston's fully-rendered JSON line), where no further escaping will happen.
|
|
103
102
|
*/
|
|
104
103
|
byteLength(text) {
|
|
105
|
-
|
|
104
|
+
let bytes = 0;
|
|
105
|
+
for (const char of text)
|
|
106
|
+
bytes += this.utf8Cost(char.codePointAt(0));
|
|
107
|
+
return bytes;
|
|
106
108
|
}
|
|
107
109
|
/**
|
|
108
110
|
* The byte cost of `text` once it has been JSON-escaped as a string VALUE inside a record.
|
|
@@ -225,15 +227,15 @@ class LogChunkerImpl {
|
|
|
225
227
|
return 6;
|
|
226
228
|
}
|
|
227
229
|
// Otherwise the character is emitted as-is, costing its UTF-8 length.
|
|
228
|
-
|
|
230
|
+
return this.utf8Cost(codePoint);
|
|
231
|
+
}
|
|
232
|
+
utf8Cost(codePoint) {
|
|
233
|
+
if (codePoint < 0x80)
|
|
229
234
|
return 1;
|
|
230
|
-
|
|
231
|
-
if (codePoint < 0x800) {
|
|
235
|
+
if (codePoint < 0x800)
|
|
232
236
|
return 2;
|
|
233
|
-
|
|
234
|
-
if (codePoint < 0x10000) {
|
|
237
|
+
if (codePoint < 0x10000)
|
|
235
238
|
return 3;
|
|
236
|
-
}
|
|
237
239
|
return 4;
|
|
238
240
|
}
|
|
239
241
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LogChunker.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/logging/LogChunker.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * LogChunker - splits an oversized log field into pieces small enough that each emitted record\n * survives GCP Cloud Logging's per-entry size limit.\n *\n * WHY THIS EXISTS: an oversized entry does NOT come back as an error — it is SILENTLY DROPPED.\n * Per the GKE docs: \"Any LogEntry exceeding the size limit is dropped for jsonPayload logs and\n * truncated for textPayload logs.\" Our GCP backends emit structured JSON, so we are squarely in the\n * \"dropped\" half: a 300KB response body or a giant stack trace makes the whole line vanish with no\n * diagnostic anywhere. Chunking is what turns that silent loss into N recoverable lines.\n *\n * WHY NOT TRUNCATE: the content that blows the limit (a stack trace, a response body) is precisely\n * the content you opened the logs to read. Splitting keeps all of it.\n *\n * WHY NOT SPLIT THE SERIALIZED LINE: a fragment of a JSON line is not valid JSON, and the logging\n * agent would file each piece as an unparsed `textPayload` — losing every structured field. So\n * callers chunk a FIELD and emit N COMPLETE records, each carrying a `logChunk` tag.\n *\n * WHY GCP's OWN SPLITTING DOES NOT HELP: Cloud Logging's `LogSplit` (split.uid/index/totalSplits) is\n * only applied to Google-generated audit logs, never to user-written entries. We mirror its field\n * shape ({@link LogChunkInfo}) but must do the work ourselves.\n *\n * BROWSER-SAFE: lives in core-util, which ships in the browser bundle — so `TextEncoder`, never\n * `Buffer`.\n *\n * Singleton, mirroring `RequestContext`: use the exported {@link LogChunker}, not `new`.\n */\n\n/**\n * GCP Cloud Logging's maximum size for a single LogEntry: 256 KiB. Note KiB, not KB — the docs say\n * 256 KiB, so 262,144 bytes and NOT the 256,000 that several client libraries hardcode as their own\n * conservative guard.\n */\nexport const MAX_GCP_LOG_BYTES = 262_144;\n\n/**\n * The per-record budget we actually chunk to: 75% of {@link MAX_GCP_LOG_BYTES}.\n *\n * The 25% headroom is NOT superstition — three things eat into the limit that a caller cannot see:\n * 1. The limit is explicitly \"approximate and based on internal data sizes, not the actual REST API\n * request size\" (GCP quotas docs), so byte-exact packing against 262,144 is not a thing you can do.\n * 2. Labels, resource, and metadata share the entry's budget with the payload.\n * 3. The record envelope — context keys (requestId, tenantId, ...), the `api` tag, svcName, severity,\n * timestamps — is serialized alongside the field being chunked.\n */\nexport const GCP_LOG_BUDGET_BYTES = 196_608;\n\n/**\n * The tag stamped on every record of a split message, mirroring GCP's own `LogSplit` shape.\n * Data-only structure → a class, per CLAUDE.md.\n *\n * Reassembling in Cloud Logging: filter `jsonPayload.logChunk.uid=\"<uid>\"`, sort by\n * `jsonPayload.logChunk.index`, concatenate the `message` fields.\n *\n * WHY A DEDICATED uid, given every line already carries requestId: requestId correlates a whole\n * REQUEST, which emits many lines (LogApiCall alone emits a request line AND a response line per\n * call). It cannot tell you which lines are pieces of ONE message. This uid can.\n */\nexport class LogChunkInfo {\n constructor(\n /** Correlates the pieces of ONE split message. */\n readonly uid: string,\n /** 0-based position of this piece. */\n readonly index: number,\n /** How many pieces this message was split into. */\n readonly total: number,\n ) {}\n}\n\n/**\n * Bytes reserved for the `logChunk` tag a backend adds to each piece, e.g.\n * `,\"logChunk\":{\"uid\":\"chunk-mabc1234-x7f2q1\",\"index\":12,\"total\":34}` — ~70 bytes, rounded up.\n */\nconst CHUNK_TAG_BYTES = 128;\n\n/**\n * Floor for a per-record field budget. Only reachable if the ENVELOPE alone (context keys, the api\n * tag, svcName) already fills the budget — pathological, and slicing a message into 1-byte pieces\n * would be worse than emitting one slightly-oversized record.\n */\nconst MIN_FIELD_BYTES = 1024;\n\n/**\n * How many bytes each of two chunked fields may spend PER RECORD. Data-only structure → a class.\n */\nexport class ChunkBudgets {\n constructor(\n readonly firstBudget: number,\n readonly secondBudget: number,\n ) {}\n}\n\nexport class LogChunkerImpl {\n private readonly encoder = new TextEncoder();\n\n /**\n * Plain UTF-8 byte length — for measuring text that is ALREADY in its final serialized form\n * (e.g. winston's fully-rendered JSON line), where no further escaping will happen.\n */\n byteLength(text: string): number {\n return this.encoder.encode(text).length;\n }\n\n /**\n * The byte cost of `text` once it has been JSON-escaped as a string VALUE inside a record.\n *\n * This is the measurement that matters, and it is why chunking on raw UTF-8 length is a bug: a\n * log message holding a JSON body is escaped a SECOND time when the record is serialized, so\n * every `\"` becomes `\\\"`, every newline `\\n`, and a control character explodes to a 6-byte\n * `\\u00XX`. A body that is dense in quotes can inflate by ~2x on that second pass — enough to\n * push a \"196KB\" chunk past the 262KB ceiling and silently drop it.\n *\n * Exact for `JSON.stringify` semantics (V8 does not \\u-escape non-ASCII). For the bunyan GCP\n * path — which ships over gRPC/protobuf rather than as JSON text — this over-counts slightly,\n * which is the safe direction.\n */\n escapedByteLength(text: string): number {\n let bytes = 0;\n // for...of iterates CODE POINTS, so a surrogate pair is one step, not two.\n for (const char of text) {\n bytes += this.escapedCost(char.codePointAt(0)!);\n }\n return bytes;\n }\n\n /**\n * Split `text` so each piece costs at most `maxBytes` once JSON-escaped\n * (see {@link escapedByteLength}).\n *\n * GUARANTEES:\n * - `chunk(t, n).join('') === t` — nothing is lost, so the pieces reassemble exactly.\n * - No piece splits a code point: a 4-byte emoji or a CJK character is never cut in half (which\n * would corrupt the boundary character into replacement junk on reassembly).\n * - Always returns at least one piece (`['']` for empty input), so callers can treat the result\n * uniformly.\n *\n * Degenerate case: if a SINGLE code point costs more than `maxBytes`, that piece necessarily\n * exceeds the budget — unavoidable, and irrelevant at any sane budget (max cost is 6 bytes).\n */\n chunk(text: string, maxBytes: number): string[] {\n if (maxBytes <= 0) {\n throw new Error(`maxBytes must be positive, was ${maxBytes}`);\n }\n if (this.escapedByteLength(text) <= maxBytes) {\n return [text];\n }\n\n const chunks: string[] = [];\n let start = 0;\n // UTF-16 index (what slice() wants), advanced by each code point's unit length so every\n // boundary we cut on is a code-point boundary.\n let position = 0;\n let bytes = 0;\n for (const char of text) {\n const cost = this.escapedCost(char.codePointAt(0)!);\n if (bytes + cost > maxBytes && position > start) {\n chunks.push(text.slice(start, position));\n start = position;\n bytes = 0;\n }\n bytes += cost;\n position += char.length;\n }\n chunks.push(text.slice(start));\n return chunks;\n }\n\n /**\n * Divide a record's budget between the TWO fields a backend chunks — the message and the stack\n * trace. Shared by the winston and bunyan GCP backends, which differ only in what those fields\n * are called (`message`/`errStack` vs `msg`/`err.stack`), never in this arithmetic.\n *\n * The envelope's cost is derived by SUBTRACTION: `renderedBytes` minus the escaped cost of the\n * two fields IS the envelope, whatever it happens to hold. That stays correct as apps register\n * new context keys, where summing up known parts would silently drift.\n *\n * The split: whichever field is small enough to fit whole gets exactly what it needs and the\n * other takes the rest; if BOTH are oversized they share evenly. Either way record N holds\n * first[N] + second[N] and still lands within budget.\n *\n * @param renderedBytes - size of the fully-serialized record as it stands today\n * @param budgetBytes - the per-record ceiling (typically {@link GCP_LOG_BUDGET_BYTES})\n */\n chunkBudgets(renderedBytes: number, budgetBytes: number, first: string, second: string): ChunkBudgets {\n const firstBytes = this.escapedByteLength(first);\n const secondBytes = this.escapedByteLength(second);\n const envelopeBytes = renderedBytes - firstBytes - secondBytes;\n const available = Math.max(budgetBytes - envelopeBytes - CHUNK_TAG_BYTES, MIN_FIELD_BYTES);\n const half = Math.floor(available / 2);\n\n // Both too big to fit alongside anything → split the room evenly.\n if (firstBytes > half && secondBytes > half) {\n return new ChunkBudgets(half, half);\n }\n // A field that fits in one piece is given exactly its own size (never 0 — chunk() rejects\n // that), and the oversized field gets everything left over.\n if (secondBytes <= half) {\n return new ChunkBudgets(Math.max(available - secondBytes, MIN_FIELD_BYTES), Math.max(secondBytes, 1));\n }\n return new ChunkBudgets(Math.max(firstBytes, 1), Math.max(available - firstBytes, MIN_FIELD_BYTES));\n }\n\n /**\n * A fresh id correlating the pieces of one split message. Uses Math.random rather than\n * crypto.randomUUID so it works in every browser context (randomUUID needs a secure context) and\n * on older Node — matching how RequestContextHeaders generates its fallback request id. These\n * only need to be unique among the lines an operator is grepping, not cryptographically strong.\n */\n newUid(): string {\n return `chunk-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 10)}`;\n }\n\n /** Bytes this code point occupies once JSON-escaped inside a string value. */\n private escapedCost(codePoint: number): number {\n // The characters JSON.stringify escapes with a 2-char backslash sequence: \" \\ \\b \\t \\n \\f \\r\n if (codePoint === 0x22 || codePoint === 0x5c) {\n return 2;\n }\n if (\n codePoint === 0x08 ||\n codePoint === 0x09 ||\n codePoint === 0x0a ||\n codePoint === 0x0c ||\n codePoint === 0x0d\n ) {\n return 2;\n }\n // Every other control character becomes a 6-byte \\u00XX escape.\n if (codePoint < 0x20) {\n return 6;\n }\n // Otherwise the character is emitted as-is, costing its UTF-8 length.\n if (codePoint < 0x80) {\n return 1;\n }\n if (codePoint < 0x800) {\n return 2;\n }\n if (codePoint < 0x10000) {\n return 3;\n }\n return 4;\n }\n}\n\n/**\n * The process-wide {@link LogChunkerImpl} singleton — mirrors the `RequestContext` export pattern.\n * Callers use `LogChunker.chunk(...)`, never `new`.\n */\nexport const LogChunker = new LogChunkerImpl();\n"]}
|
|
1
|
+
{"version":3,"file":"LogChunker.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/logging/LogChunker.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * LogChunker - splits an oversized log field into pieces small enough that each emitted record\n * survives GCP Cloud Logging's per-entry size limit.\n *\n * WHY THIS EXISTS: an oversized entry does NOT come back as an error — it is SILENTLY DROPPED.\n * Per the GKE docs: \"Any LogEntry exceeding the size limit is dropped for jsonPayload logs and\n * truncated for textPayload logs.\" Our GCP backends emit structured JSON, so we are squarely in the\n * \"dropped\" half: a 300KB response body or a giant stack trace makes the whole line vanish with no\n * diagnostic anywhere. Chunking is what turns that silent loss into N recoverable lines.\n *\n * WHY NOT TRUNCATE: the content that blows the limit (a stack trace, a response body) is precisely\n * the content you opened the logs to read. Splitting keeps all of it.\n *\n * WHY NOT SPLIT THE SERIALIZED LINE: a fragment of a JSON line is not valid JSON, and the logging\n * agent would file each piece as an unparsed `textPayload` — losing every structured field. So\n * callers chunk a FIELD and emit N COMPLETE records, each carrying a `logChunk` tag.\n *\n * WHY GCP's OWN SPLITTING DOES NOT HELP: Cloud Logging's `LogSplit` (split.uid/index/totalSplits) is\n * only applied to Google-generated audit logs, never to user-written entries. We mirror its field\n * shape ({@link LogChunkInfo}) but must do the work ourselves.\n *\n * PORTABLE: lives in core-util, which ships in browser and React Native bundles — so byte counts\n * are calculated directly without relying on either `TextEncoder` or Node's `Buffer`.\n *\n * Singleton, mirroring `RequestContext`: use the exported {@link LogChunker}, not `new`.\n */\n\n/**\n * GCP Cloud Logging's maximum size for a single LogEntry: 256 KiB. Note KiB, not KB — the docs say\n * 256 KiB, so 262,144 bytes and NOT the 256,000 that several client libraries hardcode as their own\n * conservative guard.\n */\nexport const MAX_GCP_LOG_BYTES = 262_144;\n\n/**\n * The per-record budget we actually chunk to: 75% of {@link MAX_GCP_LOG_BYTES}.\n *\n * The 25% headroom is NOT superstition — three things eat into the limit that a caller cannot see:\n * 1. The limit is explicitly \"approximate and based on internal data sizes, not the actual REST API\n * request size\" (GCP quotas docs), so byte-exact packing against 262,144 is not a thing you can do.\n * 2. Labels, resource, and metadata share the entry's budget with the payload.\n * 3. The record envelope — context keys (requestId, tenantId, ...), the `api` tag, svcName, severity,\n * timestamps — is serialized alongside the field being chunked.\n */\nexport const GCP_LOG_BUDGET_BYTES = 196_608;\n\n/**\n * The tag stamped on every record of a split message, mirroring GCP's own `LogSplit` shape.\n * Data-only structure → a class, per CLAUDE.md.\n *\n * Reassembling in Cloud Logging: filter `jsonPayload.logChunk.uid=\"<uid>\"`, sort by\n * `jsonPayload.logChunk.index`, concatenate the `message` fields.\n *\n * WHY A DEDICATED uid, given every line already carries requestId: requestId correlates a whole\n * REQUEST, which emits many lines (LogApiCall alone emits a request line AND a response line per\n * call). It cannot tell you which lines are pieces of ONE message. This uid can.\n */\nexport class LogChunkInfo {\n constructor(\n /** Correlates the pieces of ONE split message. */\n readonly uid: string,\n /** 0-based position of this piece. */\n readonly index: number,\n /** How many pieces this message was split into. */\n readonly total: number,\n ) {}\n}\n\n/**\n * Bytes reserved for the `logChunk` tag a backend adds to each piece, e.g.\n * `,\"logChunk\":{\"uid\":\"chunk-mabc1234-x7f2q1\",\"index\":12,\"total\":34}` — ~70 bytes, rounded up.\n */\nconst CHUNK_TAG_BYTES = 128;\n\n/**\n * Floor for a per-record field budget. Only reachable if the ENVELOPE alone (context keys, the api\n * tag, svcName) already fills the budget — pathological, and slicing a message into 1-byte pieces\n * would be worse than emitting one slightly-oversized record.\n */\nconst MIN_FIELD_BYTES = 1024;\n\n/**\n * How many bytes each of two chunked fields may spend PER RECORD. Data-only structure → a class.\n */\nexport class ChunkBudgets {\n constructor(\n readonly firstBudget: number,\n readonly secondBudget: number,\n ) {}\n}\n\nexport class LogChunkerImpl {\n /**\n * Plain UTF-8 byte length — for measuring text that is ALREADY in its final serialized form\n * (e.g. winston's fully-rendered JSON line), where no further escaping will happen.\n */\n byteLength(text: string): number {\n let bytes = 0;\n for (const char of text) bytes += this.utf8Cost(char.codePointAt(0)!);\n return bytes;\n }\n\n /**\n * The byte cost of `text` once it has been JSON-escaped as a string VALUE inside a record.\n *\n * This is the measurement that matters, and it is why chunking on raw UTF-8 length is a bug: a\n * log message holding a JSON body is escaped a SECOND time when the record is serialized, so\n * every `\"` becomes `\\\"`, every newline `\\n`, and a control character explodes to a 6-byte\n * `\\u00XX`. A body that is dense in quotes can inflate by ~2x on that second pass — enough to\n * push a \"196KB\" chunk past the 262KB ceiling and silently drop it.\n *\n * Exact for `JSON.stringify` semantics (V8 does not \\u-escape non-ASCII). For the bunyan GCP\n * path — which ships over gRPC/protobuf rather than as JSON text — this over-counts slightly,\n * which is the safe direction.\n */\n escapedByteLength(text: string): number {\n let bytes = 0;\n // for...of iterates CODE POINTS, so a surrogate pair is one step, not two.\n for (const char of text) {\n bytes += this.escapedCost(char.codePointAt(0)!);\n }\n return bytes;\n }\n\n /**\n * Split `text` so each piece costs at most `maxBytes` once JSON-escaped\n * (see {@link escapedByteLength}).\n *\n * GUARANTEES:\n * - `chunk(t, n).join('') === t` — nothing is lost, so the pieces reassemble exactly.\n * - No piece splits a code point: a 4-byte emoji or a CJK character is never cut in half (which\n * would corrupt the boundary character into replacement junk on reassembly).\n * - Always returns at least one piece (`['']` for empty input), so callers can treat the result\n * uniformly.\n *\n * Degenerate case: if a SINGLE code point costs more than `maxBytes`, that piece necessarily\n * exceeds the budget — unavoidable, and irrelevant at any sane budget (max cost is 6 bytes).\n */\n chunk(text: string, maxBytes: number): string[] {\n if (maxBytes <= 0) {\n throw new Error(`maxBytes must be positive, was ${maxBytes}`);\n }\n if (this.escapedByteLength(text) <= maxBytes) {\n return [text];\n }\n\n const chunks: string[] = [];\n let start = 0;\n // UTF-16 index (what slice() wants), advanced by each code point's unit length so every\n // boundary we cut on is a code-point boundary.\n let position = 0;\n let bytes = 0;\n for (const char of text) {\n const cost = this.escapedCost(char.codePointAt(0)!);\n if (bytes + cost > maxBytes && position > start) {\n chunks.push(text.slice(start, position));\n start = position;\n bytes = 0;\n }\n bytes += cost;\n position += char.length;\n }\n chunks.push(text.slice(start));\n return chunks;\n }\n\n /**\n * Divide a record's budget between the TWO fields a backend chunks — the message and the stack\n * trace. Shared by the winston and bunyan GCP backends, which differ only in what those fields\n * are called (`message`/`errStack` vs `msg`/`err.stack`), never in this arithmetic.\n *\n * The envelope's cost is derived by SUBTRACTION: `renderedBytes` minus the escaped cost of the\n * two fields IS the envelope, whatever it happens to hold. That stays correct as apps register\n * new context keys, where summing up known parts would silently drift.\n *\n * The split: whichever field is small enough to fit whole gets exactly what it needs and the\n * other takes the rest; if BOTH are oversized they share evenly. Either way record N holds\n * first[N] + second[N] and still lands within budget.\n *\n * @param renderedBytes - size of the fully-serialized record as it stands today\n * @param budgetBytes - the per-record ceiling (typically {@link GCP_LOG_BUDGET_BYTES})\n */\n chunkBudgets(\n renderedBytes: number,\n budgetBytes: number,\n first: string,\n second: string,\n ): ChunkBudgets {\n const firstBytes = this.escapedByteLength(first);\n const secondBytes = this.escapedByteLength(second);\n const envelopeBytes = renderedBytes - firstBytes - secondBytes;\n const available = Math.max(budgetBytes - envelopeBytes - CHUNK_TAG_BYTES, MIN_FIELD_BYTES);\n const half = Math.floor(available / 2);\n\n // Both too big to fit alongside anything → split the room evenly.\n if (firstBytes > half && secondBytes > half) {\n return new ChunkBudgets(half, half);\n }\n // A field that fits in one piece is given exactly its own size (never 0 — chunk() rejects\n // that), and the oversized field gets everything left over.\n if (secondBytes <= half) {\n return new ChunkBudgets(\n Math.max(available - secondBytes, MIN_FIELD_BYTES),\n Math.max(secondBytes, 1),\n );\n }\n return new ChunkBudgets(\n Math.max(firstBytes, 1),\n Math.max(available - firstBytes, MIN_FIELD_BYTES),\n );\n }\n\n /**\n * A fresh id correlating the pieces of one split message. Uses Math.random rather than\n * crypto.randomUUID so it works in every browser context (randomUUID needs a secure context) and\n * on older Node — matching how RequestContextHeaders generates its fallback request id. These\n * only need to be unique among the lines an operator is grepping, not cryptographically strong.\n */\n newUid(): string {\n return `chunk-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 10)}`;\n }\n\n /** Bytes this code point occupies once JSON-escaped inside a string value. */\n private escapedCost(codePoint: number): number {\n // The characters JSON.stringify escapes with a 2-char backslash sequence: \" \\ \\b \\t \\n \\f \\r\n if (codePoint === 0x22 || codePoint === 0x5c) {\n return 2;\n }\n if (\n codePoint === 0x08 ||\n codePoint === 0x09 ||\n codePoint === 0x0a ||\n codePoint === 0x0c ||\n codePoint === 0x0d\n ) {\n return 2;\n }\n // Every other control character becomes a 6-byte \\u00XX escape.\n if (codePoint < 0x20) {\n return 6;\n }\n // Otherwise the character is emitted as-is, costing its UTF-8 length.\n return this.utf8Cost(codePoint);\n }\n\n private utf8Cost(codePoint: number): number {\n if (codePoint < 0x80) return 1;\n if (codePoint < 0x800) return 2;\n if (codePoint < 0x10000) return 3;\n return 4;\n }\n}\n\n/**\n * The process-wide {@link LogChunkerImpl} singleton — mirrors the `RequestContext` export pattern.\n * Callers use `LogChunker.chunk(...)`, never `new`.\n */\nexport const LogChunker = new LogChunkerImpl();\n"]}
|