@webpieces/core-util 0.4.396 → 0.4.397
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 +53 -1
- package/src/http/ApiCallInfo.js +32 -1
- package/src/http/ApiCallInfo.js.map +1 -1
- package/src/http/LogApiCall.d.ts +15 -0
- package/src/http/LogApiCall.js +53 -18
- package/src/http/LogApiCall.js.map +1 -1
- package/src/index.d.ts +1 -0
- package/src/index.js +8 -2
- package/src/index.js.map +1 -1
- package/src/logging/LogChunker.d.ts +145 -0
- package/src/logging/LogChunker.js +246 -0
- package/src/logging/LogChunker.js.map +1 -0
package/package.json
CHANGED
|
@@ -11,6 +11,8 @@ import { ApiMethodInfo } from './ApiMethodInfo';
|
|
|
11
11
|
* - `jsonPayload.api.method.side="server"` — every inbound call it handled
|
|
12
12
|
* - `jsonPayload.api.method.apiClass="SaveApi"` — one logical method, BOTH sides (client + server)
|
|
13
13
|
* - `jsonPayload.api.result="failure"` — failed exchanges only
|
|
14
|
+
* - `jsonPayload.api.durationMs>1000` — slow calls, either side
|
|
15
|
+
* - `jsonPayload.api.responseSize>100000` — the fat responses (the ones that get chunked)
|
|
14
16
|
* - `jsonPayload.api:*` — "API traffic only" (tracing + the recorder)
|
|
15
17
|
*
|
|
16
18
|
* IMPORTANT: the field names here (and on the nested {@link ApiMethodInfo}) ARE the GCP field names —
|
|
@@ -43,10 +45,60 @@ export declare class ApiCallInfo {
|
|
|
43
45
|
readonly type: ApiType;
|
|
44
46
|
/** Response only — undefined on the 'request' tag. */
|
|
45
47
|
readonly result?: ApiResult | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Wall-clock milliseconds the call took. RESPONSE tag only (undefined on 'request') — a
|
|
50
|
+
* request has no duration yet. Present on BOTH the success and failure paths, so
|
|
51
|
+
* `jsonPayload.api.durationMs>1000 AND api.result="failure"` finds slow failures.
|
|
52
|
+
*
|
|
53
|
+
* There is deliberately no `statusCode` beside this. LogApiCall runs deep in the stack over
|
|
54
|
+
* in-process calls, pubsub handlers, and cloud-task enqueues — none of which have an HTTP
|
|
55
|
+
* status — and business logic must not know about HTTP. `result` (see {@link ApiResult}) is
|
|
56
|
+
* the transport-neutral outcome, exactly as {@link LogApiCall.isUserError} classifies by
|
|
57
|
+
* portable Error TYPE rather than by status code.
|
|
58
|
+
*/
|
|
59
|
+
readonly durationMs?: number | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Bytes of the serialized request DTO. Stamped on BOTH tags: the 'request' tag reports it as
|
|
62
|
+
* soon as it is known, and the 'response' tag repeats it so one record shows the whole
|
|
63
|
+
* exchange (`api.requestSize` + `api.responseSize` without a join).
|
|
64
|
+
*
|
|
65
|
+
* This is the TOTAL size of the body, measured BEFORE any log chunking — chunking is a
|
|
66
|
+
* transport concern handled by the GCP backends, and a body split across 3 records still
|
|
67
|
+
* reports its one true size here.
|
|
68
|
+
*/
|
|
69
|
+
readonly requestSize?: number | undefined;
|
|
70
|
+
/** Bytes of the serialized response. RESPONSE tag only, and only when the call succeeded —
|
|
71
|
+
* a thrown error produced no response body to measure. Total size, pre-chunking. */
|
|
72
|
+
readonly responseSize?: number | undefined;
|
|
46
73
|
constructor(
|
|
47
74
|
/** The call identity (side, apiClass, methodName, controllerName) — surfaces nested under
|
|
48
75
|
* `jsonPayload.api.method`. */
|
|
49
76
|
method: ApiMethodInfo, type: ApiType,
|
|
50
77
|
/** Response only — undefined on the 'request' tag. */
|
|
51
|
-
result?: ApiResult | undefined
|
|
78
|
+
result?: ApiResult | undefined,
|
|
79
|
+
/**
|
|
80
|
+
* Wall-clock milliseconds the call took. RESPONSE tag only (undefined on 'request') — a
|
|
81
|
+
* request has no duration yet. Present on BOTH the success and failure paths, so
|
|
82
|
+
* `jsonPayload.api.durationMs>1000 AND api.result="failure"` finds slow failures.
|
|
83
|
+
*
|
|
84
|
+
* There is deliberately no `statusCode` beside this. LogApiCall runs deep in the stack over
|
|
85
|
+
* in-process calls, pubsub handlers, and cloud-task enqueues — none of which have an HTTP
|
|
86
|
+
* status — and business logic must not know about HTTP. `result` (see {@link ApiResult}) is
|
|
87
|
+
* the transport-neutral outcome, exactly as {@link LogApiCall.isUserError} classifies by
|
|
88
|
+
* portable Error TYPE rather than by status code.
|
|
89
|
+
*/
|
|
90
|
+
durationMs?: number | undefined,
|
|
91
|
+
/**
|
|
92
|
+
* Bytes of the serialized request DTO. Stamped on BOTH tags: the 'request' tag reports it as
|
|
93
|
+
* soon as it is known, and the 'response' tag repeats it so one record shows the whole
|
|
94
|
+
* exchange (`api.requestSize` + `api.responseSize` without a join).
|
|
95
|
+
*
|
|
96
|
+
* This is the TOTAL size of the body, measured BEFORE any log chunking — chunking is a
|
|
97
|
+
* transport concern handled by the GCP backends, and a body split across 3 records still
|
|
98
|
+
* reports its one true size here.
|
|
99
|
+
*/
|
|
100
|
+
requestSize?: number | undefined,
|
|
101
|
+
/** Bytes of the serialized response. RESPONSE tag only, and only when the call succeeded —
|
|
102
|
+
* a thrown error produced no response body to measure. Total size, pre-chunking. */
|
|
103
|
+
responseSize?: number | undefined);
|
|
52
104
|
}
|
package/src/http/ApiCallInfo.js
CHANGED
|
@@ -5,15 +5,46 @@ class ApiCallInfo {
|
|
|
5
5
|
method;
|
|
6
6
|
type;
|
|
7
7
|
result;
|
|
8
|
+
durationMs;
|
|
9
|
+
requestSize;
|
|
10
|
+
responseSize;
|
|
8
11
|
constructor(
|
|
9
12
|
/** The call identity (side, apiClass, methodName, controllerName) — surfaces nested under
|
|
10
13
|
* `jsonPayload.api.method`. */
|
|
11
14
|
method, type,
|
|
12
15
|
/** Response only — undefined on the 'request' tag. */
|
|
13
|
-
result
|
|
16
|
+
result,
|
|
17
|
+
/**
|
|
18
|
+
* Wall-clock milliseconds the call took. RESPONSE tag only (undefined on 'request') — a
|
|
19
|
+
* request has no duration yet. Present on BOTH the success and failure paths, so
|
|
20
|
+
* `jsonPayload.api.durationMs>1000 AND api.result="failure"` finds slow failures.
|
|
21
|
+
*
|
|
22
|
+
* There is deliberately no `statusCode` beside this. LogApiCall runs deep in the stack over
|
|
23
|
+
* in-process calls, pubsub handlers, and cloud-task enqueues — none of which have an HTTP
|
|
24
|
+
* status — and business logic must not know about HTTP. `result` (see {@link ApiResult}) is
|
|
25
|
+
* the transport-neutral outcome, exactly as {@link LogApiCall.isUserError} classifies by
|
|
26
|
+
* portable Error TYPE rather than by status code.
|
|
27
|
+
*/
|
|
28
|
+
durationMs,
|
|
29
|
+
/**
|
|
30
|
+
* Bytes of the serialized request DTO. Stamped on BOTH tags: the 'request' tag reports it as
|
|
31
|
+
* soon as it is known, and the 'response' tag repeats it so one record shows the whole
|
|
32
|
+
* exchange (`api.requestSize` + `api.responseSize` without a join).
|
|
33
|
+
*
|
|
34
|
+
* This is the TOTAL size of the body, measured BEFORE any log chunking — chunking is a
|
|
35
|
+
* transport concern handled by the GCP backends, and a body split across 3 records still
|
|
36
|
+
* reports its one true size here.
|
|
37
|
+
*/
|
|
38
|
+
requestSize,
|
|
39
|
+
/** Bytes of the serialized response. RESPONSE tag only, and only when the call succeeded —
|
|
40
|
+
* a thrown error produced no response body to measure. Total size, pre-chunking. */
|
|
41
|
+
responseSize) {
|
|
14
42
|
this.method = method;
|
|
15
43
|
this.type = type;
|
|
16
44
|
this.result = result;
|
|
45
|
+
this.durationMs = durationMs;
|
|
46
|
+
this.requestSize = requestSize;
|
|
47
|
+
this.responseSize = responseSize;
|
|
17
48
|
}
|
|
18
49
|
}
|
|
19
50
|
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":";;;AA6CA,MAAa,WAAW;IAIP;IACA;IAEA;IAYA;IAUA;IAGA;IA/Bb;IACI;oCACgC;IACvB,MAAqB,EACrB,IAAa;IACtB,sDAAsD;IAC7C,MAAkB;IAC3B;;;;;;;;;;OAUG;IACM,UAAmB;IAC5B;;;;;;;;OAQG;IACM,WAAoB;IAC7B;yFACqF;IAC5E,YAAqB;QA5BrB,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;IAC/B,CAAC;CACP;AAlCD,kCAkCC","sourcesContent":["import { ApiMethodInfo } from './ApiMethodInfo';\n\n/**\n * ApiCallInfo - the structured tag stamped into RequestContext around every API call\n * (by {@link LogApiCall}), 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:*` — \"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 LogApiCall.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 LogApiCall.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 ) {}\n}\n"]}
|
package/src/http/LogApiCall.d.ts
CHANGED
|
@@ -41,6 +41,21 @@ export declare class LogApiCallImpl {
|
|
|
41
41
|
* exactly what the GCP filters (`jsonPayload.api.*`) want.
|
|
42
42
|
*/
|
|
43
43
|
execute(methodInfo: ApiMethodInfo, requestDto: any, method: (dto: any) => Promise<any>): Promise<any>;
|
|
44
|
+
/**
|
|
45
|
+
* The ApiCallContext to stamp into. Throws if none was installed at startup, or if there is no
|
|
46
|
+
* active scope — loud misconfiguration, because an api call with nowhere to tag is a bug.
|
|
47
|
+
*/
|
|
48
|
+
private activeContext;
|
|
49
|
+
/**
|
|
50
|
+
* Tag + log a thrown call. There is no responseSize — a throw produced no response body to measure.
|
|
51
|
+
*/
|
|
52
|
+
private logFailure;
|
|
53
|
+
/**
|
|
54
|
+
* UTF-8 byte size of an already-serialized body. TextEncoder, not Buffer: LogApiCall runs in the
|
|
55
|
+
* browser bundle. Undefined in, undefined out — a `Promise<void>` method has no body to measure,
|
|
56
|
+
* and a 0 there would be a lie (JSON.stringify(undefined) returns undefined, not '').
|
|
57
|
+
*/
|
|
58
|
+
private byteSize;
|
|
44
59
|
/**
|
|
45
60
|
* Is this error a NON-failure for HEALTH/METRICS — the process working CORRECTLY (log OTHER, api
|
|
46
61
|
* result:'success') — rather than a real failure to surface (log FAIL, result:'failure')?
|
package/src/http/LogApiCall.js
CHANGED
|
@@ -54,16 +54,9 @@ class LogApiCallImpl {
|
|
|
54
54
|
requestDto,
|
|
55
55
|
// webpieces-disable no-any-unknown -- DTO types are erased at the api/proxy boundary
|
|
56
56
|
method) {
|
|
57
|
-
|
|
58
|
-
// into (loud misconfiguration — an api call with nowhere to tag is a bug).
|
|
59
|
-
const ctx = ApiCallContext_1.ApiCallContextHolder.get();
|
|
60
|
-
if (!ctx.isActive()) {
|
|
61
|
-
throw new Error('LogApiCall requires an ACTIVE ApiCallContext. On a Node server, run inside the ' +
|
|
62
|
-
'RequestContext.run(...) a server filter opens; in a browser, build ClientHttpBrowserFactory first.');
|
|
63
|
-
}
|
|
57
|
+
const ctx = this.activeContext();
|
|
64
58
|
const key = WebpiecesCoreHeaders_1.WebpiecesCoreHeaders.API_CALL_INFO;
|
|
65
59
|
const side = methodInfo.side;
|
|
66
|
-
const server = side === 'server';
|
|
67
60
|
const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;
|
|
68
61
|
// set → emit → remove, as ONE synchronous span: the tag is live only while the logger reads it,
|
|
69
62
|
// never across an await, so a single browser global slot can never be clobbered by a concurrent call.
|
|
@@ -72,28 +65,70 @@ class LogApiCallImpl {
|
|
|
72
65
|
emit();
|
|
73
66
|
ctx.remove(key);
|
|
74
67
|
};
|
|
68
|
+
// Stringify ONCE and reuse for both the log text and the size — a second JSON.stringify of a
|
|
69
|
+
// large DTO purely to measure it would double the cost of the thing we are measuring.
|
|
70
|
+
const requestBody = JSON.stringify(requestDto);
|
|
71
|
+
const requestSize = this.byteSize(requestBody);
|
|
72
|
+
// Declared out here so the catch below can read it too. Reassigned just before the call, so
|
|
73
|
+
// the number times ONLY the call and not our own request-logging.
|
|
74
|
+
let startMs = Date.now();
|
|
75
75
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- LogApiCall logs errors before re-throwing to caller
|
|
76
76
|
try {
|
|
77
|
-
stamp(new ApiCallInfo_1.ApiCallInfo(methodInfo, 'request', undefined), () => log.info(`[API-${side}-req] ${id} request=${
|
|
77
|
+
stamp(new ApiCallInfo_1.ApiCallInfo(methodInfo, 'request', undefined, undefined, requestSize), () => log.info(`[API-${side}-req] ${id} request=${requestBody}`));
|
|
78
78
|
if (!requestDto)
|
|
79
79
|
throw new Error(`Request cannot be null and was from ${id}`);
|
|
80
|
+
startMs = Date.now();
|
|
80
81
|
const response = await method(requestDto);
|
|
81
|
-
|
|
82
|
+
const durationMs = Date.now() - startMs;
|
|
83
|
+
const responseBody = JSON.stringify(response);
|
|
84
|
+
stamp(new ApiCallInfo_1.ApiCallInfo(methodInfo, 'response', 'success', durationMs, requestSize, this.byteSize(responseBody)), () => log.info(`[API-${side}-resp-SUCCESS] ${id} response=${responseBody}`));
|
|
82
85
|
return response;
|
|
83
86
|
}
|
|
84
87
|
catch (err) {
|
|
85
88
|
const error = (0, errorUtils_1.toError)(err);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// CLIENT receives means its call FAILED. HttpUserError (266) is never a failure, either side.
|
|
90
|
-
const isUser = this.isUserError(error, server);
|
|
91
|
-
stamp(new ApiCallInfo_1.ApiCallInfo(methodInfo, 'response', isUser ? 'success' : 'failure'), () => isUser
|
|
92
|
-
? log.warn(`[API-${side}-resp-OTHER] ${id} errorType=${errorType}`)
|
|
93
|
-
: log.error(`[API-${side}-resp-FAIL] ${id} errorType=${errorType} error=${errorMessage}`));
|
|
89
|
+
// Duration comes off the SAME start as the success path, so a slow failure (a timeout, a
|
|
90
|
+
// hung dependency) reports its real cost rather than nothing.
|
|
91
|
+
this.logFailure(error, methodInfo, Date.now() - startMs, requestSize, stamp);
|
|
94
92
|
throw error;
|
|
95
93
|
}
|
|
96
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* The ApiCallContext to stamp into. Throws if none was installed at startup, or if there is no
|
|
97
|
+
* active scope — loud misconfiguration, because an api call with nowhere to tag is a bug.
|
|
98
|
+
*/
|
|
99
|
+
activeContext() {
|
|
100
|
+
const ctx = ApiCallContext_1.ApiCallContextHolder.get();
|
|
101
|
+
if (!ctx.isActive()) {
|
|
102
|
+
throw new Error('LogApiCall requires an ACTIVE ApiCallContext. On a Node server, run inside the ' +
|
|
103
|
+
'RequestContext.run(...) a server filter opens; in a browser, build ClientHttpBrowserFactory first.');
|
|
104
|
+
}
|
|
105
|
+
return ctx;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Tag + log a thrown call. There is no responseSize — a throw produced no response body to measure.
|
|
109
|
+
*/
|
|
110
|
+
logFailure(error, methodInfo, durationMs, requestSize, stamp) {
|
|
111
|
+
const side = methodInfo.side;
|
|
112
|
+
const id = `${methodInfo.apiClass}.${methodInfo.methodName}`;
|
|
113
|
+
const errorType = error.constructor.name;
|
|
114
|
+
// Side-dependent: a 4xx the SERVER raised is a handled non-failure (OTHER); the same 4xx a
|
|
115
|
+
// CLIENT receives means its call FAILED. HttpUserError (266) is never a failure, either side.
|
|
116
|
+
const isUser = this.isUserError(error, side === 'server');
|
|
117
|
+
stamp(new ApiCallInfo_1.ApiCallInfo(methodInfo, 'response', isUser ? 'success' : 'failure', durationMs, requestSize), () => isUser
|
|
118
|
+
? log.warn(`[API-${side}-resp-OTHER] ${id} errorType=${errorType}`)
|
|
119
|
+
: log.error(`[API-${side}-resp-FAIL] ${id} errorType=${errorType} error=${error.message}`));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* UTF-8 byte size of an already-serialized body. TextEncoder, not Buffer: LogApiCall runs in the
|
|
123
|
+
* browser bundle. Undefined in, undefined out — a `Promise<void>` method has no body to measure,
|
|
124
|
+
* and a 0 there would be a lie (JSON.stringify(undefined) returns undefined, not '').
|
|
125
|
+
*/
|
|
126
|
+
byteSize(serialized) {
|
|
127
|
+
if (serialized === undefined) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
return new TextEncoder().encode(serialized).length;
|
|
131
|
+
}
|
|
97
132
|
/**
|
|
98
133
|
* Is this error a NON-failure for HEALTH/METRICS — the process working CORRECTLY (log OTHER, api
|
|
99
134
|
* result:'success') — rather than a real failure to surface (log FAIL, result:'failure')?
|
|
@@ -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 {\n HttpBadRequestError,\n HttpUnauthorizedError,\n HttpForbiddenError,\n HttpNotFoundError,\n HttpUserError,\n} from './errors';\nimport {toError} from \"../lib/errorUtils\";\nimport {LogManager} from \"../logging/LogManager\";\nimport {ApiCallInfo} from \"./ApiCallInfo\";\nimport {ApiMethodInfo} from \"./ApiMethodInfo\";\nimport {ApiCallContextHolder} from \"./ApiCallContext\";\nimport {WebpiecesCoreHeaders} from \"./WebpiecesCoreHeaders\";\n\nconst log = LogManager.getLogger('LogApiCall');\n\n/**\n * LogApiCall - 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 ApiCallContextHolder} 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: `setupRuntime` installs a\n * RequestContext-backed impl on a Node server, and `ClientHttpBrowserFactory` a module-global impl in a\n * browser. If neither ran, {@link ApiCallContextHolder.get} throws (loud misconfiguration).\n *\n * Singleton, mirroring `RequestContext`: use the exported {@link LogApiCall} constant, not `new`.\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 * 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 // Throws if no ApiCallContext was installed at startup, or there is no active scope to stamp\n // into (loud misconfiguration — an api call with nowhere to tag is a bug).\n const ctx = ApiCallContextHolder.get();\n if (!ctx.isActive()) {\n throw new Error(\n 'LogApiCall requires an ACTIVE ApiCallContext. On a Node server, run inside the ' +\n 'RequestContext.run(...) a server filter opens; in a browser, build ClientHttpBrowserFactory first.',\n );\n }\n const key = WebpiecesCoreHeaders.API_CALL_INFO;\n const side = methodInfo.side;\n const server = side === 'server';\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 // 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), () =>\n log.info(`[API-${side}-req] ${id} request=${JSON.stringify(requestDto)}`));\n\n if(!requestDto)\n throw new Error(`Request cannot be null and was from ${id}`);\n\n const response = await method(requestDto);\n\n stamp(new ApiCallInfo(methodInfo, 'response', 'success'), () =>\n log.info(`[API-${side}-resp-SUCCESS] ${id} response=${JSON.stringify(response)}`));\n\n return response;\n } catch (err: unknown) {\n const error = toError(err);\n const errorType = error.constructor.name;\n const errorMessage = error.message;\n // Side-dependent: a 4xx the SERVER raised is a handled non-failure (OTHER); the same 4xx a\n // CLIENT receives means its call FAILED. HttpUserError (266) is never a failure, either side.\n const isUser = this.isUserError(error, server);\n\n stamp(new ApiCallInfo(methodInfo, 'response', isUser ? 'success' : 'failure'), () =>\n isUser\n ? log.warn(`[API-${side}-resp-OTHER] ${id} errorType=${errorType}`)\n : log.error(`[API-${side}-resp-FAIL] ${id} errorType=${errorType} error=${errorMessage}`));\n throw error;\n }\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 * The question is \"are things WORKING?\", NOT \"was it an HTTP 4xx vs 5xx\" — and the two are not the\n * same (see 408 below). Classification is by portable Error TYPE, never by transport: LogApiCall runs\n * deep in the stack (in-process calls, pubsub/queue handlers, HTTP — one code path) and only ever\n * sees a thrown Error. The Http* classes are poorly named for that (the `Http` prefix is historical),\n * but they ARE portable error types that travel with the throw anywhere, so matching the TYPE works\n * with or without any HTTP in the picture. Do NOT swap this for an `error.code` 4xx range check — that\n * both re-couples to HTTP AND would bury 408.\n *\n * SERVER — a healthy server correctly rejecting a CLIENT'S mistake is metrics NOISE, not a failure:\n * - HttpBadRequestError (400) \"your request is malformed\"\n * - HttpUnauthorizedError (401) \"you're not authenticated\"\n * - HttpForbiddenError (403) \"authenticated, but not allowed\"\n * - HttpNotFoundError (404) \"wrong url / no such entity\" (EndpointNotFoundError is a subclass)\n * → the server is fine, the caller erred → result:'success', logged OTHER.\n *\n * SERVER — something may actually be WRONG, so SURFACE it (result:'failure', logged FAIL):\n * - HttpTimeoutError (408): a 4xx, but the client may NEVER have seen the response — deliberately\n * absent from the list below so it counts as a failure.\n * - 500 / 502 / 504 / 598, and any non-Http Error: real failures.\n *\n * HttpUserError (266): ALWAYS a non-failure, server OR client — an expected \"user made a mistake\" signal.\n * CLIENT: receiving ANY error except 266 means the outbound call FAILED → result:'failure'.\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 // 266 is the one error that is never a failure, on either side.\n if (error instanceof HttpUserError) {\n return true;\n }\n // A client that RECEIVED any error (except the 266 above) made a failed call.\n if (!server) {\n return false;\n }\n // SERVER: only a healthy rejection of the caller's mistake is a non-failure. Note 408\n // (HttpTimeoutError) is intentionally NOT here — the client may never have seen the response.\n return (\n error instanceof HttpBadRequestError ||\n error instanceof HttpUnauthorizedError ||\n error instanceof HttpForbiddenError ||\n error instanceof HttpNotFoundError\n );\n }\n}\n\n/**\n * The process-wide {@link LogApiCallImpl} singleton — mirrors the `RequestContext` export pattern.\n * Callers use `LogApiCall.execute(...)`, never `new`.\n */\nexport const LogApiCall = new LogApiCallImpl();\n"]}
|
|
1
|
+
{"version":3,"file":"LogApiCall.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/http/LogApiCall.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n HttpBadRequestError,\n HttpUnauthorizedError,\n HttpForbiddenError,\n HttpNotFoundError,\n HttpUserError,\n} from './errors';\nimport {toError} from \"../lib/errorUtils\";\nimport {LogManager} from \"../logging/LogManager\";\nimport {ApiCallInfo} from \"./ApiCallInfo\";\nimport {ApiMethodInfo} from \"./ApiMethodInfo\";\nimport {ApiCallContext, ApiCallContextHolder} from \"./ApiCallContext\";\nimport {WebpiecesCoreHeaders} from \"./WebpiecesCoreHeaders\";\n\nconst log = LogManager.getLogger('LogApiCall');\n\n/**\n * LogApiCall - 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 ApiCallContextHolder} 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: `setupRuntime` installs a\n * RequestContext-backed impl on a Node server, and `ClientHttpBrowserFactory` a module-global impl in a\n * browser. If neither ran, {@link ApiCallContextHolder.get} throws (loud misconfiguration).\n *\n * Singleton, mirroring `RequestContext`: use the exported {@link LogApiCall} constant, not `new`.\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 * 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 const requestBody = JSON.stringify(requestDto);\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 = JSON.stringify(response);\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 error;\n }\n }\n\n /**\n * The ApiCallContext to stamp into. Throws if none was installed at startup, or if there is no\n * active scope — loud misconfiguration, because an api call with nowhere to tag is a bug.\n */\n private activeContext(): ApiCallContext {\n const ctx = ApiCallContextHolder.get();\n if (!ctx.isActive()) {\n throw new Error(\n 'LogApiCall requires an ACTIVE ApiCallContext. On a Node server, run inside the ' +\n 'RequestContext.run(...) a server filter opens; in a browser, build ClientHttpBrowserFactory first.',\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 // Side-dependent: a 4xx the SERVER raised is a handled non-failure (OTHER); the same 4xx a\n // CLIENT receives means its call FAILED. HttpUserError (266) is never a failure, either side.\n const isUser = this.isUserError(error, side === 'server');\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 of an already-serialized body. TextEncoder, not Buffer: 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 return new TextEncoder().encode(serialized).length;\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 * The question is \"are things WORKING?\", NOT \"was it an HTTP 4xx vs 5xx\" — and the two are not the\n * same (see 408 below). Classification is by portable Error TYPE, never by transport: LogApiCall runs\n * deep in the stack (in-process calls, pubsub/queue handlers, HTTP — one code path) and only ever\n * sees a thrown Error. The Http* classes are poorly named for that (the `Http` prefix is historical),\n * but they ARE portable error types that travel with the throw anywhere, so matching the TYPE works\n * with or without any HTTP in the picture. Do NOT swap this for an `error.code` 4xx range check — that\n * both re-couples to HTTP AND would bury 408.\n *\n * SERVER — a healthy server correctly rejecting a CLIENT'S mistake is metrics NOISE, not a failure:\n * - HttpBadRequestError (400) \"your request is malformed\"\n * - HttpUnauthorizedError (401) \"you're not authenticated\"\n * - HttpForbiddenError (403) \"authenticated, but not allowed\"\n * - HttpNotFoundError (404) \"wrong url / no such entity\" (EndpointNotFoundError is a subclass)\n * → the server is fine, the caller erred → result:'success', logged OTHER.\n *\n * SERVER — something may actually be WRONG, so SURFACE it (result:'failure', logged FAIL):\n * - HttpTimeoutError (408): a 4xx, but the client may NEVER have seen the response — deliberately\n * absent from the list below so it counts as a failure.\n * - 500 / 502 / 504 / 598, and any non-Http Error: real failures.\n *\n * HttpUserError (266): ALWAYS a non-failure, server OR client — an expected \"user made a mistake\" signal.\n * CLIENT: receiving ANY error except 266 means the outbound call FAILED → result:'failure'.\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 // 266 is the one error that is never a failure, on either side.\n if (error instanceof HttpUserError) {\n return true;\n }\n // A client that RECEIVED any error (except the 266 above) made a failed call.\n if (!server) {\n return false;\n }\n // SERVER: only a healthy rejection of the caller's mistake is a non-failure. Note 408\n // (HttpTimeoutError) is intentionally NOT here — the client may never have seen the response.\n return (\n error instanceof HttpBadRequestError ||\n error instanceof HttpUnauthorizedError ||\n error instanceof HttpForbiddenError ||\n error instanceof HttpNotFoundError\n );\n }\n}\n\n/**\n * The process-wide {@link LogApiCallImpl} singleton — mirrors the `RequestContext` export pattern.\n * Callers use `LogApiCall.execute(...)`, never `new`.\n */\nexport const LogApiCall = new LogApiCallImpl();\n"]}
|
package/src/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type { LoggerFactory } from './logging/LoggerFactory';
|
|
|
15
15
|
export { ConsoleLogger } from './logging/ConsoleLogger';
|
|
16
16
|
export { ConsoleLoggerFactory } from './logging/ConsoleLoggerFactory';
|
|
17
17
|
export { LogManager } from './logging/LogManager';
|
|
18
|
+
export { LogChunker, LogChunkerImpl, LogChunkInfo, MAX_GCP_LOG_BYTES, GCP_LOG_BUDGET_BYTES } from './logging/LogChunker';
|
|
18
19
|
export { ApiPath, Endpoint, Authentication, AuthenticationConfig, Public, AuthJwt, Auth, AuthOidc, AuthSharedSecret, Rpc, PubSub, Queue, getApiPath, getEndpoints, getEndpointOptions, isFormPost, isApiPath, getAuthMeta, getAuthMode, assertEveryEndpointHasAuthMode, getApiKind, assertApiKind, assertPubSubConventions, getQueueName, validateNoConflictingDecorators, AuthMeta, RouteMetadata, METADATA_KEYS, } from './http/decorators';
|
|
19
20
|
export type { AuthMode, ApiKind, JwtRequirement, EndpointOptions } from './http/decorators';
|
|
20
21
|
export { Secrets, SECRETS } from './http/Secrets';
|
package/src/index.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* @packageDocumentation
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.
|
|
12
|
-
exports.SerializedError = exports.SerializedMap = exports.RecordSerializer = exports.getDoNotRecordFields = exports.DoNotRecord = exports.RecordedTestCase = exports.RecordedError = exports.RecordedEndpoint = exports.RecorderKeys = exports.ApiCallContextHolder = exports.ApiMethodInfo = exports.ApiCallInfo = exports.LogApiCallImpl = exports.LogApiCall = exports.ContextMgr = exports.WebpiecesCoreHeaders = exports.templateDeriver = exports.ErrorWireForm = exports.ServiceInfo = exports.ClientRegistry = exports.HeaderRegistry = exports.DateTimeUtil = exports.TimeUtil = exports.DateUtil = exports.InstantUtil = exports.NO_REG_CODE = exports.WRONG_COMPANY = exports.WRONG_DOMAIN = exports.EMAIL_NOT_CONFIRMED = exports.NOT_APPROVED = exports.WRONG_LOGIN = exports.WRONG_LOGIN_TYPE = exports.ENTITY_NOT_FOUND = exports.HttpUserError = exports.HttpVendorError = void 0;
|
|
11
|
+
exports.HttpUnauthorizedError = exports.HttpBadRequestError = exports.EndpointNotFoundError = exports.HttpNotFoundError = exports.HttpError = exports.ProtocolError = exports.SECRETS = exports.Secrets = exports.METADATA_KEYS = exports.RouteMetadata = exports.AuthMeta = exports.validateNoConflictingDecorators = exports.getQueueName = exports.assertPubSubConventions = exports.assertApiKind = exports.getApiKind = exports.assertEveryEndpointHasAuthMode = exports.getAuthMode = exports.getAuthMeta = exports.isApiPath = exports.isFormPost = exports.getEndpointOptions = exports.getEndpoints = exports.getApiPath = exports.Queue = exports.PubSub = exports.Rpc = exports.AuthSharedSecret = exports.AuthOidc = exports.Auth = exports.AuthJwt = exports.Public = exports.AuthenticationConfig = exports.Authentication = exports.Endpoint = exports.ApiPath = exports.GCP_LOG_BUDGET_BYTES = exports.MAX_GCP_LOG_BYTES = exports.LogChunkInfo = exports.LogChunkerImpl = exports.LogChunker = exports.LogManager = exports.ConsoleLoggerFactory = exports.ConsoleLogger = exports.DESIGN_METADATA_KEYS = exports.isDocumentDesign = exports.DocumentDesign = exports.ContextTuple = exports.ContextKey = exports.toError = void 0;
|
|
12
|
+
exports.SerializedError = exports.SerializedMap = exports.RecordSerializer = exports.getDoNotRecordFields = exports.DoNotRecord = exports.RecordedTestCase = exports.RecordedError = exports.RecordedEndpoint = exports.RecorderKeys = exports.ApiCallContextHolder = exports.ApiMethodInfo = exports.ApiCallInfo = exports.LogApiCallImpl = exports.LogApiCall = exports.ContextMgr = exports.WebpiecesCoreHeaders = exports.templateDeriver = exports.ErrorWireForm = exports.ServiceInfo = exports.ClientRegistry = exports.HeaderRegistry = exports.DateTimeUtil = exports.TimeUtil = exports.DateUtil = exports.InstantUtil = exports.NO_REG_CODE = exports.WRONG_COMPANY = exports.WRONG_DOMAIN = exports.EMAIL_NOT_CONFIRMED = exports.NOT_APPROVED = exports.WRONG_LOGIN = exports.WRONG_LOGIN_TYPE = exports.ENTITY_NOT_FOUND = exports.HttpUserError = exports.HttpVendorError = exports.HttpInternalServerError = exports.HttpGatewayTimeoutError = exports.HttpBadGatewayError = exports.HttpTimeoutError = exports.HttpForbiddenError = void 0;
|
|
13
13
|
var errorUtils_1 = require("./lib/errorUtils");
|
|
14
14
|
Object.defineProperty(exports, "toError", { enumerable: true, get: function () { return errorUtils_1.toError; } });
|
|
15
15
|
var ContextKey_1 = require("./ContextKey");
|
|
@@ -29,6 +29,12 @@ var ConsoleLoggerFactory_1 = require("./logging/ConsoleLoggerFactory");
|
|
|
29
29
|
Object.defineProperty(exports, "ConsoleLoggerFactory", { enumerable: true, get: function () { return ConsoleLoggerFactory_1.ConsoleLoggerFactory; } });
|
|
30
30
|
var LogManager_1 = require("./logging/LogManager");
|
|
31
31
|
Object.defineProperty(exports, "LogManager", { enumerable: true, get: function () { return LogManager_1.LogManager; } });
|
|
32
|
+
var LogChunker_1 = require("./logging/LogChunker");
|
|
33
|
+
Object.defineProperty(exports, "LogChunker", { enumerable: true, get: function () { return LogChunker_1.LogChunker; } });
|
|
34
|
+
Object.defineProperty(exports, "LogChunkerImpl", { enumerable: true, get: function () { return LogChunker_1.LogChunkerImpl; } });
|
|
35
|
+
Object.defineProperty(exports, "LogChunkInfo", { enumerable: true, get: function () { return LogChunker_1.LogChunkInfo; } });
|
|
36
|
+
Object.defineProperty(exports, "MAX_GCP_LOG_BYTES", { enumerable: true, get: function () { return LogChunker_1.MAX_GCP_LOG_BYTES; } });
|
|
37
|
+
Object.defineProperty(exports, "GCP_LOG_BUDGET_BYTES", { enumerable: true, get: function () { return LogChunker_1.GCP_LOG_BUDGET_BYTES; } });
|
|
32
38
|
// HTTP API contract (merged from former @webpieces/http-api).
|
|
33
39
|
// Shared HTTP API definition consumed by both client and server: REST
|
|
34
40
|
// decorators, the HttpError hierarchy, datetime DTOs, platform-header
|
package/src/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/core/core-util/src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;GAOG;;;;AAEH,+CAA2C;AAAlC,qGAAA,OAAO,OAAA;AAChB,2CAA0C;AAAjC,wGAAA,UAAU,OAAA;AACnB,+CAA8C;AAArC,4GAAA,YAAY,OAAA;AAErB,+EAA+E;AAC/E,kFAAkF;AAClF,yCAAyC;AACzC,mDAA0F;AAAjF,gHAAA,cAAc,OAAA;AAAE,kHAAA,gBAAgB,OAAA;AAAE,sHAAA,oBAAoB,OAAA;AAO/D,yDAAwD;AAA/C,8GAAA,aAAa,OAAA;AACtB,uEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,mDAAkD;AAAzC,wGAAA,UAAU,OAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/core/core-util/src/index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * @webpieces/core-util\n *\n * Utility functions for WebPieces applications.\n * This package works in both browser and Node.js environments.\n *\n * @packageDocumentation\n */\n\nexport { toError } from './lib/errorUtils';\nexport { ContextKey } from './ContextKey';\nexport { ContextTuple } from './ContextTuple';\n\n// @DocumentDesign — DI-design-root marker. Applies to ANY project kind (server\n// controllers AND library impl classes), so it lives here (browser + Node) rather\n// than in a server-only routing package.\nexport { DocumentDesign, isDocumentDesign, DESIGN_METADATA_KEYS } from './DocumentDesign';\n\n// Logging (merged from former @webpieces/wp-logging).\n// Pluggable logging interface + a browser-safe console default; apps plug in\n// bunyan/winston/pino/etc. via LogManager.setFactory(...). Browser + Node.\nexport type { Logger, LogLevel } from './logging/Logger';\nexport type { LoggerFactory } from './logging/LoggerFactory';\nexport { ConsoleLogger } from './logging/ConsoleLogger';\nexport { ConsoleLoggerFactory } from './logging/ConsoleLoggerFactory';\nexport { LogManager } from './logging/LogManager';\nexport { LogChunker, LogChunkerImpl, LogChunkInfo, MAX_GCP_LOG_BYTES, GCP_LOG_BUDGET_BYTES } from './logging/LogChunker';\n\n// HTTP API contract (merged from former @webpieces/http-api).\n// Shared HTTP API definition consumed by both client and server: REST\n// decorators, the HttpError hierarchy, datetime DTOs, platform-header\n// registry/readers, ValidateImplementation, and the test-case recorder\n// contract. Pure definitions — express-free, browser + Node safe.\n\n// API definition decorators\nexport {\n ApiPath,\n Endpoint,\n Authentication,\n AuthenticationConfig,\n // Auth mode decorators (clean service-to-service + user JWT model)\n Public,\n AuthJwt,\n Auth,\n AuthOidc,\n AuthSharedSecret,\n // API kind (RPC vs PubSub/Cloud Tasks) + queue naming\n Rpc,\n PubSub,\n Queue,\n getApiPath,\n getEndpoints,\n getEndpointOptions,\n isFormPost,\n isApiPath,\n getAuthMeta,\n getAuthMode,\n assertEveryEndpointHasAuthMode,\n getApiKind,\n assertApiKind,\n assertPubSubConventions,\n getQueueName,\n validateNoConflictingDecorators,\n AuthMeta,\n RouteMetadata,\n METADATA_KEYS,\n} from './http/decorators';\nexport type { AuthMode, ApiKind, JwtRequirement, EndpointOptions } from './http/decorators';\n// Client-side shared-secret store (the value THIS service sends per @AuthSharedSecret key).\nexport { Secrets, SECRETS } from './http/Secrets';\n\n// Type validators\nexport { ValidateImplementation } from './http/validators';\n\n// HTTP errors\nexport {\n ProtocolError,\n HttpError,\n HttpNotFoundError,\n EndpointNotFoundError,\n HttpBadRequestError,\n HttpUnauthorizedError,\n HttpForbiddenError,\n HttpTimeoutError,\n HttpBadGatewayError,\n HttpGatewayTimeoutError,\n HttpInternalServerError,\n HttpVendorError,\n HttpUserError,\n // Error subtype constants\n ENTITY_NOT_FOUND,\n WRONG_LOGIN_TYPE,\n WRONG_LOGIN,\n NOT_APPROVED,\n EMAIL_NOT_CONFIRMED,\n WRONG_DOMAIN,\n WRONG_COMPANY,\n NO_REG_CODE,\n} from './http/errors';\n\n// Date/Time DTOs and Utilities (inspired by Java Time / JSR-310)\nexport {\n InstantDto,\n DateDto,\n TimeDto,\n DateTimeDto,\n InstantUtil,\n DateUtil,\n TimeUtil,\n DateTimeUtil,\n} from './http/datetime';\n\n// Context keys + registry (the global magic-context header system)\nexport { HeaderRegistry } from './http/HeaderRegistry';\nexport { ClientRegistry } from './http/ClientRegistry';\nexport type { ServiceUrlDeriver } from './http/ClientRegistry';\n\n// \"What service am I\" — set once at startup, read by the logging backends and by\n// RequestContextHeaders (to stamp requestIdSource on ids this service mints).\nexport { ServiceInfo } from './http/ServiceInfo';\n// Pluggable, bidirectional error translation (app exception <-> wire form). Registered on\n// ClientRegistry at startup; consulted before the built-in webpieces mapping on BOTH sides.\nexport { ErrorWireForm } from './http/ErrorTranslation';\nexport type { ErrorTranslation } from './http/ErrorTranslation';\nexport { templateDeriver } from './http/templateDeriver';\nexport { WebpiecesCoreHeaders } from './http/WebpiecesCoreHeaders';\nexport { ContextReader } from './http/ContextReader';\nexport type { ContextRead, StructuredContextRead } from './http/ContextReader';\n\n// BROWSER-ONLY outbound-header propagation (app-held store + registry -> outbound HTTP headers).\n// Only @webpieces/http-client-browser may name it; the server reads RequestContext directly via\n// RequestContextHeaders in the Node-only @webpieces/core-context.\nexport { ContextMgr } from './http/ContextMgr';\n\n// API-call logging helper (uses LogManager above). Singleton: use the LogApiCall constant, not `new`.\nexport { LogApiCall, LogApiCallImpl } from './http/LogApiCall';\n\n// The structured `api` tag + the context-writer seam LogApiCall stamps through. The Node\n// RequestContext-backed impl is installed by @webpieces/core-context; the browser gets the no-op.\nexport { ApiCallInfo } from './http/ApiCallInfo';\nexport type { ApiType, ApiResult } from './http/ApiCallInfo';\nexport { ApiMethodInfo } from './http/ApiMethodInfo';\nexport type { ApiSide } from './http/ApiMethodInfo';\nexport { ApiCallContextHolder } from './http/ApiCallContext';\nexport type { ApiCallContext } from './http/ApiCallContext';\n\n// Test-case recording contract (impl lives in http-server; hooks in http-client)\nexport { TestCaseRecorder, RecorderKeys } from './http/recorder/TestCaseRecorder';\nexport { RecordedEndpoint, RecordedError, RecordedTestCase } from './http/recorder/RecordedEndpoint';\nexport { DoNotRecord, getDoNotRecordFields } from './http/recorder/DoNotRecord';\nexport { RecordSerializer, SerializedMap, SerializedError } from './http/recorder/RecordSerializer';\n"]}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogChunker - splits an oversized log field into pieces small enough that each emitted record
|
|
3
|
+
* survives GCP Cloud Logging's per-entry size limit.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS: an oversized entry does NOT come back as an error — it is SILENTLY DROPPED.
|
|
6
|
+
* Per the GKE docs: "Any LogEntry exceeding the size limit is dropped for jsonPayload logs and
|
|
7
|
+
* truncated for textPayload logs." Our GCP backends emit structured JSON, so we are squarely in the
|
|
8
|
+
* "dropped" half: a 300KB response body or a giant stack trace makes the whole line vanish with no
|
|
9
|
+
* diagnostic anywhere. Chunking is what turns that silent loss into N recoverable lines.
|
|
10
|
+
*
|
|
11
|
+
* WHY NOT TRUNCATE: the content that blows the limit (a stack trace, a response body) is precisely
|
|
12
|
+
* the content you opened the logs to read. Splitting keeps all of it.
|
|
13
|
+
*
|
|
14
|
+
* WHY NOT SPLIT THE SERIALIZED LINE: a fragment of a JSON line is not valid JSON, and the logging
|
|
15
|
+
* agent would file each piece as an unparsed `textPayload` — losing every structured field. So
|
|
16
|
+
* callers chunk a FIELD and emit N COMPLETE records, each carrying a `logChunk` tag.
|
|
17
|
+
*
|
|
18
|
+
* WHY GCP's OWN SPLITTING DOES NOT HELP: Cloud Logging's `LogSplit` (split.uid/index/totalSplits) is
|
|
19
|
+
* only applied to Google-generated audit logs, never to user-written entries. We mirror its field
|
|
20
|
+
* shape ({@link LogChunkInfo}) but must do the work ourselves.
|
|
21
|
+
*
|
|
22
|
+
* BROWSER-SAFE: lives in core-util, which ships in the browser bundle — so `TextEncoder`, never
|
|
23
|
+
* `Buffer`.
|
|
24
|
+
*
|
|
25
|
+
* Singleton, mirroring `LogApiCall` / `RequestContext`: use the exported {@link LogChunker}, not `new`.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* GCP Cloud Logging's maximum size for a single LogEntry: 256 KiB. Note KiB, not KB — the docs say
|
|
29
|
+
* 256 KiB, so 262,144 bytes and NOT the 256,000 that several client libraries hardcode as their own
|
|
30
|
+
* conservative guard.
|
|
31
|
+
*/
|
|
32
|
+
export declare const MAX_GCP_LOG_BYTES = 262144;
|
|
33
|
+
/**
|
|
34
|
+
* The per-record budget we actually chunk to: 75% of {@link MAX_GCP_LOG_BYTES}.
|
|
35
|
+
*
|
|
36
|
+
* The 25% headroom is NOT superstition — three things eat into the limit that a caller cannot see:
|
|
37
|
+
* 1. The limit is explicitly "approximate and based on internal data sizes, not the actual REST API
|
|
38
|
+
* request size" (GCP quotas docs), so byte-exact packing against 262,144 is not a thing you can do.
|
|
39
|
+
* 2. Labels, resource, and metadata share the entry's budget with the payload.
|
|
40
|
+
* 3. The record envelope — context keys (requestId, tenantId, ...), the `api` tag, svcName, severity,
|
|
41
|
+
* timestamps — is serialized alongside the field being chunked.
|
|
42
|
+
*/
|
|
43
|
+
export declare const GCP_LOG_BUDGET_BYTES = 196608;
|
|
44
|
+
/**
|
|
45
|
+
* The tag stamped on every record of a split message, mirroring GCP's own `LogSplit` shape.
|
|
46
|
+
* Data-only structure → a class, per CLAUDE.md.
|
|
47
|
+
*
|
|
48
|
+
* Reassembling in Cloud Logging: filter `jsonPayload.logChunk.uid="<uid>"`, sort by
|
|
49
|
+
* `jsonPayload.logChunk.index`, concatenate the `message` fields.
|
|
50
|
+
*
|
|
51
|
+
* WHY A DEDICATED uid, given every line already carries requestId: requestId correlates a whole
|
|
52
|
+
* REQUEST, which emits many lines (LogApiCall alone emits a request line AND a response line per
|
|
53
|
+
* call). It cannot tell you which lines are pieces of ONE message. This uid can.
|
|
54
|
+
*/
|
|
55
|
+
export declare class LogChunkInfo {
|
|
56
|
+
/** Correlates the pieces of ONE split message. */
|
|
57
|
+
readonly uid: string;
|
|
58
|
+
/** 0-based position of this piece. */
|
|
59
|
+
readonly index: number;
|
|
60
|
+
/** How many pieces this message was split into. */
|
|
61
|
+
readonly total: number;
|
|
62
|
+
constructor(
|
|
63
|
+
/** Correlates the pieces of ONE split message. */
|
|
64
|
+
uid: string,
|
|
65
|
+
/** 0-based position of this piece. */
|
|
66
|
+
index: number,
|
|
67
|
+
/** How many pieces this message was split into. */
|
|
68
|
+
total: number);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* How many bytes each of two chunked fields may spend PER RECORD. Data-only structure → a class.
|
|
72
|
+
*/
|
|
73
|
+
export declare class ChunkBudgets {
|
|
74
|
+
readonly firstBudget: number;
|
|
75
|
+
readonly secondBudget: number;
|
|
76
|
+
constructor(firstBudget: number, secondBudget: number);
|
|
77
|
+
}
|
|
78
|
+
export declare class LogChunkerImpl {
|
|
79
|
+
private readonly encoder;
|
|
80
|
+
/**
|
|
81
|
+
* Plain UTF-8 byte length — for measuring text that is ALREADY in its final serialized form
|
|
82
|
+
* (e.g. winston's fully-rendered JSON line), where no further escaping will happen.
|
|
83
|
+
*/
|
|
84
|
+
byteLength(text: string): number;
|
|
85
|
+
/**
|
|
86
|
+
* The byte cost of `text` once it has been JSON-escaped as a string VALUE inside a record.
|
|
87
|
+
*
|
|
88
|
+
* This is the measurement that matters, and it is why chunking on raw UTF-8 length is a bug: a
|
|
89
|
+
* log message holding a JSON body is escaped a SECOND time when the record is serialized, so
|
|
90
|
+
* every `"` becomes `\"`, every newline `\n`, and a control character explodes to a 6-byte
|
|
91
|
+
* `\u00XX`. A body that is dense in quotes can inflate by ~2x on that second pass — enough to
|
|
92
|
+
* push a "196KB" chunk past the 262KB ceiling and silently drop it.
|
|
93
|
+
*
|
|
94
|
+
* Exact for `JSON.stringify` semantics (V8 does not \u-escape non-ASCII). For the bunyan GCP
|
|
95
|
+
* path — which ships over gRPC/protobuf rather than as JSON text — this over-counts slightly,
|
|
96
|
+
* which is the safe direction.
|
|
97
|
+
*/
|
|
98
|
+
escapedByteLength(text: string): number;
|
|
99
|
+
/**
|
|
100
|
+
* Split `text` so each piece costs at most `maxBytes` once JSON-escaped
|
|
101
|
+
* (see {@link escapedByteLength}).
|
|
102
|
+
*
|
|
103
|
+
* GUARANTEES:
|
|
104
|
+
* - `chunk(t, n).join('') === t` — nothing is lost, so the pieces reassemble exactly.
|
|
105
|
+
* - No piece splits a code point: a 4-byte emoji or a CJK character is never cut in half (which
|
|
106
|
+
* would corrupt the boundary character into replacement junk on reassembly).
|
|
107
|
+
* - Always returns at least one piece (`['']` for empty input), so callers can treat the result
|
|
108
|
+
* uniformly.
|
|
109
|
+
*
|
|
110
|
+
* Degenerate case: if a SINGLE code point costs more than `maxBytes`, that piece necessarily
|
|
111
|
+
* exceeds the budget — unavoidable, and irrelevant at any sane budget (max cost is 6 bytes).
|
|
112
|
+
*/
|
|
113
|
+
chunk(text: string, maxBytes: number): string[];
|
|
114
|
+
/**
|
|
115
|
+
* Divide a record's budget between the TWO fields a backend chunks — the message and the stack
|
|
116
|
+
* trace. Shared by the winston and bunyan GCP backends, which differ only in what those fields
|
|
117
|
+
* are called (`message`/`errStack` vs `msg`/`err.stack`), never in this arithmetic.
|
|
118
|
+
*
|
|
119
|
+
* The envelope's cost is derived by SUBTRACTION: `renderedBytes` minus the escaped cost of the
|
|
120
|
+
* two fields IS the envelope, whatever it happens to hold. That stays correct as apps register
|
|
121
|
+
* new context keys, where summing up known parts would silently drift.
|
|
122
|
+
*
|
|
123
|
+
* The split: whichever field is small enough to fit whole gets exactly what it needs and the
|
|
124
|
+
* other takes the rest; if BOTH are oversized they share evenly. Either way record N holds
|
|
125
|
+
* first[N] + second[N] and still lands within budget.
|
|
126
|
+
*
|
|
127
|
+
* @param renderedBytes - size of the fully-serialized record as it stands today
|
|
128
|
+
* @param budgetBytes - the per-record ceiling (typically {@link GCP_LOG_BUDGET_BYTES})
|
|
129
|
+
*/
|
|
130
|
+
chunkBudgets(renderedBytes: number, budgetBytes: number, first: string, second: string): ChunkBudgets;
|
|
131
|
+
/**
|
|
132
|
+
* A fresh id correlating the pieces of one split message. Uses Math.random rather than
|
|
133
|
+
* crypto.randomUUID so it works in every browser context (randomUUID needs a secure context) and
|
|
134
|
+
* on older Node — matching how RequestContextHeaders generates its fallback request id. These
|
|
135
|
+
* only need to be unique among the lines an operator is grepping, not cryptographically strong.
|
|
136
|
+
*/
|
|
137
|
+
newUid(): string;
|
|
138
|
+
/** Bytes this code point occupies once JSON-escaped inside a string value. */
|
|
139
|
+
private escapedCost;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The process-wide {@link LogChunkerImpl} singleton — mirrors the `LogApiCall` export pattern.
|
|
143
|
+
* Callers use `LogChunker.chunk(...)`, never `new`.
|
|
144
|
+
*/
|
|
145
|
+
export declare const LogChunker: LogChunkerImpl;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* LogChunker - splits an oversized log field into pieces small enough that each emitted record
|
|
4
|
+
* survives GCP Cloud Logging's per-entry size limit.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS: an oversized entry does NOT come back as an error — it is SILENTLY DROPPED.
|
|
7
|
+
* Per the GKE docs: "Any LogEntry exceeding the size limit is dropped for jsonPayload logs and
|
|
8
|
+
* truncated for textPayload logs." Our GCP backends emit structured JSON, so we are squarely in the
|
|
9
|
+
* "dropped" half: a 300KB response body or a giant stack trace makes the whole line vanish with no
|
|
10
|
+
* diagnostic anywhere. Chunking is what turns that silent loss into N recoverable lines.
|
|
11
|
+
*
|
|
12
|
+
* WHY NOT TRUNCATE: the content that blows the limit (a stack trace, a response body) is precisely
|
|
13
|
+
* the content you opened the logs to read. Splitting keeps all of it.
|
|
14
|
+
*
|
|
15
|
+
* WHY NOT SPLIT THE SERIALIZED LINE: a fragment of a JSON line is not valid JSON, and the logging
|
|
16
|
+
* agent would file each piece as an unparsed `textPayload` — losing every structured field. So
|
|
17
|
+
* callers chunk a FIELD and emit N COMPLETE records, each carrying a `logChunk` tag.
|
|
18
|
+
*
|
|
19
|
+
* WHY GCP's OWN SPLITTING DOES NOT HELP: Cloud Logging's `LogSplit` (split.uid/index/totalSplits) is
|
|
20
|
+
* only applied to Google-generated audit logs, never to user-written entries. We mirror its field
|
|
21
|
+
* shape ({@link LogChunkInfo}) but must do the work ourselves.
|
|
22
|
+
*
|
|
23
|
+
* BROWSER-SAFE: lives in core-util, which ships in the browser bundle — so `TextEncoder`, never
|
|
24
|
+
* `Buffer`.
|
|
25
|
+
*
|
|
26
|
+
* Singleton, mirroring `LogApiCall` / `RequestContext`: use the exported {@link LogChunker}, not `new`.
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.LogChunker = exports.LogChunkerImpl = exports.ChunkBudgets = exports.LogChunkInfo = exports.GCP_LOG_BUDGET_BYTES = exports.MAX_GCP_LOG_BYTES = void 0;
|
|
30
|
+
/**
|
|
31
|
+
* GCP Cloud Logging's maximum size for a single LogEntry: 256 KiB. Note KiB, not KB — the docs say
|
|
32
|
+
* 256 KiB, so 262,144 bytes and NOT the 256,000 that several client libraries hardcode as their own
|
|
33
|
+
* conservative guard.
|
|
34
|
+
*/
|
|
35
|
+
exports.MAX_GCP_LOG_BYTES = 262_144;
|
|
36
|
+
/**
|
|
37
|
+
* The per-record budget we actually chunk to: 75% of {@link MAX_GCP_LOG_BYTES}.
|
|
38
|
+
*
|
|
39
|
+
* The 25% headroom is NOT superstition — three things eat into the limit that a caller cannot see:
|
|
40
|
+
* 1. The limit is explicitly "approximate and based on internal data sizes, not the actual REST API
|
|
41
|
+
* request size" (GCP quotas docs), so byte-exact packing against 262,144 is not a thing you can do.
|
|
42
|
+
* 2. Labels, resource, and metadata share the entry's budget with the payload.
|
|
43
|
+
* 3. The record envelope — context keys (requestId, tenantId, ...), the `api` tag, svcName, severity,
|
|
44
|
+
* timestamps — is serialized alongside the field being chunked.
|
|
45
|
+
*/
|
|
46
|
+
exports.GCP_LOG_BUDGET_BYTES = 196_608;
|
|
47
|
+
/**
|
|
48
|
+
* The tag stamped on every record of a split message, mirroring GCP's own `LogSplit` shape.
|
|
49
|
+
* Data-only structure → a class, per CLAUDE.md.
|
|
50
|
+
*
|
|
51
|
+
* Reassembling in Cloud Logging: filter `jsonPayload.logChunk.uid="<uid>"`, sort by
|
|
52
|
+
* `jsonPayload.logChunk.index`, concatenate the `message` fields.
|
|
53
|
+
*
|
|
54
|
+
* WHY A DEDICATED uid, given every line already carries requestId: requestId correlates a whole
|
|
55
|
+
* REQUEST, which emits many lines (LogApiCall alone emits a request line AND a response line per
|
|
56
|
+
* call). It cannot tell you which lines are pieces of ONE message. This uid can.
|
|
57
|
+
*/
|
|
58
|
+
class LogChunkInfo {
|
|
59
|
+
uid;
|
|
60
|
+
index;
|
|
61
|
+
total;
|
|
62
|
+
constructor(
|
|
63
|
+
/** Correlates the pieces of ONE split message. */
|
|
64
|
+
uid,
|
|
65
|
+
/** 0-based position of this piece. */
|
|
66
|
+
index,
|
|
67
|
+
/** How many pieces this message was split into. */
|
|
68
|
+
total) {
|
|
69
|
+
this.uid = uid;
|
|
70
|
+
this.index = index;
|
|
71
|
+
this.total = total;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.LogChunkInfo = LogChunkInfo;
|
|
75
|
+
/**
|
|
76
|
+
* Bytes reserved for the `logChunk` tag a backend adds to each piece, e.g.
|
|
77
|
+
* `,"logChunk":{"uid":"chunk-mabc1234-x7f2q1","index":12,"total":34}` — ~70 bytes, rounded up.
|
|
78
|
+
*/
|
|
79
|
+
const CHUNK_TAG_BYTES = 128;
|
|
80
|
+
/**
|
|
81
|
+
* Floor for a per-record field budget. Only reachable if the ENVELOPE alone (context keys, the api
|
|
82
|
+
* tag, svcName) already fills the budget — pathological, and slicing a message into 1-byte pieces
|
|
83
|
+
* would be worse than emitting one slightly-oversized record.
|
|
84
|
+
*/
|
|
85
|
+
const MIN_FIELD_BYTES = 1024;
|
|
86
|
+
/**
|
|
87
|
+
* How many bytes each of two chunked fields may spend PER RECORD. Data-only structure → a class.
|
|
88
|
+
*/
|
|
89
|
+
class ChunkBudgets {
|
|
90
|
+
firstBudget;
|
|
91
|
+
secondBudget;
|
|
92
|
+
constructor(firstBudget, secondBudget) {
|
|
93
|
+
this.firstBudget = firstBudget;
|
|
94
|
+
this.secondBudget = secondBudget;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
exports.ChunkBudgets = ChunkBudgets;
|
|
98
|
+
class LogChunkerImpl {
|
|
99
|
+
encoder = new TextEncoder();
|
|
100
|
+
/**
|
|
101
|
+
* Plain UTF-8 byte length — for measuring text that is ALREADY in its final serialized form
|
|
102
|
+
* (e.g. winston's fully-rendered JSON line), where no further escaping will happen.
|
|
103
|
+
*/
|
|
104
|
+
byteLength(text) {
|
|
105
|
+
return this.encoder.encode(text).length;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The byte cost of `text` once it has been JSON-escaped as a string VALUE inside a record.
|
|
109
|
+
*
|
|
110
|
+
* This is the measurement that matters, and it is why chunking on raw UTF-8 length is a bug: a
|
|
111
|
+
* log message holding a JSON body is escaped a SECOND time when the record is serialized, so
|
|
112
|
+
* every `"` becomes `\"`, every newline `\n`, and a control character explodes to a 6-byte
|
|
113
|
+
* `\u00XX`. A body that is dense in quotes can inflate by ~2x on that second pass — enough to
|
|
114
|
+
* push a "196KB" chunk past the 262KB ceiling and silently drop it.
|
|
115
|
+
*
|
|
116
|
+
* Exact for `JSON.stringify` semantics (V8 does not \u-escape non-ASCII). For the bunyan GCP
|
|
117
|
+
* path — which ships over gRPC/protobuf rather than as JSON text — this over-counts slightly,
|
|
118
|
+
* which is the safe direction.
|
|
119
|
+
*/
|
|
120
|
+
escapedByteLength(text) {
|
|
121
|
+
let bytes = 0;
|
|
122
|
+
// for...of iterates CODE POINTS, so a surrogate pair is one step, not two.
|
|
123
|
+
for (const char of text) {
|
|
124
|
+
bytes += this.escapedCost(char.codePointAt(0));
|
|
125
|
+
}
|
|
126
|
+
return bytes;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Split `text` so each piece costs at most `maxBytes` once JSON-escaped
|
|
130
|
+
* (see {@link escapedByteLength}).
|
|
131
|
+
*
|
|
132
|
+
* GUARANTEES:
|
|
133
|
+
* - `chunk(t, n).join('') === t` — nothing is lost, so the pieces reassemble exactly.
|
|
134
|
+
* - No piece splits a code point: a 4-byte emoji or a CJK character is never cut in half (which
|
|
135
|
+
* would corrupt the boundary character into replacement junk on reassembly).
|
|
136
|
+
* - Always returns at least one piece (`['']` for empty input), so callers can treat the result
|
|
137
|
+
* uniformly.
|
|
138
|
+
*
|
|
139
|
+
* Degenerate case: if a SINGLE code point costs more than `maxBytes`, that piece necessarily
|
|
140
|
+
* exceeds the budget — unavoidable, and irrelevant at any sane budget (max cost is 6 bytes).
|
|
141
|
+
*/
|
|
142
|
+
chunk(text, maxBytes) {
|
|
143
|
+
if (maxBytes <= 0) {
|
|
144
|
+
throw new Error(`maxBytes must be positive, was ${maxBytes}`);
|
|
145
|
+
}
|
|
146
|
+
if (this.escapedByteLength(text) <= maxBytes) {
|
|
147
|
+
return [text];
|
|
148
|
+
}
|
|
149
|
+
const chunks = [];
|
|
150
|
+
let start = 0;
|
|
151
|
+
// UTF-16 index (what slice() wants), advanced by each code point's unit length so every
|
|
152
|
+
// boundary we cut on is a code-point boundary.
|
|
153
|
+
let position = 0;
|
|
154
|
+
let bytes = 0;
|
|
155
|
+
for (const char of text) {
|
|
156
|
+
const cost = this.escapedCost(char.codePointAt(0));
|
|
157
|
+
if (bytes + cost > maxBytes && position > start) {
|
|
158
|
+
chunks.push(text.slice(start, position));
|
|
159
|
+
start = position;
|
|
160
|
+
bytes = 0;
|
|
161
|
+
}
|
|
162
|
+
bytes += cost;
|
|
163
|
+
position += char.length;
|
|
164
|
+
}
|
|
165
|
+
chunks.push(text.slice(start));
|
|
166
|
+
return chunks;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Divide a record's budget between the TWO fields a backend chunks — the message and the stack
|
|
170
|
+
* trace. Shared by the winston and bunyan GCP backends, which differ only in what those fields
|
|
171
|
+
* are called (`message`/`errStack` vs `msg`/`err.stack`), never in this arithmetic.
|
|
172
|
+
*
|
|
173
|
+
* The envelope's cost is derived by SUBTRACTION: `renderedBytes` minus the escaped cost of the
|
|
174
|
+
* two fields IS the envelope, whatever it happens to hold. That stays correct as apps register
|
|
175
|
+
* new context keys, where summing up known parts would silently drift.
|
|
176
|
+
*
|
|
177
|
+
* The split: whichever field is small enough to fit whole gets exactly what it needs and the
|
|
178
|
+
* other takes the rest; if BOTH are oversized they share evenly. Either way record N holds
|
|
179
|
+
* first[N] + second[N] and still lands within budget.
|
|
180
|
+
*
|
|
181
|
+
* @param renderedBytes - size of the fully-serialized record as it stands today
|
|
182
|
+
* @param budgetBytes - the per-record ceiling (typically {@link GCP_LOG_BUDGET_BYTES})
|
|
183
|
+
*/
|
|
184
|
+
chunkBudgets(renderedBytes, budgetBytes, first, second) {
|
|
185
|
+
const firstBytes = this.escapedByteLength(first);
|
|
186
|
+
const secondBytes = this.escapedByteLength(second);
|
|
187
|
+
const envelopeBytes = renderedBytes - firstBytes - secondBytes;
|
|
188
|
+
const available = Math.max(budgetBytes - envelopeBytes - CHUNK_TAG_BYTES, MIN_FIELD_BYTES);
|
|
189
|
+
const half = Math.floor(available / 2);
|
|
190
|
+
// Both too big to fit alongside anything → split the room evenly.
|
|
191
|
+
if (firstBytes > half && secondBytes > half) {
|
|
192
|
+
return new ChunkBudgets(half, half);
|
|
193
|
+
}
|
|
194
|
+
// A field that fits in one piece is given exactly its own size (never 0 — chunk() rejects
|
|
195
|
+
// that), and the oversized field gets everything left over.
|
|
196
|
+
if (secondBytes <= half) {
|
|
197
|
+
return new ChunkBudgets(Math.max(available - secondBytes, MIN_FIELD_BYTES), Math.max(secondBytes, 1));
|
|
198
|
+
}
|
|
199
|
+
return new ChunkBudgets(Math.max(firstBytes, 1), Math.max(available - firstBytes, MIN_FIELD_BYTES));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A fresh id correlating the pieces of one split message. Uses Math.random rather than
|
|
203
|
+
* crypto.randomUUID so it works in every browser context (randomUUID needs a secure context) and
|
|
204
|
+
* on older Node — matching how RequestContextHeaders generates its fallback request id. These
|
|
205
|
+
* only need to be unique among the lines an operator is grepping, not cryptographically strong.
|
|
206
|
+
*/
|
|
207
|
+
newUid() {
|
|
208
|
+
return `chunk-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 10)}`;
|
|
209
|
+
}
|
|
210
|
+
/** Bytes this code point occupies once JSON-escaped inside a string value. */
|
|
211
|
+
escapedCost(codePoint) {
|
|
212
|
+
// The characters JSON.stringify escapes with a 2-char backslash sequence: " \ \b \t \n \f \r
|
|
213
|
+
if (codePoint === 0x22 || codePoint === 0x5c) {
|
|
214
|
+
return 2;
|
|
215
|
+
}
|
|
216
|
+
if (codePoint === 0x08 ||
|
|
217
|
+
codePoint === 0x09 ||
|
|
218
|
+
codePoint === 0x0a ||
|
|
219
|
+
codePoint === 0x0c ||
|
|
220
|
+
codePoint === 0x0d) {
|
|
221
|
+
return 2;
|
|
222
|
+
}
|
|
223
|
+
// Every other control character becomes a 6-byte \u00XX escape.
|
|
224
|
+
if (codePoint < 0x20) {
|
|
225
|
+
return 6;
|
|
226
|
+
}
|
|
227
|
+
// Otherwise the character is emitted as-is, costing its UTF-8 length.
|
|
228
|
+
if (codePoint < 0x80) {
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
if (codePoint < 0x800) {
|
|
232
|
+
return 2;
|
|
233
|
+
}
|
|
234
|
+
if (codePoint < 0x10000) {
|
|
235
|
+
return 3;
|
|
236
|
+
}
|
|
237
|
+
return 4;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
exports.LogChunkerImpl = LogChunkerImpl;
|
|
241
|
+
/**
|
|
242
|
+
* The process-wide {@link LogChunkerImpl} singleton — mirrors the `LogApiCall` export pattern.
|
|
243
|
+
* Callers use `LogChunker.chunk(...)`, never `new`.
|
|
244
|
+
*/
|
|
245
|
+
exports.LogChunker = new LogChunkerImpl();
|
|
246
|
+
//# sourceMappingURL=LogChunker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LogChunker.js","sourceRoot":"","sources":["../../../../../../packages/core/core-util/src/logging/LogChunker.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;;;AAEH;;;;GAIG;AACU,QAAA,iBAAiB,GAAG,OAAO,CAAC;AAEzC;;;;;;;;;GASG;AACU,QAAA,oBAAoB,GAAG,OAAO,CAAC;AAE5C;;;;;;;;;;GAUG;AACH,MAAa,YAAY;IAGR;IAEA;IAEA;IANb;IACI,kDAAkD;IACzC,GAAW;IACpB,sCAAsC;IAC7B,KAAa;IACtB,mDAAmD;IAC1C,KAAa;QAJb,QAAG,GAAH,GAAG,CAAQ;QAEX,UAAK,GAAL,KAAK,CAAQ;QAEb,UAAK,GAAL,KAAK,CAAQ;IACvB,CAAC;CACP;AATD,oCASC;AAED;;;GAGG;AACH,MAAM,eAAe,GAAG,GAAG,CAAC;AAE5B;;;;GAIG;AACH,MAAM,eAAe,GAAG,IAAI,CAAC;AAE7B;;GAEG;AACH,MAAa,YAAY;IAER;IACA;IAFb,YACa,WAAmB,EACnB,YAAoB;QADpB,gBAAW,GAAX,WAAW,CAAQ;QACnB,iBAAY,GAAZ,YAAY,CAAQ;IAC9B,CAAC;CACP;AALD,oCAKC;AAED,MAAa,cAAc;IACN,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;IAE7C;;;OAGG;IACH,UAAU,CAAC,IAAY;QACnB,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC;IAC5C,CAAC;IAED;;;;;;;;;;;;OAYG;IACH,iBAAiB,CAAC,IAAY;QAC1B,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,2EAA2E;QAC3E,KAAK,MAAM,IAAI,IAAI,IAAI,EAAE,CAAC;YACtB,KAAK,IAAI,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAE,CAAC,CAAC;QACpD,CAAC;QACD,OAAO,KAAK,CAAC;IACjB,CAAC;IAED;;;;;;;;;;;;;OAaG;IACH,KAAK,CAAC,IAAY,EAAE,QAAgB;QAChC,IAAI,QAAQ,IAAI,CAAC,EAAE,CAAC;YAChB,MAAM,IAAI,KAAK,CAAC,kCAAkC,QAAQ,EAAE,CAAC,CAAC;QAClE,CAAC;QACD,IAAI,IAAI,CAAC,iBAAiB,CAAC,IAAI,CAAC,IAAI,QAAQ,EAAE,CAAC;YAC3C,OAAO,CAAC,IAAI,CAAC,CAAC;QAClB,CAAC;QAED,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,wFAAwF;QACxF,+CAA+C;QAC/C,IAAI,QAAQ,GAAG,CAAC,CAAC;QACjB,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,KAAK,MAAM,IAAI,IAAI,IAAI,EAAE,CAAC;YACtB,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAE,CAAC,CAAC;YACpD,IAAI,KAAK,GAAG,IAAI,GAAG,QAAQ,IAAI,QAAQ,GAAG,KAAK,EAAE,CAAC;gBAC9C,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC;gBACzC,KAAK,GAAG,QAAQ,CAAC;gBACjB,KAAK,GAAG,CAAC,CAAC;YACd,CAAC;YACD,KAAK,IAAI,IAAI,CAAC;YACd,QAAQ,IAAI,IAAI,CAAC,MAAM,CAAC;QAC5B,CAAC;QACD,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;QAC/B,OAAO,MAAM,CAAC;IAClB,CAAC;IAED;;;;;;;;;;;;;;;OAeG;IACH,YAAY,CAAC,aAAqB,EAAE,WAAmB,EAAE,KAAa,EAAE,MAAc;QAClF,MAAM,UAAU,GAAG,IAAI,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC;QACjD,MAAM,WAAW,GAAG,IAAI,CAAC,iBAAiB,CAAC,MAAM,CAAC,CAAC;QACnD,MAAM,aAAa,GAAG,aAAa,GAAG,UAAU,GAAG,WAAW,CAAC;QAC/D,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,WAAW,GAAG,aAAa,GAAG,eAAe,EAAE,eAAe,CAAC,CAAC;QAC3F,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC;QAEvC,kEAAkE;QAClE,IAAI,UAAU,GAAG,IAAI,IAAI,WAAW,GAAG,IAAI,EAAE,CAAC;YAC1C,OAAO,IAAI,YAAY,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACxC,CAAC;QACD,0FAA0F;QAC1F,4DAA4D;QAC5D,IAAI,WAAW,IAAI,IAAI,EAAE,CAAC;YACtB,OAAO,IAAI,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,GAAG,WAAW,EAAE,eAAe,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,CAAC;QAC1G,CAAC;QACD,OAAO,IAAI,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,SAAS,GAAG,UAAU,EAAE,eAAe,CAAC,CAAC,CAAC;IACxG,CAAC;IAED;;;;;OAKG;IACH,MAAM;QACF,OAAO,SAAS,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC;IAC7F,CAAC;IAED,8EAA8E;IACtE,WAAW,CAAC,SAAiB;QACjC,6FAA6F;QAC7F,IAAI,SAAS,KAAK,IAAI,IAAI,SAAS,KAAK,IAAI,EAAE,CAAC;YAC3C,OAAO,CAAC,CAAC;QACb,CAAC;QACD,IACI,SAAS,KAAK,IAAI;YAClB,SAAS,KAAK,IAAI;YAClB,SAAS,KAAK,IAAI;YAClB,SAAS,KAAK,IAAI;YAClB,SAAS,KAAK,IAAI,EACpB,CAAC;YACC,OAAO,CAAC,CAAC;QACb,CAAC;QACD,gEAAgE;QAChE,IAAI,SAAS,GAAG,IAAI,EAAE,CAAC;YACnB,OAAO,CAAC,CAAC;QACb,CAAC;QACD,sEAAsE;QACtE,IAAI,SAAS,GAAG,IAAI,EAAE,CAAC;YACnB,OAAO,CAAC,CAAC;QACb,CAAC;QACD,IAAI,SAAS,GAAG,KAAK,EAAE,CAAC;YACpB,OAAO,CAAC,CAAC;QACb,CAAC;QACD,IAAI,SAAS,GAAG,OAAO,EAAE,CAAC;YACtB,OAAO,CAAC,CAAC;QACb,CAAC;QACD,OAAO,CAAC,CAAC;IACb,CAAC;CACJ;AAvJD,wCAuJC;AAED;;;GAGG;AACU,QAAA,UAAU,GAAG,IAAI,cAAc,EAAE,CAAC","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 `LogApiCall` / `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 `LogApiCall` export pattern.\n * Callers use `LogChunker.chunk(...)`, never `new`.\n */\nexport const LogChunker = new LogChunkerImpl();\n"]}
|