@juspay/neurolink 12.20.0 → 12.20.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 -6
- package/dist/browser/neurolink.min.js +400 -400
- package/dist/cli/commands/proxy.js +171 -25
- package/dist/cli/commands/telemetry.d.ts +4 -1
- package/dist/cli/commands/telemetry.js +113 -0
- package/dist/cli/utils/nativeTelemetry.d.ts +19 -0
- package/dist/cli/utils/nativeTelemetry.js +322 -0
- package/dist/proxy/bodyCapturePolicy.d.ts +6 -0
- package/dist/proxy/bodyCapturePolicy.js +132 -0
- package/dist/proxy/codexFallback.d.ts +27 -9
- package/dist/proxy/codexFallback.js +84 -13
- package/dist/proxy/openaiFormat.js +8 -1
- package/dist/proxy/otelLogSink.d.ts +11 -1
- package/dist/proxy/otelLogSink.js +174 -27
- package/dist/proxy/otlpLogTransport.d.ts +4 -2
- package/dist/proxy/otlpLogTransport.js +22 -7
- package/dist/proxy/requestLogger.js +11 -3
- package/dist/proxy/restartControl.js +18 -3
- package/dist/proxy/rollingProxyServer.js +48 -1
- package/dist/proxy/rollingWorkerProcess.js +87 -10
- package/dist/proxy/rollingWorkerProtocol.js +4 -2
- package/dist/proxy/rollingWorkerSupervisor.js +47 -10
- package/dist/proxy/socketWorkerRuntime.js +90 -4
- package/dist/proxy/updateChecker.d.ts +3 -3
- package/dist/proxy/updateChecker.js +16 -9
- package/dist/proxy/updateCoordinator.d.ts +10 -1
- package/dist/proxy/updateCoordinator.js +68 -1
- package/dist/proxy/updateState.d.ts +2 -0
- package/dist/proxy/updateState.js +15 -0
- package/dist/proxy/vertexAnthropicFallback.d.ts +17 -1
- package/dist/proxy/vertexAnthropicFallback.js +25 -1
- package/dist/server/routes/claudeProxyRoutes.d.ts +17 -0
- package/dist/server/routes/claudeProxyRoutes.js +53 -5
- package/dist/types/cli.d.ts +18 -0
- package/dist/types/providers.d.ts +8 -0
- package/dist/types/proxy.d.ts +117 -7
- package/dist/utils/anthropicCacheBreakpoints.d.ts +22 -1
- package/dist/utils/anthropicCacheBreakpoints.js +113 -0
- package/dist/utils/pricing.js +69 -2
- package/docs-site/static/search-index.json +1 -1
- package/package.json +1 -1
- package/scripts/observability/NATIVE-DURABLE.md +68 -0
- package/scripts/observability/otel-collector.proxy-native-durable.yaml +2 -2
- package/scripts/observability/proxy-telemetry-check.mjs +135 -50
|
@@ -52,12 +52,22 @@ export declare function getProxyOtelLogSnapshot(): {
|
|
|
52
52
|
exportUnconfirmed: number;
|
|
53
53
|
dropped: number;
|
|
54
54
|
outstanding: number;
|
|
55
|
+
outstandingBytes: number;
|
|
56
|
+
highWaterBytes: number;
|
|
57
|
+
byteLimitDrops: number;
|
|
58
|
+
retryingRecords: number;
|
|
59
|
+
retriedBatches: number;
|
|
60
|
+
retentionExpired: number;
|
|
61
|
+
lastTransientFailureAt: string | undefined;
|
|
55
62
|
lastAcknowledgedAt: string | undefined;
|
|
56
63
|
lastFailureAt: string | undefined;
|
|
57
64
|
highWaterOutstanding: number;
|
|
58
65
|
failureHistoryEvicted: number;
|
|
59
|
-
kind: "metadata" | "bodies";
|
|
66
|
+
kind: "metadata" | "bodies" | "indexes" | "diagnostics";
|
|
60
67
|
capacity: number;
|
|
68
|
+
byteCapacity: number;
|
|
69
|
+
retentionMs: number;
|
|
70
|
+
oldestOutstandingAgeMs: number;
|
|
61
71
|
}[];
|
|
62
72
|
};
|
|
63
73
|
/** Bounded provider flush belongs after final request and lifecycle publication. */
|
|
@@ -5,7 +5,7 @@ import { getProxyRequestTraceContext, proxyLogContext, } from "./proxyTraceConte
|
|
|
5
5
|
import { setImmediate as yieldToRequests } from "node:timers/promises";
|
|
6
6
|
import { SeverityNumber } from "@opentelemetry/api-logs";
|
|
7
7
|
import { ExportResultCode } from "@opentelemetry/core";
|
|
8
|
-
import { createProxyOtlpLogTransport } from "./otlpLogTransport.js";
|
|
8
|
+
import { createProxyOtlpLogTransport, getProxyOtlpRetryAfter, } from "./otlpLogTransport.js";
|
|
9
9
|
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
10
10
|
import { BatchLogRecordProcessor, LoggerProvider, } from "@opentelemetry/sdk-logs";
|
|
11
11
|
import { sanitizeForLog } from "../utils/logSanitize.js";
|
|
@@ -21,6 +21,21 @@ const BODY_OTLP_CHUNK_SIZE = 128 * 1024;
|
|
|
21
21
|
const OTLP_EXPORT_TIMEOUT_MS = 30_000;
|
|
22
22
|
const OTLP_EXPORT_CALLBACK_DEADLINE_MS = OTLP_EXPORT_TIMEOUT_MS + 1_000;
|
|
23
23
|
const BODY_PUBLICATION_ACTIVE_DEADLINE_MS = 20_000;
|
|
24
|
+
const METADATA_RETENTION_MS = 120_000;
|
|
25
|
+
const DIAGNOSTIC_KINDS = new Set(["console", "runtime", "telemetry_delivery"]);
|
|
26
|
+
/** Payload accounting includes UTF-8 attributes and fixed per-record overhead. */
|
|
27
|
+
function retainedRecordBytes(record) {
|
|
28
|
+
try {
|
|
29
|
+
return (512 +
|
|
30
|
+
Buffer.byteLength(typeof record.body === "string"
|
|
31
|
+
? record.body
|
|
32
|
+
: (JSON.stringify(record.body) ?? ""), "utf8") +
|
|
33
|
+
Buffer.byteLength(JSON.stringify(record.attributes), "utf8"));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return Infinity;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
24
39
|
const bodyDelivery = {
|
|
25
40
|
attempted: 0,
|
|
26
41
|
transportAcknowledged: 0,
|
|
@@ -39,9 +54,12 @@ export function isProxyOtelOnly() {
|
|
|
39
54
|
return process.env.NEUROLINK_PROXY_LOG_SINK === "otel";
|
|
40
55
|
}
|
|
41
56
|
/** Reserve capacity including exports in flight, independently for metadata and bodies. */
|
|
42
|
-
function createTrackedProcessor(url, capacity, kind) {
|
|
57
|
+
function createTrackedProcessor(url, capacity, kind, byteCapacity) {
|
|
43
58
|
const unsettled = new Set();
|
|
44
59
|
const enqueuedAt = new WeakMap();
|
|
60
|
+
const retainedBytes = new WeakMap();
|
|
61
|
+
const stopRetentionWaiters = new Set();
|
|
62
|
+
const retentionMs = kind === "metadata" || kind === "indexes" ? METADATA_RETENTION_MS : 0;
|
|
45
63
|
const capacityWaiters = new Set();
|
|
46
64
|
const flushWaiters = new Set();
|
|
47
65
|
const state = {
|
|
@@ -51,6 +69,13 @@ function createTrackedProcessor(url, capacity, kind) {
|
|
|
51
69
|
exportUnconfirmed: 0,
|
|
52
70
|
dropped: 0,
|
|
53
71
|
outstanding: 0,
|
|
72
|
+
outstandingBytes: 0,
|
|
73
|
+
highWaterBytes: 0,
|
|
74
|
+
byteLimitDrops: 0,
|
|
75
|
+
retryingRecords: 0,
|
|
76
|
+
retriedBatches: 0,
|
|
77
|
+
retentionExpired: 0,
|
|
78
|
+
lastTransientFailureAt: undefined,
|
|
54
79
|
lastAcknowledgedAt: undefined,
|
|
55
80
|
lastFailureAt: undefined,
|
|
56
81
|
highWaterOutstanding: 0,
|
|
@@ -113,17 +138,32 @@ function createTrackedProcessor(url, capacity, kind) {
|
|
|
113
138
|
}
|
|
114
139
|
}
|
|
115
140
|
let settled = false;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
141
|
+
let retrying = false;
|
|
142
|
+
let retryTimer;
|
|
143
|
+
let deadline;
|
|
144
|
+
let lastFailure;
|
|
145
|
+
let attempt = 0;
|
|
146
|
+
const oldestEnqueuedAt = Math.min(...records.map((record) => enqueuedAt.get(record) ?? exportStartedAt));
|
|
147
|
+
const retainUntil = oldestEnqueuedAt + retentionMs;
|
|
148
|
+
const stopRetention = () => {
|
|
149
|
+
// Only a waiting retry is settled early. An active HTTP export keeps
|
|
150
|
+
// ownership until its callback/deadline so capacity stays truthful.
|
|
151
|
+
if (retryTimer && lastFailure) {
|
|
152
|
+
settle(lastFailure);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
stopRetentionWaiters.add(stopRetention);
|
|
121
156
|
const settle = (result) => {
|
|
122
157
|
if (settled) {
|
|
123
158
|
return;
|
|
124
159
|
}
|
|
125
160
|
settled = true;
|
|
126
161
|
clearTimeout(deadline);
|
|
162
|
+
clearTimeout(retryTimer);
|
|
163
|
+
stopRetentionWaiters.delete(stopRetention);
|
|
164
|
+
if (retrying) {
|
|
165
|
+
state.retryingRecords -= records.length;
|
|
166
|
+
}
|
|
127
167
|
state.outstanding -= records.length;
|
|
128
168
|
if (result.code === ExportResultCode.SUCCESS) {
|
|
129
169
|
state.transportAcknowledged += records.length;
|
|
@@ -137,6 +177,7 @@ function createTrackedProcessor(url, capacity, kind) {
|
|
|
137
177
|
}
|
|
138
178
|
for (const record of records) {
|
|
139
179
|
unsettled.delete(record);
|
|
180
|
+
state.outstandingBytes -= retainedBytes.get(record) ?? 0;
|
|
140
181
|
const id = record.attributes?.["body.capture_id"];
|
|
141
182
|
const publication = typeof id === "string" ? bodyPublications.get(id) : undefined;
|
|
142
183
|
if (publication) {
|
|
@@ -160,15 +201,64 @@ function createTrackedProcessor(url, capacity, kind) {
|
|
|
160
201
|
}
|
|
161
202
|
callback(result);
|
|
162
203
|
};
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
204
|
+
const send = () => {
|
|
205
|
+
retryTimer = undefined;
|
|
206
|
+
if (retentionMs && performance.now() >= retainUntil) {
|
|
207
|
+
state.retentionExpired += records.length;
|
|
208
|
+
settle({
|
|
209
|
+
code: ExportResultCode.FAILED,
|
|
210
|
+
error: new Error("OTLP metadata retention budget exceeded"),
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
attempt++;
|
|
215
|
+
if (attempt > 1) {
|
|
216
|
+
state.retriedBatches++;
|
|
217
|
+
}
|
|
218
|
+
let attemptSettled = false;
|
|
219
|
+
const completed = (result) => {
|
|
220
|
+
if (attemptSettled || settled) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
attemptSettled = true;
|
|
224
|
+
clearTimeout(deadline);
|
|
225
|
+
const retryAfter = result.code === ExportResultCode.FAILED
|
|
226
|
+
? getProxyOtlpRetryAfter(result.error)
|
|
227
|
+
: undefined;
|
|
228
|
+
if (retentionMs && retryAfter !== undefined && !shuttingDown) {
|
|
229
|
+
state.lastTransientFailureAt = new Date().toISOString();
|
|
230
|
+
const wait = Math.max(retryAfter, Math.min(10_000, 1000 * 2 ** Math.min(attempt - 1, 4)) *
|
|
231
|
+
(0.8 + Math.random() * 0.4));
|
|
232
|
+
if (performance.now() + wait < retainUntil) {
|
|
233
|
+
if (!retrying) {
|
|
234
|
+
retrying = true;
|
|
235
|
+
state.retryingRecords += records.length;
|
|
236
|
+
}
|
|
237
|
+
lastFailure = result;
|
|
238
|
+
retryTimer = setTimeout(send, wait);
|
|
239
|
+
retryTimer.unref();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
state.retentionExpired += records.length;
|
|
243
|
+
}
|
|
244
|
+
settle(result);
|
|
245
|
+
};
|
|
246
|
+
deadline = setTimeout(() => completed({
|
|
168
247
|
code: ExportResultCode.FAILED,
|
|
169
|
-
error:
|
|
170
|
-
});
|
|
171
|
-
|
|
248
|
+
error: new Error("OTLP export callback deadline exceeded"),
|
|
249
|
+
}), OTLP_EXPORT_CALLBACK_DEADLINE_MS);
|
|
250
|
+
deadline.unref();
|
|
251
|
+
try {
|
|
252
|
+
transport.export(records, completed);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
completed({
|
|
256
|
+
code: ExportResultCode.FAILED,
|
|
257
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
send();
|
|
172
262
|
},
|
|
173
263
|
shutdown: () => transport.shutdown(),
|
|
174
264
|
};
|
|
@@ -176,7 +266,7 @@ function createTrackedProcessor(url, capacity, kind) {
|
|
|
176
266
|
maxQueueSize: capacity,
|
|
177
267
|
maxExportBatchSize: 64,
|
|
178
268
|
scheduledDelayMillis: kind === "bodies" ? 25 : 1000,
|
|
179
|
-
exportTimeoutMillis: OTLP_EXPORT_CALLBACK_DEADLINE_MS,
|
|
269
|
+
exportTimeoutMillis: retentionMs + OTLP_EXPORT_CALLBACK_DEADLINE_MS + 1000,
|
|
180
270
|
});
|
|
181
271
|
const processor = {
|
|
182
272
|
onEmit(record) {
|
|
@@ -187,8 +277,13 @@ function createTrackedProcessor(url, capacity, kind) {
|
|
|
187
277
|
if (publication) {
|
|
188
278
|
publication.emitted++;
|
|
189
279
|
}
|
|
190
|
-
|
|
280
|
+
const bytes = retainedRecordBytes(record);
|
|
281
|
+
if (state.outstanding >= capacity ||
|
|
282
|
+
state.outstandingBytes + bytes > byteCapacity) {
|
|
191
283
|
state.dropped++;
|
|
284
|
+
if (state.outstandingBytes + bytes > byteCapacity) {
|
|
285
|
+
state.byteLimitDrops++;
|
|
286
|
+
}
|
|
192
287
|
rememberFailure([record], "queue_full");
|
|
193
288
|
if (publication) {
|
|
194
289
|
publication.dropped++;
|
|
@@ -198,6 +293,9 @@ function createTrackedProcessor(url, capacity, kind) {
|
|
|
198
293
|
}
|
|
199
294
|
state.submitted++;
|
|
200
295
|
state.outstanding++;
|
|
296
|
+
state.outstandingBytes += bytes;
|
|
297
|
+
state.highWaterBytes = Math.max(state.highWaterBytes, state.outstandingBytes);
|
|
298
|
+
retainedBytes.set(record, bytes);
|
|
201
299
|
unsettled.add(record);
|
|
202
300
|
enqueuedAt.set(record, performance.now());
|
|
203
301
|
state.highWaterOutstanding = Math.max(state.highWaterOutstanding, state.outstanding);
|
|
@@ -250,7 +348,28 @@ function createTrackedProcessor(url, capacity, kind) {
|
|
|
250
348
|
capacityWaiters.add(check);
|
|
251
349
|
check();
|
|
252
350
|
});
|
|
253
|
-
return {
|
|
351
|
+
return {
|
|
352
|
+
state,
|
|
353
|
+
processor,
|
|
354
|
+
capacity,
|
|
355
|
+
byteCapacity,
|
|
356
|
+
retentionMs,
|
|
357
|
+
kind,
|
|
358
|
+
waitForCapacity,
|
|
359
|
+
abortPending: () => transport.abortPending(),
|
|
360
|
+
stopRetention: () => {
|
|
361
|
+
for (const stop of stopRetentionWaiters) {
|
|
362
|
+
stop();
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
oldestOutstandingAgeMs: () => {
|
|
366
|
+
let oldest = performance.now();
|
|
367
|
+
for (const record of unsettled) {
|
|
368
|
+
oldest = Math.min(oldest, enqueuedAt.get(record) ?? oldest);
|
|
369
|
+
}
|
|
370
|
+
return unsettled.size ? Math.max(0, performance.now() - oldest) : 0;
|
|
371
|
+
},
|
|
372
|
+
};
|
|
254
373
|
}
|
|
255
374
|
/**
|
|
256
375
|
* Publish bounded captures concurrently into a shared batch queue. Transport
|
|
@@ -450,10 +569,13 @@ export function initializeProxyOtelLogs(role = "worker") {
|
|
|
450
569
|
throw new Error("Proxy OTLP logs require HTTPS for non-loopback collectors");
|
|
451
570
|
}
|
|
452
571
|
}
|
|
453
|
-
const metadata = createTrackedProcessor(endpoint, 2048, "metadata");
|
|
454
|
-
const bodies = createTrackedProcessor(bodyEndpoint, 256, "bodies");
|
|
455
|
-
|
|
572
|
+
const metadata = createTrackedProcessor(endpoint, 2048, "metadata", 8 * 1024 * 1024);
|
|
573
|
+
const bodies = createTrackedProcessor(bodyEndpoint, 256, "bodies", 40 * 1024 * 1024);
|
|
574
|
+
const diagnostics = createTrackedProcessor(endpoint, 512, "diagnostics", 2 * 1024 * 1024);
|
|
575
|
+
const indexes = createTrackedProcessor(endpoint, 1024, "indexes", 8 * 1024 * 1024);
|
|
576
|
+
queues.push(metadata, bodies, diagnostics, indexes);
|
|
456
577
|
provider = new LoggerProvider({
|
|
578
|
+
forceFlushTimeoutMillis: METADATA_RETENTION_MS + OTLP_EXPORT_CALLBACK_DEADLINE_MS + 2000,
|
|
457
579
|
resource: resourceFromAttributes({
|
|
458
580
|
"service.name": process.env.OTEL_SERVICE_NAME ?? "neurolink-proxy",
|
|
459
581
|
"service.instance.id": `${role}-${process.pid}`,
|
|
@@ -465,7 +587,13 @@ export function initializeProxyOtelLogs(role = "worker") {
|
|
|
465
587
|
onEmit(record, context) {
|
|
466
588
|
(record.attributes?.["proxy.record_kind"] === "body"
|
|
467
589
|
? bodies
|
|
468
|
-
:
|
|
590
|
+
: record.attributes?.["proxy.record_kind"] === "body_capture_index"
|
|
591
|
+
? indexes
|
|
592
|
+
: record.attributes?.["proxy.lifecycle.event"] ===
|
|
593
|
+
"runtime_sample" ||
|
|
594
|
+
DIAGNOSTIC_KINDS.has(String(record.attributes?.["proxy.record_kind"]))
|
|
595
|
+
? diagnostics
|
|
596
|
+
: metadata).processor.onEmit(record, context);
|
|
469
597
|
},
|
|
470
598
|
forceFlush: async () => {
|
|
471
599
|
await Promise.all(queues.map((q) => q.processor.forceFlush()));
|
|
@@ -577,6 +705,9 @@ export function getProxyOtelLogSnapshot() {
|
|
|
577
705
|
queues: queues.map((q) => ({
|
|
578
706
|
kind: q.kind,
|
|
579
707
|
capacity: q.capacity,
|
|
708
|
+
byteCapacity: q.byteCapacity,
|
|
709
|
+
retentionMs: q.retentionMs,
|
|
710
|
+
oldestOutstandingAgeMs: q.oldestOutstandingAgeMs(),
|
|
580
711
|
...q.state,
|
|
581
712
|
recentFailures: q.state.recentFailures.map((failure) => ({
|
|
582
713
|
...failure,
|
|
@@ -593,10 +724,27 @@ export async function flushProxyOtelLogs() {
|
|
|
593
724
|
/** Release this process's exporter and restore console ownership. */
|
|
594
725
|
export async function shutdownProxyOtelLogs() {
|
|
595
726
|
shuttingDown = true;
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
727
|
+
for (const queue of queues) {
|
|
728
|
+
queue.stopRetention();
|
|
729
|
+
}
|
|
730
|
+
// Service cleanup gives telemetry five seconds. End waiting retries now,
|
|
731
|
+
// then let healthy exports drain; cancel unfinished HTTP work before that
|
|
732
|
+
// outer deadline. Cancellation remains unconfirmed, never acknowledged.
|
|
733
|
+
const deadline = setTimeout(() => {
|
|
734
|
+
for (const queue of queues) {
|
|
735
|
+
queue.abortPending();
|
|
736
|
+
}
|
|
737
|
+
}, 4000);
|
|
738
|
+
deadline.unref();
|
|
739
|
+
try {
|
|
740
|
+
await Promise.allSettled([...bodyPublicationOperations]);
|
|
741
|
+
restoreConsole?.();
|
|
742
|
+
restoreConsole = undefined;
|
|
743
|
+
await provider?.shutdown();
|
|
744
|
+
}
|
|
745
|
+
finally {
|
|
746
|
+
clearTimeout(deadline);
|
|
747
|
+
}
|
|
600
748
|
provider = undefined;
|
|
601
749
|
queues.length = 0;
|
|
602
750
|
invalidRecords = 0;
|
|
@@ -624,7 +772,6 @@ export function withProxyOtelLogShutdown(handler) {
|
|
|
624
772
|
await handler(arg);
|
|
625
773
|
}
|
|
626
774
|
finally {
|
|
627
|
-
await flushProxyOtelLogs().catch(() => undefined);
|
|
628
775
|
await shutdownProxyOtelLogs().catch(() => undefined);
|
|
629
776
|
}
|
|
630
777
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ProxyOtlpLogTransport } from "../types/index.js";
|
|
2
|
+
/** Only explicit transient transport failures may be retained and retried. */
|
|
3
|
+
export declare function getProxyOtlpRetryAfter(error: Error | undefined): number | undefined;
|
|
2
4
|
/** Both proxy queues use this transport; success is not backend persistence. */
|
|
3
|
-
export declare function createProxyOtlpLogTransport(endpoint: string, timeoutMillis: number):
|
|
5
|
+
export declare function createProxyOtlpLogTransport(endpoint: string, timeoutMillis: number): ProxyOtlpLogTransport;
|
|
@@ -36,6 +36,12 @@ class ProxyOtlpResponseError extends Error {
|
|
|
36
36
|
this.name = "ProxyOtlpResponseError";
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
/** Only explicit transient transport failures may be retained and retried. */
|
|
40
|
+
export function getProxyOtlpRetryAfter(error) {
|
|
41
|
+
return error instanceof ProxyOtlpResponseError && error.retryable
|
|
42
|
+
? (error.retryAfterMs ?? 0)
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
|
39
45
|
function signalSetting(suffix) {
|
|
40
46
|
return (process.env[`OTEL_EXPORTER_OTLP_LOGS_${suffix}`]?.trim() ||
|
|
41
47
|
process.env[`OTEL_EXPORTER_OTLP_${suffix}`]?.trim() ||
|
|
@@ -200,6 +206,7 @@ export function createProxyOtlpLogTransport(endpoint, timeoutMillis) {
|
|
|
200
206
|
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
201
207
|
let closed = false;
|
|
202
208
|
const active = new Set();
|
|
209
|
+
const controllers = new Set();
|
|
203
210
|
const sendOnce = (data, signal) => new Promise((resolve, reject) => {
|
|
204
211
|
let receivedResponse = false;
|
|
205
212
|
const req = request(url, {
|
|
@@ -256,13 +263,13 @@ export function createProxyOtlpLogTransport(endpoint, timeoutMillis) {
|
|
|
256
263
|
});
|
|
257
264
|
req.on("error", (error) => reject(new ProxyOtlpResponseError(signal.aborted
|
|
258
265
|
? "OTLP export deadline exceeded"
|
|
259
|
-
: "OTLP transport failed", !
|
|
260
|
-
|
|
261
|
-
RETRYABLE_NETWORK_CODES.has(error.code ?? ""))));
|
|
266
|
+
: "OTLP transport failed", !receivedResponse &&
|
|
267
|
+
(signal.aborted || RETRYABLE_NETWORK_CODES.has(error.code ?? "")))));
|
|
262
268
|
req.end(data);
|
|
263
269
|
});
|
|
264
270
|
const send = async (records) => {
|
|
265
271
|
const controller = new AbortController();
|
|
272
|
+
controllers.add(controller);
|
|
266
273
|
const deadlineAt = Date.now() + timeoutMillis;
|
|
267
274
|
const deadline = setTimeout(() => controller.abort(), timeoutMillis);
|
|
268
275
|
deadline.unref();
|
|
@@ -286,19 +293,19 @@ export function createProxyOtlpLogTransport(endpoint, timeoutMillis) {
|
|
|
286
293
|
throw error;
|
|
287
294
|
}
|
|
288
295
|
if (attempt >= MAX_EXPORT_ATTEMPTS) {
|
|
289
|
-
throw new ProxyOtlpResponseError("OTLP retry attempt limit reached");
|
|
296
|
+
throw new ProxyOtlpResponseError("OTLP retry attempt limit reached", true, error.retryAfterMs);
|
|
290
297
|
}
|
|
291
298
|
const exponential = Math.min(5000, 1000 * 2 ** Math.min(attempt - 1, 3));
|
|
292
299
|
// Retry-After is a lower bound, never permission to hammer a busy
|
|
293
300
|
// collector. Even Retry-After: 0 retains exponential backoff+jitter.
|
|
294
301
|
const wait = Math.max(error.retryAfterMs ?? 0, exponential * (0.8 + Math.random() * 0.4));
|
|
295
302
|
if (wait >= deadlineAt - Date.now()) {
|
|
296
|
-
throw new ProxyOtlpResponseError("OTLP retry exceeds export deadline");
|
|
303
|
+
throw new ProxyOtlpResponseError("OTLP retry exceeds export deadline", true, wait);
|
|
297
304
|
}
|
|
298
305
|
await delay(wait, undefined, { signal: controller.signal });
|
|
299
306
|
}
|
|
300
307
|
}
|
|
301
|
-
throw new ProxyOtlpResponseError("OTLP export deadline exceeded");
|
|
308
|
+
throw new ProxyOtlpResponseError("OTLP export deadline exceeded", true);
|
|
302
309
|
}
|
|
303
310
|
catch (error) {
|
|
304
311
|
if (error instanceof ProxyOtlpResponseError) {
|
|
@@ -306,13 +313,21 @@ export function createProxyOtlpLogTransport(endpoint, timeoutMillis) {
|
|
|
306
313
|
}
|
|
307
314
|
throw new ProxyOtlpResponseError(controller.signal.aborted
|
|
308
315
|
? "OTLP export deadline exceeded"
|
|
309
|
-
: "OTLP export failed");
|
|
316
|
+
: "OTLP export failed", controller.signal.aborted);
|
|
310
317
|
}
|
|
311
318
|
finally {
|
|
312
319
|
clearTimeout(deadline);
|
|
320
|
+
controllers.delete(controller);
|
|
313
321
|
}
|
|
314
322
|
};
|
|
315
323
|
return {
|
|
324
|
+
abortPending() {
|
|
325
|
+
closed = true;
|
|
326
|
+
for (const controller of controllers) {
|
|
327
|
+
controller.abort();
|
|
328
|
+
}
|
|
329
|
+
agent.destroy();
|
|
330
|
+
},
|
|
316
331
|
export(records, callback) {
|
|
317
332
|
if (closed) {
|
|
318
333
|
callback({
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { emitProxyOtelEvent, getProxyOtelLogSnapshot, initializeProxyOtelLogs, isProxyOtelOnly, publishProxyOtelBody, } from "./otelLogSink.js";
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { createProxyBodyCapturePolicy } from "./bodyCapturePolicy.js";
|
|
10
11
|
import { join } from "path";
|
|
11
12
|
import { homedir } from "os";
|
|
12
13
|
import { logger } from "../utils/logger.js";
|
|
@@ -26,6 +27,7 @@ import { withTimeout } from "../utils/async/withTimeout.js";
|
|
|
26
27
|
import { resolveProxyLogTraceContext, proxyLogContext, } from "./proxyTraceContext.js";
|
|
27
28
|
let logDir = null;
|
|
28
29
|
let logEnabled = false;
|
|
30
|
+
let bodyCapturePolicy = createProxyBodyCapturePolicy();
|
|
29
31
|
const pendingLogOperations = new Set();
|
|
30
32
|
const REQUEST_LOG_IO_TIMEOUT_MS = 5_000;
|
|
31
33
|
// launchd gives the service 45 seconds after SIGTERM. Leave fifteen seconds
|
|
@@ -61,6 +63,7 @@ export function getRequestLoggerSnapshot() {
|
|
|
61
63
|
attempts: { ...metadataSinks.attempts },
|
|
62
64
|
debug: { ...metadataSinks.debug },
|
|
63
65
|
bodyCapture: getBodyCaptureWorkerSnapshot(),
|
|
66
|
+
bodyCapturePolicy: bodyCapturePolicy.snapshot(),
|
|
64
67
|
};
|
|
65
68
|
}
|
|
66
69
|
/**
|
|
@@ -165,6 +168,10 @@ export function initRequestLogger(enabled = true, customLogsDir) {
|
|
|
165
168
|
// Lifecycle metadata deliberately shares the request logger's enablement,
|
|
166
169
|
// directory permissions, retention boundary, and operator privacy control.
|
|
167
170
|
logEnabled = enabled;
|
|
171
|
+
bodyCapturePolicy = createProxyBodyCapturePolicy({
|
|
172
|
+
bytesPerMinute: process.env.NEUROLINK_PROXY_BODY_BYTES_PER_MINUTE,
|
|
173
|
+
deduplicate: process.env.NEUROLINK_PROXY_BODY_DEDUPLICATION !== "false",
|
|
174
|
+
});
|
|
168
175
|
if (!enabled) {
|
|
169
176
|
configureProxyLifecycleLogger({ enabled: false });
|
|
170
177
|
return;
|
|
@@ -699,13 +706,14 @@ export async function logBodyCapture(entry) {
|
|
|
699
706
|
indexEntry.traceFlags = traceCtx.traceFlags;
|
|
700
707
|
}
|
|
701
708
|
if (isProxyOtelOnly()) {
|
|
702
|
-
const
|
|
709
|
+
const publication = await bodyCapturePolicy.publish(metadata, stored, () => emitOtlpBodyLogRecord({
|
|
703
710
|
...metadata,
|
|
704
711
|
traceId: traceCtx?.traceId ?? metadata.traceId,
|
|
705
712
|
spanId: traceCtx?.spanId ?? metadata.spanId,
|
|
706
713
|
traceFlags: traceCtx?.traceFlags ?? metadata.traceFlags,
|
|
707
|
-
}, stored);
|
|
708
|
-
indexEntry.
|
|
714
|
+
}, stored));
|
|
715
|
+
indexEntry.bodyReference = publication.reference;
|
|
716
|
+
indexEntry.bodyDelivery = publication.delivery ?? {
|
|
709
717
|
status: processed.error
|
|
710
718
|
? "capture_rejected"
|
|
711
719
|
: stored.redactedBody === undefined
|
|
@@ -104,6 +104,21 @@ export async function startProxyRestartControl(options) {
|
|
|
104
104
|
// its existing streams. Full quiescence is only required before replacing
|
|
105
105
|
// the supervisor itself (updateCoordinator.isProxyRuntimeSettled).
|
|
106
106
|
};
|
|
107
|
+
const readServingStatus = async () => {
|
|
108
|
+
let lastError;
|
|
109
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
110
|
+
try {
|
|
111
|
+
return statusSchema.parse(await options.getStatus());
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
lastError = error;
|
|
115
|
+
if (attempt < 3) {
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 100));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw lastError;
|
|
121
|
+
};
|
|
107
122
|
const operate = async (restart) => {
|
|
108
123
|
if (busy || closing) {
|
|
109
124
|
return result("refused", "A restart check or activation is already in progress.");
|
|
@@ -130,7 +145,7 @@ export async function startProxyRestartControl(options) {
|
|
|
130
145
|
throw new Error("The serving worker exited during preflight.");
|
|
131
146
|
}
|
|
132
147
|
previousWorkerPid = active.pid;
|
|
133
|
-
const status =
|
|
148
|
+
const status = await readServingStatus();
|
|
134
149
|
assertSupervisorLogging(status, options.getTelemetry?.());
|
|
135
150
|
if (status.pid !== previousWorkerPid ||
|
|
136
151
|
status.version !== active.version) {
|
|
@@ -156,7 +171,7 @@ export async function startProxyRestartControl(options) {
|
|
|
156
171
|
handoffBaseline = options.server.snapshot();
|
|
157
172
|
const after = await options.server.replace(version);
|
|
158
173
|
activated = true;
|
|
159
|
-
const fresh =
|
|
174
|
+
const fresh = await readServingStatus();
|
|
160
175
|
if (fresh.pid !== after.active?.pid ||
|
|
161
176
|
fresh.pid === previousWorkerPid ||
|
|
162
177
|
fresh.version !== version) {
|
|
@@ -372,7 +387,7 @@ export async function requestProxyRestart(identity, check) {
|
|
|
372
387
|
connection: "close",
|
|
373
388
|
},
|
|
374
389
|
});
|
|
375
|
-
const timeout = setTimeout(() => req.destroy(new Error("Restart result timed out; outcome is unknown. Inspect proxy status before retrying.")), check ?
|
|
390
|
+
const timeout = setTimeout(() => req.destroy(new Error("Restart result timed out; outcome is unknown. Inspect proxy status before retrying.")), check ? 30_000 : 150_000);
|
|
376
391
|
req.once("error", (error) => {
|
|
377
392
|
clearTimeout(timeout);
|
|
378
393
|
reject(error);
|
|
@@ -14,9 +14,14 @@ export async function startRollingProxyServer(options) {
|
|
|
14
14
|
let requestedReplacementSchedule = 0;
|
|
15
15
|
let requestedReplacementPending = false;
|
|
16
16
|
let requestedReplacementReason = "environment";
|
|
17
|
+
let stalledGeneration;
|
|
18
|
+
let stallReplacementFailures = 0;
|
|
19
|
+
let nextStallReplacementAt = 0;
|
|
17
20
|
let replacementQueueTail = null;
|
|
18
21
|
const recoveryDelayMs = Math.max(1, options.recoveryDelayMs ?? DEFAULT_RECOVERY_DELAY_MS);
|
|
19
22
|
const maxRecoveryDelayMs = Math.max(recoveryDelayMs, options.maxRecoveryDelayMs ?? DEFAULT_MAX_RECOVERY_DELAY_MS);
|
|
23
|
+
const stallReplacementDelayMs = Math.max(1, options.stallReplacementDelayMs ?? 60_000);
|
|
24
|
+
const maxStallReplacementDelayMs = Math.max(stallReplacementDelayMs, options.maxStallReplacementDelayMs ?? 15 * 60_000);
|
|
20
25
|
const queueReplacement = (operation) => {
|
|
21
26
|
const predecessor = replacementQueueTail;
|
|
22
27
|
const result = (predecessor ?? Promise.resolve()).then(operation);
|
|
@@ -61,6 +66,21 @@ export async function startRollingProxyServer(options) {
|
|
|
61
66
|
recoveryTimer.unref?.();
|
|
62
67
|
};
|
|
63
68
|
const stateChanged = (snapshot) => {
|
|
69
|
+
if (requestedReplacementReason !== "environment" &&
|
|
70
|
+
stalledGeneration !== undefined &&
|
|
71
|
+
snapshot.active?.generation !== stalledGeneration) {
|
|
72
|
+
// A pressure retry belongs only to the generation that stalled. Once it
|
|
73
|
+
// exits or is replaced, ordinary recovery owns the next generation.
|
|
74
|
+
if (requestedReplacementTimer) {
|
|
75
|
+
clearTimeout(requestedReplacementTimer);
|
|
76
|
+
requestedReplacementTimer = undefined;
|
|
77
|
+
}
|
|
78
|
+
requestedReplacementSchedule += 1;
|
|
79
|
+
requestedReplacementPending = false;
|
|
80
|
+
stalledGeneration = undefined;
|
|
81
|
+
stallReplacementFailures = 0;
|
|
82
|
+
nextStallReplacementAt = 0;
|
|
83
|
+
}
|
|
64
84
|
try {
|
|
65
85
|
options.onStateChange?.(snapshot);
|
|
66
86
|
}
|
|
@@ -78,6 +98,12 @@ export async function startRollingProxyServer(options) {
|
|
|
78
98
|
requestedReplacementPending = true;
|
|
79
99
|
if (request) {
|
|
80
100
|
requestedReplacementReason = request.reason;
|
|
101
|
+
if (request.reason !== "environment" &&
|
|
102
|
+
stalledGeneration !== request.generation) {
|
|
103
|
+
stalledGeneration = request.generation;
|
|
104
|
+
stallReplacementFailures = 0;
|
|
105
|
+
nextStallReplacementAt = 0;
|
|
106
|
+
}
|
|
81
107
|
}
|
|
82
108
|
if (requestedReplacementTimer || replacementQueueTail) {
|
|
83
109
|
return;
|
|
@@ -89,22 +115,41 @@ export async function startRollingProxyServer(options) {
|
|
|
89
115
|
return;
|
|
90
116
|
}
|
|
91
117
|
if (closing || !supervisor.snapshot().active) {
|
|
118
|
+
requestedReplacementPending = false;
|
|
92
119
|
return;
|
|
93
120
|
}
|
|
94
121
|
requestedReplacementPending = false;
|
|
95
122
|
const replacementVersion = desiredVersion;
|
|
96
123
|
const replacementReason = requestedReplacementReason;
|
|
124
|
+
const replacementGeneration = supervisor.snapshot().active?.generation;
|
|
97
125
|
void queueReplacement(async () => {
|
|
98
126
|
if (closing || !supervisor.snapshot().active) {
|
|
99
127
|
return;
|
|
100
128
|
}
|
|
101
129
|
options.log?.(`[proxy-supervisor] preparing same-version worker replacement version=${replacementVersion} reason=${replacementReason}`);
|
|
102
130
|
await supervisor.replace(replacementVersion);
|
|
131
|
+
stallReplacementFailures = 0;
|
|
132
|
+
nextStallReplacementAt = 0;
|
|
103
133
|
options.log?.(`[proxy-supervisor] same-version worker replacement complete version=${replacementVersion} reason=${replacementReason}`);
|
|
104
134
|
}).catch((error) => {
|
|
105
135
|
options.log?.(`[proxy-supervisor] same-version worker replacement failed version=${replacementVersion} reason=${replacementReason}: ${error instanceof Error ? error.message : String(error)}`);
|
|
136
|
+
// A failed candidate does not invalidate the old generation's streams.
|
|
137
|
+
// Retry pressure recovery with a growing, capped delay. New requests
|
|
138
|
+
// cannot reset that delay, and an unrelated successful rotation ends it.
|
|
139
|
+
if (replacementReason !== "environment" &&
|
|
140
|
+
!closing &&
|
|
141
|
+
supervisor.snapshot().active?.generation ===
|
|
142
|
+
replacementGeneration &&
|
|
143
|
+
schedule === requestedReplacementSchedule) {
|
|
144
|
+
stallReplacementFailures += 1;
|
|
145
|
+
const delay = Math.min(maxStallReplacementDelayMs, stallReplacementDelayMs *
|
|
146
|
+
2 ** Math.min(stallReplacementFailures - 1, 16));
|
|
147
|
+
nextStallReplacementAt = Date.now() + delay;
|
|
148
|
+
options.log?.(`[proxy-supervisor] stall replacement retry deferred failures=${stallReplacementFailures} delayMs=${delay}`);
|
|
149
|
+
scheduleRequestedReplacement();
|
|
150
|
+
}
|
|
106
151
|
});
|
|
107
|
-
}, 50);
|
|
152
|
+
}, Math.max(50, nextStallReplacementAt - Date.now()));
|
|
108
153
|
requestedReplacementTimer.unref?.();
|
|
109
154
|
}
|
|
110
155
|
const supervisor = new RollingWorkerSupervisor({
|
|
@@ -189,6 +234,8 @@ export async function startRollingProxyServer(options) {
|
|
|
189
234
|
try {
|
|
190
235
|
const snapshot = await queueReplacement(() => supervisor.replace(expectedVersion));
|
|
191
236
|
recoveryFailures = 0;
|
|
237
|
+
stallReplacementFailures = 0;
|
|
238
|
+
nextStallReplacementAt = 0;
|
|
192
239
|
return snapshot;
|
|
193
240
|
}
|
|
194
241
|
catch (error) {
|