@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +2 -6
  2. package/dist/browser/neurolink.min.js +400 -400
  3. package/dist/cli/commands/proxy.js +171 -25
  4. package/dist/cli/commands/telemetry.d.ts +4 -1
  5. package/dist/cli/commands/telemetry.js +113 -0
  6. package/dist/cli/utils/nativeTelemetry.d.ts +19 -0
  7. package/dist/cli/utils/nativeTelemetry.js +322 -0
  8. package/dist/proxy/bodyCapturePolicy.d.ts +6 -0
  9. package/dist/proxy/bodyCapturePolicy.js +132 -0
  10. package/dist/proxy/codexFallback.d.ts +27 -9
  11. package/dist/proxy/codexFallback.js +84 -13
  12. package/dist/proxy/openaiFormat.js +8 -1
  13. package/dist/proxy/otelLogSink.d.ts +11 -1
  14. package/dist/proxy/otelLogSink.js +174 -27
  15. package/dist/proxy/otlpLogTransport.d.ts +4 -2
  16. package/dist/proxy/otlpLogTransport.js +22 -7
  17. package/dist/proxy/requestLogger.js +11 -3
  18. package/dist/proxy/restartControl.js +18 -3
  19. package/dist/proxy/rollingProxyServer.js +48 -1
  20. package/dist/proxy/rollingWorkerProcess.js +87 -10
  21. package/dist/proxy/rollingWorkerProtocol.js +4 -2
  22. package/dist/proxy/rollingWorkerSupervisor.js +47 -10
  23. package/dist/proxy/socketWorkerRuntime.js +90 -4
  24. package/dist/proxy/updateChecker.d.ts +3 -3
  25. package/dist/proxy/updateChecker.js +16 -9
  26. package/dist/proxy/updateCoordinator.d.ts +10 -1
  27. package/dist/proxy/updateCoordinator.js +68 -1
  28. package/dist/proxy/updateState.d.ts +2 -0
  29. package/dist/proxy/updateState.js +15 -0
  30. package/dist/proxy/vertexAnthropicFallback.d.ts +17 -1
  31. package/dist/proxy/vertexAnthropicFallback.js +25 -1
  32. package/dist/server/routes/claudeProxyRoutes.d.ts +17 -0
  33. package/dist/server/routes/claudeProxyRoutes.js +53 -5
  34. package/dist/types/cli.d.ts +18 -0
  35. package/dist/types/providers.d.ts +8 -0
  36. package/dist/types/proxy.d.ts +117 -7
  37. package/dist/utils/anthropicCacheBreakpoints.d.ts +22 -1
  38. package/dist/utils/anthropicCacheBreakpoints.js +113 -0
  39. package/dist/utils/pricing.js +69 -2
  40. package/docs-site/static/search-index.json +1 -1
  41. package/package.json +1 -1
  42. package/scripts/observability/NATIVE-DURABLE.md +68 -0
  43. package/scripts/observability/otel-collector.proxy-native-durable.yaml +2 -2
  44. 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
- const deadline = setTimeout(() => settle({
117
- code: ExportResultCode.FAILED,
118
- error: new Error("OTLP export callback deadline exceeded"),
119
- }), OTLP_EXPORT_CALLBACK_DEADLINE_MS);
120
- deadline.unref();
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
- try {
164
- transport.export(records, settle);
165
- }
166
- catch (error) {
167
- settle({
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: error instanceof Error ? error : new Error(String(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
- if (state.outstanding >= capacity) {
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 { state, processor, capacity, kind, waitForCapacity };
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
- queues.push(metadata, bodies);
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
- : metadata).processor.onEmit(record, context);
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
- await Promise.allSettled([...bodyPublicationOperations]);
597
- restoreConsole?.();
598
- restoreConsole = undefined;
599
- await provider?.shutdown();
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 { LogRecordExporter } from "@opentelemetry/sdk-logs";
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): LogRecordExporter;
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", !signal.aborted &&
260
- !receivedResponse &&
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 delivery = await emitOtlpBodyLogRecord({
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.bodyDelivery = delivery ?? {
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 = statusSchema.parse(await options.getStatus());
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 = statusSchema.parse(await options.getStatus());
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 ? 15_000 : 150_000);
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) {