@juspay/neurolink 12.17.1 → 12.17.2
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/CHANGELOG.md +2 -2
- package/dist/browser/neurolink.min.js +421 -412
- package/dist/cli/commands/proxy.js +455 -259
- package/dist/constants/contextWindows.d.ts +2 -0
- package/dist/constants/contextWindows.js +4 -0
- package/dist/proxy/anthropicBudgetResponse.d.ts +3 -0
- package/dist/proxy/anthropicBudgetResponse.js +153 -0
- package/dist/proxy/bodyCaptureProcessing.d.ts +1 -0
- package/dist/proxy/bodyCaptureProcessing.js +23 -4
- package/dist/proxy/bodyCaptureWorker.js +20 -2
- package/dist/proxy/codexFallback.d.ts +10 -0
- package/dist/proxy/codexFallback.js +108 -24
- package/dist/proxy/codexUsage.js +2 -0
- package/dist/proxy/globalInstaller.d.ts +32 -1
- package/dist/proxy/globalInstaller.js +386 -3
- package/dist/proxy/otelLogSink.d.ts +3 -3
- package/dist/proxy/otelLogSink.js +137 -144
- package/dist/proxy/otlpLogTransport.d.ts +3 -0
- package/dist/proxy/otlpLogTransport.js +334 -0
- package/dist/proxy/proxyActivity.d.ts +22 -2
- package/dist/proxy/proxyActivity.js +114 -9
- package/dist/proxy/proxyAnalysis.js +82 -52
- package/dist/proxy/proxyClientCancellation.d.ts +7 -0
- package/dist/proxy/proxyClientCancellation.js +23 -0
- package/dist/proxy/proxyContextPreflight.d.ts +25 -0
- package/dist/proxy/proxyContextPreflight.js +337 -0
- package/dist/proxy/proxyFailureDetails.d.ts +19 -0
- package/dist/proxy/proxyFailureDetails.js +96 -0
- package/dist/proxy/proxyLifecycle.js +7 -0
- package/dist/proxy/proxyRouteBodyCapture.d.ts +13 -0
- package/dist/proxy/proxyRouteBodyCapture.js +112 -0
- package/dist/proxy/proxyTokenBudget.d.ts +40 -0
- package/dist/proxy/proxyTokenBudget.js +523 -0
- package/dist/proxy/proxyTracer.d.ts +3 -2
- package/dist/proxy/proxyTracer.js +46 -20
- package/dist/proxy/proxyTranslationEngine.d.ts +8 -10
- package/dist/proxy/proxyTranslationEngine.js +736 -376
- package/dist/proxy/requestLogger.d.ts +1 -0
- package/dist/proxy/requestLogger.js +107 -5
- package/dist/proxy/restartControl.js +9 -4
- package/dist/proxy/rollingWorkerProcess.js +65 -0
- package/dist/proxy/rollingWorkerSupervisor.js +7 -0
- package/dist/proxy/updateCoordinator.d.ts +14 -1
- package/dist/proxy/updateCoordinator.js +57 -2
- package/dist/server/routes/claudeProxyRoutes.d.ts +4 -5
- package/dist/server/routes/claudeProxyRoutes.js +809 -577
- package/dist/server/routes/codexProxyRoutes.js +191 -9
- package/dist/server/routes/openaiProxyRoutes.d.ts +1 -1
- package/dist/server/routes/openaiProxyRoutes.js +261 -69
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/codex.d.ts +5 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/proxy.d.ts +148 -3
- package/dist/types/proxy.js +0 -15
- package/dist/types/proxyBudget.d.ts +74 -0
- package/dist/types/proxyBudget.js +1 -0
- package/dist/types/proxyContext.d.ts +35 -0
- package/dist/types/proxyContext.js +1 -0
- package/docs/assets/dashboards/README.md +37 -0
- package/docs/assets/dashboards/neurolink-proxy-observability-dashboard.json +197 -89
- package/docs-site/static/search-index.json +1 -1
- package/package.json +3 -1
- package/scripts/observability/NATIVE-DURABLE.md +63 -0
- package/scripts/observability/otel-collector.proxy-native-durable.yaml +93 -0
- package/scripts/observability/proxy-telemetry-backend.mjs +32 -5
- package/scripts/observability/proxy-telemetry-check.mjs +396 -61
- package/scripts/observability/query-proxy-history.mjs +34 -5
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTLP/HTTP JSON transport with response-aware acknowledgement. The SDK's HTTP
|
|
3
|
+
* exporter reports SUCCESS even for partialSuccess or malformed responses, so
|
|
4
|
+
* its callback cannot establish that every record in our batch was accepted.
|
|
5
|
+
* Serialization stays with the official OpenTelemetry implementation.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { Agent as HttpAgent, request as httpRequest, validateHeaderName, validateHeaderValue, } from "node:http";
|
|
9
|
+
import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
|
|
10
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { gzip } from "node:zlib";
|
|
13
|
+
import { ExportResultCode, parseKeyPairsIntoRecord } from "@opentelemetry/core";
|
|
14
|
+
import { JsonLogsSerializer } from "@opentelemetry/otlp-transformer";
|
|
15
|
+
const gzipAsync = promisify(gzip);
|
|
16
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
17
|
+
const MAX_EXPORT_ATTEMPTS = 5;
|
|
18
|
+
const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
|
|
19
|
+
const RETRYABLE_NETWORK_CODES = new Set([
|
|
20
|
+
"ECONNRESET",
|
|
21
|
+
"ECONNREFUSED",
|
|
22
|
+
"EPIPE",
|
|
23
|
+
"ETIMEDOUT",
|
|
24
|
+
"EAI_AGAIN",
|
|
25
|
+
"ENOTFOUND",
|
|
26
|
+
"ENETUNREACH",
|
|
27
|
+
"EHOSTUNREACH",
|
|
28
|
+
]);
|
|
29
|
+
class ProxyOtlpResponseError extends Error {
|
|
30
|
+
retryable;
|
|
31
|
+
retryAfterMs;
|
|
32
|
+
constructor(message, retryable = false, retryAfterMs) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.retryable = retryable;
|
|
35
|
+
this.retryAfterMs = retryAfterMs;
|
|
36
|
+
this.name = "ProxyOtlpResponseError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function signalSetting(suffix) {
|
|
40
|
+
return (process.env[`OTEL_EXPORTER_OTLP_LOGS_${suffix}`]?.trim() ||
|
|
41
|
+
process.env[`OTEL_EXPORTER_OTLP_${suffix}`]?.trim() ||
|
|
42
|
+
undefined);
|
|
43
|
+
}
|
|
44
|
+
function certificate(suffix) {
|
|
45
|
+
const path = signalSetting(suffix);
|
|
46
|
+
if (!path) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return readFileSync(path);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// File paths, headers, backend bodies and native TLS errors can contain
|
|
54
|
+
// credentials. Export diagnostics intentionally expose only fixed reasons.
|
|
55
|
+
throw new Error(`Proxy OTLP ${suffix} file could not be read`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function retryAfter(value) {
|
|
59
|
+
if (!value) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
if (/^\d+$/.test(value.trim())) {
|
|
63
|
+
return Number(value) * 1000;
|
|
64
|
+
}
|
|
65
|
+
const at = Date.parse(value);
|
|
66
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
|
|
67
|
+
}
|
|
68
|
+
function validateAcknowledgement(bytes) {
|
|
69
|
+
let payload;
|
|
70
|
+
try {
|
|
71
|
+
payload = JSON.parse(bytes.toString("utf8"));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Empty JSON is not a serialized ExportLogsServiceResponse. Its valid
|
|
75
|
+
// empty message is {}, unlike the zero-byte protobuf representation.
|
|
76
|
+
throw new ProxyOtlpResponseError("OTLP response JSON is empty or malformed");
|
|
77
|
+
}
|
|
78
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
79
|
+
throw new ProxyOtlpResponseError("OTLP response must be a JSON object");
|
|
80
|
+
}
|
|
81
|
+
if (Object.keys(payload).some((key) => key !== "partialSuccess" && key !== "partial_success")) {
|
|
82
|
+
throw new ProxyOtlpResponseError("OTLP response has unexpected fields");
|
|
83
|
+
}
|
|
84
|
+
if ("partialSuccess" in payload && "partial_success" in payload) {
|
|
85
|
+
throw new ProxyOtlpResponseError("OTLP response has ambiguous field aliases");
|
|
86
|
+
}
|
|
87
|
+
const partial = "partialSuccess" in payload
|
|
88
|
+
? payload.partialSuccess
|
|
89
|
+
: "partial_success" in payload
|
|
90
|
+
? payload.partial_success
|
|
91
|
+
: undefined;
|
|
92
|
+
if (partial === null || partial === undefined) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (typeof partial !== "object" || Array.isArray(partial)) {
|
|
96
|
+
throw new ProxyOtlpResponseError("OTLP partial success response is malformed");
|
|
97
|
+
}
|
|
98
|
+
const allowedFields = new Set([
|
|
99
|
+
"rejectedLogRecords",
|
|
100
|
+
"rejected_log_records",
|
|
101
|
+
"errorMessage",
|
|
102
|
+
"error_message",
|
|
103
|
+
]);
|
|
104
|
+
if (Object.keys(partial).some((key) => !allowedFields.has(key))) {
|
|
105
|
+
throw new ProxyOtlpResponseError("OTLP partial success response has unexpected fields");
|
|
106
|
+
}
|
|
107
|
+
if (("rejectedLogRecords" in partial && "rejected_log_records" in partial) ||
|
|
108
|
+
("errorMessage" in partial && "error_message" in partial)) {
|
|
109
|
+
throw new ProxyOtlpResponseError("OTLP partial success has ambiguous field aliases");
|
|
110
|
+
}
|
|
111
|
+
const message = "errorMessage" in partial
|
|
112
|
+
? partial.errorMessage
|
|
113
|
+
: "error_message" in partial
|
|
114
|
+
? partial.error_message
|
|
115
|
+
: undefined;
|
|
116
|
+
if (message !== null &&
|
|
117
|
+
message !== undefined &&
|
|
118
|
+
typeof message !== "string") {
|
|
119
|
+
throw new ProxyOtlpResponseError("OTLP partial success response is malformed");
|
|
120
|
+
}
|
|
121
|
+
const rejected = "rejectedLogRecords" in partial
|
|
122
|
+
? partial.rejectedLogRecords
|
|
123
|
+
: "rejected_log_records" in partial
|
|
124
|
+
? partial.rejected_log_records
|
|
125
|
+
: 0;
|
|
126
|
+
// ProtoJSON accepts integer strings or numbers; null is the absent default.
|
|
127
|
+
if (rejected === null || rejected === 0 || rejected === "0") {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const integer = typeof rejected === "string" && /^\d+$/.test(rejected)
|
|
131
|
+
? BigInt(rejected)
|
|
132
|
+
: typeof rejected === "number" &&
|
|
133
|
+
Number.isSafeInteger(rejected) &&
|
|
134
|
+
rejected >= 0
|
|
135
|
+
? BigInt(rejected)
|
|
136
|
+
: undefined;
|
|
137
|
+
if (integer === undefined || integer > 9223372036854775807n) {
|
|
138
|
+
throw new ProxyOtlpResponseError("OTLP rejected record count is malformed");
|
|
139
|
+
}
|
|
140
|
+
if (integer > 0n) {
|
|
141
|
+
// We cannot identify the rejected subset. Mark the entire batch uncertain;
|
|
142
|
+
// replaying a known partial response would duplicate its accepted subset.
|
|
143
|
+
throw new ProxyOtlpResponseError("OTLP partial success rejected log records");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Both proxy queues use this transport; success is not backend persistence. */
|
|
147
|
+
export function createProxyOtlpLogTransport(endpoint, timeoutMillis) {
|
|
148
|
+
let url;
|
|
149
|
+
try {
|
|
150
|
+
url = new URL(endpoint);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new Error("Proxy OTLP endpoint is invalid");
|
|
154
|
+
}
|
|
155
|
+
if (!new Set(["http:", "https:"]).has(url.protocol)) {
|
|
156
|
+
throw new Error("Proxy OTLP endpoint must use HTTP or HTTPS");
|
|
157
|
+
}
|
|
158
|
+
if (url.username || url.password) {
|
|
159
|
+
throw new Error("Proxy OTLP endpoint credentials must use OTLP headers");
|
|
160
|
+
}
|
|
161
|
+
const compression = signalSetting("COMPRESSION") ?? "none";
|
|
162
|
+
if (compression !== "none" && compression !== "gzip") {
|
|
163
|
+
throw new Error("Proxy OTLP compression must be none or gzip");
|
|
164
|
+
}
|
|
165
|
+
const headers = {};
|
|
166
|
+
try {
|
|
167
|
+
for (const raw of [
|
|
168
|
+
process.env.OTEL_EXPORTER_OTLP_HEADERS,
|
|
169
|
+
process.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS,
|
|
170
|
+
]) {
|
|
171
|
+
for (const [key, value] of Object.entries(parseKeyPairsIntoRecord(raw))) {
|
|
172
|
+
validateHeaderName(key);
|
|
173
|
+
validateHeaderValue(key, value);
|
|
174
|
+
headers[key.toLowerCase()] = value;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
throw new Error("Proxy OTLP headers are invalid");
|
|
180
|
+
}
|
|
181
|
+
// These describe the serialized wire payload, irrespective of user headers.
|
|
182
|
+
headers["content-type"] = "application/json";
|
|
183
|
+
headers["accept"] = "application/json";
|
|
184
|
+
headers["accept-encoding"] = "identity";
|
|
185
|
+
delete headers["content-encoding"];
|
|
186
|
+
if (compression === "gzip") {
|
|
187
|
+
headers["content-encoding"] = "gzip";
|
|
188
|
+
}
|
|
189
|
+
const tls = {
|
|
190
|
+
ca: certificate("CERTIFICATE"),
|
|
191
|
+
cert: certificate("CLIENT_CERTIFICATE"),
|
|
192
|
+
key: certificate("CLIENT_KEY"),
|
|
193
|
+
};
|
|
194
|
+
if (Boolean(tls.cert) !== Boolean(tls.key)) {
|
|
195
|
+
throw new Error("Proxy OTLP client certificate and key must be set together");
|
|
196
|
+
}
|
|
197
|
+
const agent = url.protocol === "https:"
|
|
198
|
+
? new HttpsAgent({ keepAlive: true, maxSockets: 30, ...tls })
|
|
199
|
+
: new HttpAgent({ keepAlive: true, maxSockets: 30 });
|
|
200
|
+
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
201
|
+
let closed = false;
|
|
202
|
+
const active = new Set();
|
|
203
|
+
const sendOnce = (data, signal) => new Promise((resolve, reject) => {
|
|
204
|
+
let receivedResponse = false;
|
|
205
|
+
const req = request(url, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
agent,
|
|
208
|
+
signal,
|
|
209
|
+
headers: { ...headers, "content-length": String(data.byteLength) },
|
|
210
|
+
}, (response) => {
|
|
211
|
+
receivedResponse = true;
|
|
212
|
+
const status = response.statusCode ?? 0;
|
|
213
|
+
const fail = (error) => {
|
|
214
|
+
reject(error);
|
|
215
|
+
response.destroy();
|
|
216
|
+
};
|
|
217
|
+
// An HTTP status, a partial response or a broken success response must
|
|
218
|
+
// never be mistaken for an acknowledgement of individual log records.
|
|
219
|
+
if (status !== 200) {
|
|
220
|
+
fail(new ProxyOtlpResponseError(`OTLP HTTP ${status}`, RETRYABLE_STATUSES.has(status), retryAfter(response.headers["retry-after"])));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const encoding = response.headers["content-encoding"];
|
|
224
|
+
if (encoding && encoding !== "identity") {
|
|
225
|
+
fail(new ProxyOtlpResponseError("OTLP response encoding is unsupported"));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const contentType = response.headers["content-type"];
|
|
229
|
+
if (contentType &&
|
|
230
|
+
contentType.split(";", 1)[0].trim().toLowerCase() !==
|
|
231
|
+
"application/json") {
|
|
232
|
+
fail(new ProxyOtlpResponseError("OTLP response content type is not JSON"));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const chunks = [];
|
|
236
|
+
let size = 0;
|
|
237
|
+
response.on("data", (chunk) => {
|
|
238
|
+
size += chunk.length;
|
|
239
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
240
|
+
fail(new ProxyOtlpResponseError("OTLP response exceeds 64 KiB limit"));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
chunks.push(chunk);
|
|
244
|
+
});
|
|
245
|
+
response.on("aborted", () => reject(new ProxyOtlpResponseError("OTLP success response was interrupted")));
|
|
246
|
+
response.on("error", () => reject(new ProxyOtlpResponseError("OTLP success response could not be read")));
|
|
247
|
+
response.on("end", () => {
|
|
248
|
+
try {
|
|
249
|
+
validateAcknowledgement(Buffer.concat(chunks));
|
|
250
|
+
resolve();
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
reject(error);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
req.on("error", (error) => reject(new ProxyOtlpResponseError(signal.aborted
|
|
258
|
+
? "OTLP export deadline exceeded"
|
|
259
|
+
: "OTLP transport failed", !signal.aborted &&
|
|
260
|
+
!receivedResponse &&
|
|
261
|
+
RETRYABLE_NETWORK_CODES.has(error.code ?? ""))));
|
|
262
|
+
req.end(data);
|
|
263
|
+
});
|
|
264
|
+
const send = async (records) => {
|
|
265
|
+
const controller = new AbortController();
|
|
266
|
+
const deadlineAt = Date.now() + timeoutMillis;
|
|
267
|
+
const deadline = setTimeout(() => controller.abort(), timeoutMillis);
|
|
268
|
+
deadline.unref();
|
|
269
|
+
try {
|
|
270
|
+
const serialized = JsonLogsSerializer.serializeRequest(records);
|
|
271
|
+
if (!serialized) {
|
|
272
|
+
throw new ProxyOtlpResponseError("OTLP serialization produced no payload");
|
|
273
|
+
}
|
|
274
|
+
// Serialize and compress once, so ambiguous retries retain byte-for-byte
|
|
275
|
+
// event identities and payloads for query-side deduplication.
|
|
276
|
+
const bytes = compression === "gzip" ? await gzipAsync(serialized) : serialized;
|
|
277
|
+
let attempt = 0;
|
|
278
|
+
while (!controller.signal.aborted && Date.now() < deadlineAt) {
|
|
279
|
+
attempt++;
|
|
280
|
+
try {
|
|
281
|
+
await sendOnce(bytes, controller.signal);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
if (!(error instanceof ProxyOtlpResponseError) || !error.retryable) {
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
if (attempt >= MAX_EXPORT_ATTEMPTS) {
|
|
289
|
+
throw new ProxyOtlpResponseError("OTLP retry attempt limit reached");
|
|
290
|
+
}
|
|
291
|
+
const exponential = Math.min(5000, 1000 * 2 ** Math.min(attempt - 1, 3));
|
|
292
|
+
// Retry-After is a lower bound, never permission to hammer a busy
|
|
293
|
+
// collector. Even Retry-After: 0 retains exponential backoff+jitter.
|
|
294
|
+
const wait = Math.max(error.retryAfterMs ?? 0, exponential * (0.8 + Math.random() * 0.4));
|
|
295
|
+
if (wait >= deadlineAt - Date.now()) {
|
|
296
|
+
throw new ProxyOtlpResponseError("OTLP retry exceeds export deadline");
|
|
297
|
+
}
|
|
298
|
+
await delay(wait, undefined, { signal: controller.signal });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
throw new ProxyOtlpResponseError("OTLP export deadline exceeded");
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error instanceof ProxyOtlpResponseError) {
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
throw new ProxyOtlpResponseError(controller.signal.aborted
|
|
308
|
+
? "OTLP export deadline exceeded"
|
|
309
|
+
: "OTLP export failed");
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
clearTimeout(deadline);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
return {
|
|
316
|
+
export(records, callback) {
|
|
317
|
+
if (closed) {
|
|
318
|
+
callback({
|
|
319
|
+
code: ExportResultCode.FAILED,
|
|
320
|
+
error: new Error("OTLP transport is shut down"),
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const operation = send(records).then(() => callback({ code: ExportResultCode.SUCCESS }), (error) => callback({ code: ExportResultCode.FAILED, error }));
|
|
325
|
+
active.add(operation);
|
|
326
|
+
void operation.finally(() => active.delete(operation)).catch(() => { });
|
|
327
|
+
},
|
|
328
|
+
async shutdown() {
|
|
329
|
+
closed = true;
|
|
330
|
+
await Promise.allSettled(active);
|
|
331
|
+
agent.destroy();
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import type { ProxyActivitySnapshot, ProxyResponseTrackingObserver, RequestLogEntry, RequestAttemptLogEntry } from "../types/index.js";
|
|
2
|
+
export declare function getProxyBridgeResult(requestId: string): RequestLogEntry | undefined;
|
|
3
|
+
export declare function registerInternalProxyRequest(parentRequestId: string): {
|
|
4
|
+
token: `${string}-${string}-${string}-${string}-${string}`;
|
|
5
|
+
requestId: `${string}-${string}-${string}-${string}-${string}`;
|
|
6
|
+
dispose(): void;
|
|
7
|
+
};
|
|
8
|
+
export declare function consumeInternalProxyRequest(token: string | null): {
|
|
9
|
+
parentRequestId: string;
|
|
10
|
+
requestId: string;
|
|
11
|
+
} | undefined;
|
|
12
|
+
export declare function getProxyRequestAccounting(requestId: string): {
|
|
13
|
+
parentRequestId?: string;
|
|
14
|
+
accountingScope?: "client" | "internal";
|
|
15
|
+
usageOwnerRequestId?: string;
|
|
16
|
+
} | undefined;
|
|
17
|
+
export declare function releaseProxyRequestAccounting(requestId: string): void;
|
|
18
|
+
/** Deferred native callbacks retain this owner even after transport cleanup. */
|
|
19
|
+
export declare function withProxyFinalLogOwnership<T>(requestId: string, run: () => T): T;
|
|
2
20
|
/** Join route accounting to the HTTP lifecycle without relying on write order. */
|
|
3
21
|
export declare function observeProxyFinalLog(requestId: string, observer: (entry: RequestLogEntry) => void, attemptObserver?: (entry: RequestAttemptLogEntry) => void): () => void;
|
|
4
|
-
|
|
22
|
+
/** Check without claiming: aggregate accounting must not revive a cancelled request. */
|
|
23
|
+
export declare function isProxyRequestFinalized(requestId: string): boolean;
|
|
24
|
+
export declare function notifyProxyFinalLog(entry: RequestLogEntry): boolean;
|
|
5
25
|
/** Retain only the last attempt for a currently observed HTTP request. */
|
|
6
26
|
export declare function notifyProxyAttemptLog(entry: RequestAttemptLogEntry): void;
|
|
7
27
|
export declare function registerProxyResponseObserver(metadata: object, observer: ProxyResponseTrackingObserver): void;
|
|
@@ -11,5 +31,5 @@ export declare function beginProxyRequest(): () => void;
|
|
|
11
31
|
export declare function getProxyActivitySnapshot(): ProxyActivitySnapshot;
|
|
12
32
|
export declare function isProxyActivityQuiet(snapshot: ProxyActivitySnapshot, quietThresholdMs: number, nowMs?: number): boolean;
|
|
13
33
|
/** Keep activity open until the response body completes, errors, or is cancelled. */
|
|
14
|
-
export declare function trackProxyResponse(response: Response, finishRequest: () => void, observer?: ProxyResponseTrackingObserver): Response;
|
|
34
|
+
export declare function trackProxyResponse(response: Response, finishRequest: () => void, observer?: ProxyResponseTrackingObserver, abortSignal?: AbortSignal): Response;
|
|
15
35
|
export declare function resetProxyActivityForTests(): void;
|
|
@@ -1,6 +1,68 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { withTimeout } from "../utils/async/withTimeout.js";
|
|
2
3
|
import { logger } from "../utils/logger.js";
|
|
3
4
|
const PROXY_RESPONSE_CANCEL_TIMEOUT_MS = 1_000;
|
|
5
|
+
// Bridge identity is registered by this process, never trusted from an inbound
|
|
6
|
+
// parent-id header. One-use capabilities expire even if loopback dispatch fails.
|
|
7
|
+
const internalRequests = new Map();
|
|
8
|
+
const bridgeResults = new Map();
|
|
9
|
+
export function getProxyBridgeResult(requestId) {
|
|
10
|
+
return bridgeResults.get(requestId);
|
|
11
|
+
}
|
|
12
|
+
const requestAccounting = new Map();
|
|
13
|
+
export function registerInternalProxyRequest(parentRequestId) {
|
|
14
|
+
if (internalRequests.size >= 5000) {
|
|
15
|
+
throw Object.assign(new Error("Internal proxy dispatch capacity is full"), {
|
|
16
|
+
status: 503,
|
|
17
|
+
code: "PROXY_INTERNAL_CAPACITY",
|
|
18
|
+
retryable: false,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
const token = crypto.randomUUID();
|
|
22
|
+
const requestId = crypto.randomUUID();
|
|
23
|
+
internalRequests.set(token, { parentRequestId, requestId });
|
|
24
|
+
requestAccounting.set(parentRequestId, {
|
|
25
|
+
accountingScope: "client",
|
|
26
|
+
usageOwnerRequestId: requestId,
|
|
27
|
+
});
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
if (internalRequests.delete(token)) {
|
|
30
|
+
requestAccounting.delete(parentRequestId);
|
|
31
|
+
bridgeResults.delete(parentRequestId);
|
|
32
|
+
}
|
|
33
|
+
}, 300_000);
|
|
34
|
+
timer.unref?.();
|
|
35
|
+
return {
|
|
36
|
+
token,
|
|
37
|
+
requestId,
|
|
38
|
+
dispose() {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
internalRequests.delete(token);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function consumeInternalProxyRequest(token) {
|
|
45
|
+
if (!token) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const result = internalRequests.get(token);
|
|
49
|
+
internalRequests.delete(token);
|
|
50
|
+
if (result) {
|
|
51
|
+
requestAccounting.set(result.requestId, {
|
|
52
|
+
parentRequestId: result.parentRequestId,
|
|
53
|
+
accountingScope: "internal",
|
|
54
|
+
usageOwnerRequestId: result.requestId,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
export function getProxyRequestAccounting(requestId) {
|
|
60
|
+
return requestAccounting.get(requestId);
|
|
61
|
+
}
|
|
62
|
+
export function releaseProxyRequestAccounting(requestId) {
|
|
63
|
+
requestAccounting.delete(requestId);
|
|
64
|
+
bridgeResults.delete(requestId);
|
|
65
|
+
}
|
|
4
66
|
let activeRequests = 0;
|
|
5
67
|
let lastActivityAtMs = null;
|
|
6
68
|
// Route handlers can attach terminal observers to their request context without
|
|
@@ -8,11 +70,19 @@ let lastActivityAtMs = null;
|
|
|
8
70
|
// response through one tracker, which fans these observers out at the point
|
|
9
71
|
// where bytes actually leave the proxy.
|
|
10
72
|
const responseObserversByMetadata = new WeakMap();
|
|
73
|
+
const finalOwnership = new Map();
|
|
74
|
+
const finalOwnershipContext = new AsyncLocalStorage();
|
|
75
|
+
/** Deferred native callbacks retain this owner even after transport cleanup. */
|
|
76
|
+
export function withProxyFinalLogOwnership(requestId, run) {
|
|
77
|
+
const owner = finalOwnership.get(requestId);
|
|
78
|
+
return owner ? finalOwnershipContext.run(owner, run) : run();
|
|
79
|
+
}
|
|
11
80
|
const finalLogObservers = new Map();
|
|
12
81
|
const attemptLogObservers = new Map();
|
|
13
82
|
/** Join route accounting to the HTTP lifecycle without relying on write order. */
|
|
14
83
|
export function observeProxyFinalLog(requestId, observer, attemptObserver) {
|
|
15
84
|
finalLogObservers.set(requestId, observer);
|
|
85
|
+
finalOwnership.set(requestId, { requestId, finalized: false });
|
|
16
86
|
if (attemptObserver) {
|
|
17
87
|
attemptLogObservers.set(requestId, attemptObserver);
|
|
18
88
|
}
|
|
@@ -22,12 +92,35 @@ export function observeProxyFinalLog(requestId, observer, attemptObserver) {
|
|
|
22
92
|
return () => {
|
|
23
93
|
if (finalLogObservers.get(requestId) === observer) {
|
|
24
94
|
finalLogObservers.delete(requestId);
|
|
95
|
+
finalOwnership.delete(requestId);
|
|
25
96
|
attemptLogObservers.delete(requestId);
|
|
26
97
|
}
|
|
27
98
|
};
|
|
28
99
|
}
|
|
100
|
+
/** Check without claiming: aggregate accounting must not revive a cancelled request. */
|
|
101
|
+
export function isProxyRequestFinalized(requestId) {
|
|
102
|
+
const contextual = finalOwnershipContext.getStore();
|
|
103
|
+
return ((contextual?.requestId === requestId
|
|
104
|
+
? contextual
|
|
105
|
+
: finalOwnership.get(requestId))?.finalized ?? false);
|
|
106
|
+
}
|
|
29
107
|
export function notifyProxyFinalLog(entry) {
|
|
108
|
+
const contextual = finalOwnershipContext.getStore();
|
|
109
|
+
const owner = contextual?.requestId === entry.requestId
|
|
110
|
+
? contextual
|
|
111
|
+
: finalOwnership.get(entry.requestId);
|
|
112
|
+
if (owner) {
|
|
113
|
+
if (owner.finalized) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
owner.finalized = true;
|
|
117
|
+
}
|
|
118
|
+
const parent = requestAccounting.get(entry.requestId)?.parentRequestId;
|
|
119
|
+
if (parent && requestAccounting.has(parent)) {
|
|
120
|
+
bridgeResults.set(parent, entry);
|
|
121
|
+
}
|
|
30
122
|
finalLogObservers.get(entry.requestId)?.(entry);
|
|
123
|
+
return true;
|
|
31
124
|
}
|
|
32
125
|
/** Retain only the last attempt for a currently observed HTTP request. */
|
|
33
126
|
export function notifyProxyAttemptLog(entry) {
|
|
@@ -99,7 +192,7 @@ export function isProxyActivityQuiet(snapshot, quietThresholdMs, nowMs = Date.no
|
|
|
99
192
|
return nowMs - snapshot.lastActivityAt.getTime() >= quietThresholdMs;
|
|
100
193
|
}
|
|
101
194
|
/** Keep activity open until the response body completes, errors, or is cancelled. */
|
|
102
|
-
export function trackProxyResponse(response, finishRequest, observer) {
|
|
195
|
+
export function trackProxyResponse(response, finishRequest, observer, abortSignal) {
|
|
103
196
|
if (!response.body) {
|
|
104
197
|
try {
|
|
105
198
|
const notified = observer?.onTerminal?.({
|
|
@@ -107,7 +200,7 @@ export function trackProxyResponse(response, finishRequest, observer) {
|
|
|
107
200
|
observedBodyBytes: 0,
|
|
108
201
|
responseChunks: 0,
|
|
109
202
|
});
|
|
110
|
-
void Promise.resolve(notified).then(finishRequest, finishRequest);
|
|
203
|
+
void withTimeout(Promise.resolve(notified), 4000, "Timed out finalizing proxy response accounting").then(finishRequest, finishRequest);
|
|
111
204
|
}
|
|
112
205
|
catch {
|
|
113
206
|
finishRequest();
|
|
@@ -119,17 +212,12 @@ export function trackProxyResponse(response, finishRequest, observer) {
|
|
|
119
212
|
let responseChunks = 0;
|
|
120
213
|
let settled = false;
|
|
121
214
|
let sourceClosed = false;
|
|
122
|
-
// A framework can cancel its adapter after the upstream stream has already
|
|
123
|
-
// closed. Snapshot reader.closed before cancel() so that normal cleanup is
|
|
124
|
-
// not recorded as a client-aborted request.
|
|
125
|
-
void reader.closed.then(() => {
|
|
126
|
-
sourceClosed = true;
|
|
127
|
-
}, () => undefined);
|
|
128
215
|
const settle = (outcome, error) => {
|
|
129
216
|
if (settled) {
|
|
130
217
|
return;
|
|
131
218
|
}
|
|
132
219
|
settled = true;
|
|
220
|
+
abortSignal?.removeEventListener("abort", onAbort);
|
|
133
221
|
// Keep drain accounting open through bounded terminal bookkeeping, but
|
|
134
222
|
// never hold back the client's response body while telemetry is written.
|
|
135
223
|
try {
|
|
@@ -139,12 +227,29 @@ export function trackProxyResponse(response, finishRequest, observer) {
|
|
|
139
227
|
observedBodyBytes,
|
|
140
228
|
responseChunks,
|
|
141
229
|
});
|
|
142
|
-
void Promise.resolve(notified).then(finishRequest, finishRequest);
|
|
230
|
+
void withTimeout(Promise.resolve(notified), 4000, "Timed out finalizing proxy response accounting").then(finishRequest, finishRequest);
|
|
143
231
|
}
|
|
144
232
|
catch {
|
|
145
233
|
finishRequest();
|
|
146
234
|
}
|
|
147
235
|
};
|
|
236
|
+
const onAbort = () => {
|
|
237
|
+
settle(abortSignal?.reason?.name === "TimeoutError"
|
|
238
|
+
? "stream_error"
|
|
239
|
+
: "client_cancelled", abortSignal?.reason);
|
|
240
|
+
void withTimeout(reader.cancel(abortSignal?.reason), PROXY_RESPONSE_CANCEL_TIMEOUT_MS, "Timed out cancelling the upstream proxy response").catch(() => undefined);
|
|
241
|
+
};
|
|
242
|
+
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
243
|
+
if (abortSignal?.aborted) {
|
|
244
|
+
onAbort();
|
|
245
|
+
}
|
|
246
|
+
// A failed source cannot be observed only on a future downstream pull: a
|
|
247
|
+
// stalled reader might never pull again, leaving admission/drain open forever.
|
|
248
|
+
void reader.closed.then(() => {
|
|
249
|
+
sourceClosed = true;
|
|
250
|
+
}, (error) => {
|
|
251
|
+
settle("stream_error", error);
|
|
252
|
+
});
|
|
148
253
|
const trackedBody = new ReadableStream({
|
|
149
254
|
async pull(controller) {
|
|
150
255
|
try {
|