@juspay/neurolink 12.14.3 → 12.14.5

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.
@@ -1079,6 +1079,8 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
1079
1079
  requestMetadata.set(c.req.raw, metadata);
1080
1080
  const stopObservingFinalLog = observeProxyFinalLog(metadata.requestId, (entry) => {
1081
1081
  metadata.terminalResult = entry;
1082
+ }, (entry) => {
1083
+ metadata.lastUpstreamAttempt = { ...entry };
1082
1084
  });
1083
1085
  const finishActivity = metadata.rejectForUpdate
1084
1086
  ? () => undefined
@@ -1306,7 +1308,8 @@ export async function createProxyStartApp(params) {
1306
1308
  const recordRuntimeError = async (metadata, status, errorType, errorMessage, options) => {
1307
1309
  const clientMessage = options?.clientMessage ?? errorMessage;
1308
1310
  const clientErrorType = options?.clientErrorType ?? errorType;
1309
- recordFinalError(status, PROXY_INTERNAL_ACCOUNT_LABEL, PROXY_INTERNAL_ACCOUNT_TYPE, {
1311
+ const attempt = metadata.lastUpstreamAttempt;
1312
+ recordFinalError(status, attempt?.account ?? PROXY_INTERNAL_ACCOUNT_LABEL, attempt?.accountType ?? PROXY_INTERNAL_ACCOUNT_TYPE, {
1310
1313
  requestId: metadata.requestId,
1311
1314
  errorType,
1312
1315
  errorCode: options?.errorCode,
@@ -1322,8 +1325,13 @@ export async function createProxyStartApp(params) {
1322
1325
  model: metadata.model,
1323
1326
  stream: metadata.stream,
1324
1327
  toolCount: metadata.toolCount,
1325
- account: "",
1326
- accountType: "proxy-runtime",
1328
+ account: attempt?.account ?? "",
1329
+ accountType: attempt?.accountType ?? "proxy-runtime",
1330
+ accountKey: attempt?.accountKey,
1331
+ provider: attempt?.provider,
1332
+ transportScope: attempt?.transportScope,
1333
+ traceId: attempt?.traceId,
1334
+ spanId: attempt?.spanId,
1327
1335
  responseStatus: status,
1328
1336
  responseTimeMs: Date.now() - metadata.startedAt,
1329
1337
  errorType,
@@ -38,10 +38,18 @@ export declare class ToolCache<T = unknown> extends EventEmitter {
38
38
  constructor(config: McpCacheConfig);
39
39
  /**
40
40
  * Get a value from the cache
41
+ *
42
+ * Returns an isolated copy of the stored value (see `cloneCachedValue`), so
43
+ * a caller mutating what it gets back cannot corrupt the entry for later
44
+ * hits or for other concurrent callers of the same key.
41
45
  */
42
46
  get(key: string): T | undefined;
43
47
  /**
44
48
  * Set a value in the cache
49
+ *
50
+ * Stores an isolated copy of `value` (see `cloneCachedValue`), so mutating
51
+ * the caller's original object after this call cannot reach into the
52
+ * cache entry.
45
53
  */
46
54
  set(key: string, value: T, ttl?: number): void;
47
55
  /**
@@ -89,6 +97,28 @@ export declare class ToolCache<T = unknown> extends EventEmitter {
89
97
  * Stop the auto-cleanup timer
90
98
  */
91
99
  destroy(): void;
100
+ /**
101
+ * Isolate a value crossing the cache boundary (on write into the entry,
102
+ * and on read back out of it) so no two callers — nor a caller and the
103
+ * stored entry itself — ever share object identity.
104
+ *
105
+ * Without this, `set()` stored the caller's object by reference and
106
+ * `get()` returned `entry.value` by the same reference on every hit: one
107
+ * caller mutating a result it got back (e.g. an in-place truncation or
108
+ * normalization pass) silently corrupted the entry for every later
109
+ * caller of the same key for the rest of the TTL.
110
+ *
111
+ * `structuredClone` is the primary path — it is a deep copy, has no
112
+ * caller-visible side effects, and (unlike a JSON round-trip) tolerates
113
+ * circular references, which a sufficiently deep or recursive tool
114
+ * result could contain. It throws on values it cannot clone (functions,
115
+ * some non-plain class instances); the JSON round-trip fallback covers
116
+ * that case for the plain-data shapes MCP tool results actually have
117
+ * (text/JSON content arrays), at the cost of silently dropping
118
+ * `undefined`, functions and symbol keys — acceptable for a cache that
119
+ * only ever holds serializable tool results.
120
+ */
121
+ private cloneCachedValue;
92
122
  private getFullKey;
93
123
  private isExpired;
94
124
  /**
@@ -60,6 +60,10 @@ export class ToolCache extends EventEmitter {
60
60
  }
61
61
  /**
62
62
  * Get a value from the cache
63
+ *
64
+ * Returns an isolated copy of the stored value (see `cloneCachedValue`), so
65
+ * a caller mutating what it gets back cannot corrupt the entry for later
66
+ * hits or for other concurrent callers of the same key.
63
67
  */
64
68
  get(key) {
65
69
  const fullKey = this.getFullKey(key);
@@ -83,11 +87,24 @@ export class ToolCache extends EventEmitter {
83
87
  entry.accessCount++;
84
88
  this.stats.hits++;
85
89
  this.updateHitRate();
86
- this.emit("hit", { key: fullKey, value: entry.value });
87
- return entry.value;
90
+ const returnedValue = this.cloneCachedValue(entry.value);
91
+ if (this.listenerCount("hit") > 0) {
92
+ // Listeners get their own copy. `emit` is synchronous, so a listener
93
+ // that mutates `event.value` would otherwise be mutating the very object
94
+ // the caller is about to receive.
95
+ this.emit("hit", {
96
+ key: fullKey,
97
+ value: this.cloneCachedValue(entry.value),
98
+ });
99
+ }
100
+ return returnedValue;
88
101
  }
89
102
  /**
90
103
  * Set a value in the cache
104
+ *
105
+ * Stores an isolated copy of `value` (see `cloneCachedValue`), so mutating
106
+ * the caller's original object after this call cannot reach into the
107
+ * cache entry.
91
108
  */
92
109
  set(key, value, ttl) {
93
110
  const fullKey = this.getFullKey(key);
@@ -98,7 +115,7 @@ export class ToolCache extends EventEmitter {
98
115
  this.evictOne();
99
116
  }
100
117
  const entry = {
101
- value,
118
+ value: this.cloneCachedValue(value),
102
119
  expires: now + effectiveTtl,
103
120
  createdAt: now,
104
121
  accessedAt: now,
@@ -250,6 +267,38 @@ export class ToolCache extends EventEmitter {
250
267
  this.clear();
251
268
  }
252
269
  // ==================== Private Methods ====================
270
+ /**
271
+ * Isolate a value crossing the cache boundary (on write into the entry,
272
+ * and on read back out of it) so no two callers — nor a caller and the
273
+ * stored entry itself — ever share object identity.
274
+ *
275
+ * Without this, `set()` stored the caller's object by reference and
276
+ * `get()` returned `entry.value` by the same reference on every hit: one
277
+ * caller mutating a result it got back (e.g. an in-place truncation or
278
+ * normalization pass) silently corrupted the entry for every later
279
+ * caller of the same key for the rest of the TTL.
280
+ *
281
+ * `structuredClone` is the primary path — it is a deep copy, has no
282
+ * caller-visible side effects, and (unlike a JSON round-trip) tolerates
283
+ * circular references, which a sufficiently deep or recursive tool
284
+ * result could contain. It throws on values it cannot clone (functions,
285
+ * some non-plain class instances); the JSON round-trip fallback covers
286
+ * that case for the plain-data shapes MCP tool results actually have
287
+ * (text/JSON content arrays), at the cost of silently dropping
288
+ * `undefined`, functions and symbol keys — acceptable for a cache that
289
+ * only ever holds serializable tool results.
290
+ */
291
+ cloneCachedValue(value) {
292
+ if (value === null || typeof value !== "object") {
293
+ return value;
294
+ }
295
+ try {
296
+ return structuredClone(value);
297
+ }
298
+ catch {
299
+ return JSON.parse(JSON.stringify(value));
300
+ }
301
+ }
253
302
  getFullKey(key) {
254
303
  return this.config.namespace ? `${this.config.namespace}:${key}` : key;
255
304
  }
@@ -9,6 +9,7 @@ export declare function prepareProxyBodyForLogging(body: unknown): {
9
9
  value?: string;
10
10
  bytes?: number;
11
11
  truncated: boolean;
12
+ originalBytes?: number;
12
13
  };
13
14
  /**
14
15
  * Expose the same header-redaction policy to replay and metadata
@@ -6,6 +6,7 @@ import { gzip as gzipCallback } from "node:zlib";
6
6
  const REQUEST_LOG_IO_TIMEOUT_MS = 5_000;
7
7
  /** Maximum redacted body bytes persisted per capture entry. */
8
8
  const MAX_CAPTURED_BODY_BYTES = 1024 * 1024;
9
+ const MAX_OTEL_CAPTURED_BODY_BYTES = 8 * 1024 * 1024;
9
10
  const BODY_TRUNCATION_MARKER = "\n...[TRUNCATED]";
10
11
  const gzip = promisify(gzipCallback);
11
12
  /** Headers whose values must always be redacted. */
@@ -130,12 +131,15 @@ export function splitUtf8StringByBytes(input, maxBytes) {
130
131
  * Apply structural redaction before enforcing the per-artifact byte
131
132
  * ceiling.
132
133
  */
133
- function prepareRedactedBody(body) {
134
+ function prepareRedactedBody(body, maxBytes = MAX_CAPTURED_BODY_BYTES) {
134
135
  const redacted = redactBody(body);
135
136
  if (redacted === undefined) {
136
137
  return { truncated: false };
137
138
  }
138
- return truncateUtf8String(redacted, MAX_CAPTURED_BODY_BYTES);
139
+ return {
140
+ ...truncateUtf8String(redacted, maxBytes),
141
+ originalBytes: utf8ByteLength(redacted),
142
+ };
139
143
  }
140
144
  /**
141
145
  * Write a private gzip artifact with a unique name and return its
@@ -202,7 +206,8 @@ export function redactProxyHeadersForLogging(headers) {
202
206
  */
203
207
  export async function processProxyBodyCapture(entry, logDir) {
204
208
  const headers = redactHeaders(entry.headers);
205
- const prepared = prepareRedactedBody(entry.body);
209
+ const limit = logDir === null ? MAX_OTEL_CAPTURED_BODY_BYTES : MAX_CAPTURED_BODY_BYTES;
210
+ const prepared = prepareRedactedBody(entry.body, limit);
206
211
  if (logDir === null) {
207
212
  return {
208
213
  headers,
@@ -210,6 +215,9 @@ export async function processProxyBodyCapture(entry, logDir) {
210
215
  redactedBody: prepared.value,
211
216
  redactedBodyBytes: prepared.bytes,
212
217
  bodyTruncated: prepared.truncated,
218
+ bodyCaptureLimitBytes: limit,
219
+ originalRedactedBodyBytes: prepared.originalBytes,
220
+ bodySha256: prepared.value === undefined ? undefined : sha256(prepared.value),
213
221
  },
214
222
  };
215
223
  }
@@ -225,5 +233,12 @@ export async function processProxyBodyCapture(entry, logDir) {
225
233
  bodyWriteFailed: true,
226
234
  };
227
235
  }
228
- return { headers, stored };
236
+ return {
237
+ headers,
238
+ stored: {
239
+ ...stored,
240
+ bodyCaptureLimitBytes: limit,
241
+ originalRedactedBodyBytes: prepared.originalBytes,
242
+ },
243
+ };
229
244
  }
@@ -1,7 +1,9 @@
1
1
  import { Worker } from "node:worker_threads";
2
2
  const MAX_PENDING = 16;
3
3
  const MAX_PENDING_BYTES = 32 * 1024 * 1024;
4
- const MAX_ENTRY_BYTES = 8 * 1024 * 1024;
4
+ // Bound retained UTF-16 strings rather than a 3x UTF-8 guess. This accommodates
5
+ // the observed 7.3 MB JSON requests while retaining a 32 MiB aggregate pool.
6
+ const MAX_ENTRY_BYTES = 16 * 1024 * 1024;
5
7
  export const PROXY_BODY_CAPTURE_DEADLINE_MS = 20_000;
6
8
  let worker;
7
9
  let workerUrl;
@@ -16,6 +18,7 @@ const snapshot = {
16
18
  pendingBytes: 0,
17
19
  maxPending: MAX_PENDING,
18
20
  maxPendingBytes: MAX_PENDING_BYTES,
21
+ rejectionReasons: {},
19
22
  };
20
23
  const pending = new Map();
21
24
  // Bound traversal as well as the structured clone sent to the worker. Never
@@ -29,12 +32,15 @@ function estimateCloneBytes(value) {
29
32
  const seen = new Set();
30
33
  let bytes = 0, nodes = 0;
31
34
  while (stack.length) {
32
- if (++nodes > 100_000 || bytes > MAX_ENTRY_BYTES) {
33
- return Infinity;
35
+ if (++nodes > 100_000) {
36
+ throw new Error("body_capture_traversal_limit");
37
+ }
38
+ if (bytes > MAX_ENTRY_BYTES) {
39
+ throw new Error("body_capture_entry_too_large");
34
40
  }
35
41
  const item = stack.pop();
36
42
  if (typeof item === "string") {
37
- bytes += item.length * 3;
43
+ bytes += 16 + item.length * 2;
38
44
  continue;
39
45
  }
40
46
  bytes += 16;
@@ -42,23 +48,26 @@ function estimateCloneBytes(value) {
42
48
  continue;
43
49
  }
44
50
  if (seen.has(item)) {
45
- return Infinity;
51
+ throw new Error("body_capture_unsupported_value");
46
52
  }
47
53
  seen.add(item);
48
54
  if (!Array.isArray(item) &&
49
55
  Object.getPrototypeOf(item) !== Object.prototype &&
50
56
  Object.getPrototypeOf(item) !== null) {
51
- return Infinity;
57
+ throw new Error("body_capture_unsupported_value");
52
58
  }
53
59
  for (const key of Object.keys(item)) {
54
- bytes += key.length * 3;
60
+ bytes += 16 + key.length * 2;
55
61
  const descriptor = Object.getOwnPropertyDescriptor(item, key);
56
62
  if (!descriptor || descriptor.get || descriptor.set) {
57
- return Infinity;
63
+ throw new Error("body_capture_unsupported_value");
58
64
  }
59
65
  stack.push(descriptor.value);
60
- if (stack.length > 100_000 || bytes > MAX_ENTRY_BYTES) {
61
- return Infinity;
66
+ if (stack.length > 100_000) {
67
+ throw new Error("body_capture_traversal_limit");
68
+ }
69
+ if (bytes > MAX_ENTRY_BYTES) {
70
+ throw new Error("body_capture_entry_too_large");
62
71
  }
63
72
  }
64
73
  }
@@ -125,11 +134,21 @@ function getWorker() {
125
134
  export async function captureProxyBody(entry, logDir, consume) {
126
135
  snapshot.attempted += 1;
127
136
  let bytes;
137
+ let admissionError;
128
138
  try {
129
139
  bytes = estimateCloneBytes(entry);
130
140
  }
131
- catch {
141
+ catch (error) {
132
142
  bytes = Infinity;
143
+ admissionError =
144
+ error instanceof Error &&
145
+ [
146
+ "body_capture_entry_too_large",
147
+ "body_capture_traversal_limit",
148
+ "body_capture_unsupported_value",
149
+ ].includes(error.message)
150
+ ? error.message
151
+ : "body_capture_unsupported_value";
133
152
  }
134
153
  if (bytes > MAX_ENTRY_BYTES ||
135
154
  snapshot.pending >= MAX_PENDING ||
@@ -137,11 +156,13 @@ export async function captureProxyBody(entry, logDir, consume) {
137
156
  Date.now() < retryAfter) {
138
157
  snapshot.rejected += 1;
139
158
  const error = bytes > MAX_ENTRY_BYTES
140
- ? "body_capture_too_large_or_non_json"
159
+ ? (admissionError ?? "body_capture_entry_too_large")
141
160
  : Date.now() < retryAfter
142
161
  ? "body_worker_backoff"
143
162
  : "body_capture_queue_full";
144
163
  snapshot.lastError = error;
164
+ snapshot.rejectionReasons[error] =
165
+ (snapshot.rejectionReasons[error] ?? 0) + 1;
145
166
  return consume({ error, stored: { bodyWriteFailed: true } });
146
167
  }
147
168
  let current;
@@ -204,7 +225,7 @@ export async function captureProxyBody(entry, logDir, consume) {
204
225
  * retained publication work.
205
226
  */
206
227
  export function getBodyCaptureWorkerSnapshot() {
207
- return { ...snapshot };
228
+ return { ...snapshot, rejectionReasons: { ...snapshot.rejectionReasons } };
208
229
  }
209
230
  /** Isolated tests point at a separately executed built worker. */
210
231
  export const __bodyCaptureWorkerTestHooks = {
@@ -227,6 +248,7 @@ export const __bodyCaptureWorkerTestHooks = {
227
248
  pending: 0,
228
249
  pendingBytes: 0,
229
250
  lastError: undefined,
251
+ rejectionReasons: {},
230
252
  });
231
253
  },
232
254
  };
@@ -1,6 +1,13 @@
1
1
  import { LoggerProvider } from "@opentelemetry/sdk-logs";
2
+ import type { ProxyBodyChunkEmitter, ProxyBodyDeliveryResult } from "../types/index.js";
2
3
  /** Explicit opt-in; configuration never silently falls back to file logging. */
3
4
  export declare function isProxyOtelOnly(): boolean;
5
+ /**
6
+ * Own a whole capture within byte/count bounds, then pace its chunks by actual
7
+ * export callbacks. SDK forceFlush alone does not await an automatic export
8
+ * already in flight. Serial publication prevents bursts from dropping tails.
9
+ */
10
+ export declare function publishProxyOtelBody(captureId: string, body: string, emit: ProxyBodyChunkEmitter): Promise<ProxyBodyDeliveryResult>;
4
11
  /** Initialize a log-only provider in every proxy process, including the supervisor. */
5
12
  export declare function initializeProxyOtelLogs(role?: string): LoggerProvider | undefined;
6
13
  /** Structured evidence without final-request dashboard fields on auxiliary events. */
@@ -13,6 +20,19 @@ export declare function getProxyOtelLogSnapshot(): {
13
20
  initialized: boolean;
14
21
  deliveryGuarantee: string;
15
22
  invalidRecords: number;
23
+ bodyDelivery: {
24
+ attempted: number;
25
+ transportAcknowledged: number;
26
+ exportUnconfirmed: number;
27
+ rejected: number;
28
+ partial: number;
29
+ pending: number;
30
+ pendingBytes: number;
31
+ highWaterPending: number;
32
+ highWaterBytes: number;
33
+ maxPending: number;
34
+ maxPendingBytes: number;
35
+ };
16
36
  queues: {
17
37
  attempted: number;
18
38
  submitted: number;
@@ -22,7 +42,8 @@ export declare function getProxyOtelLogSnapshot(): {
22
42
  outstanding: number;
23
43
  lastAcknowledgedAt: string | undefined;
24
44
  lastFailureAt: string | undefined;
25
- kind: string;
45
+ highWaterOutstanding: number;
46
+ kind: "metadata" | "bodies";
26
47
  capacity: number;
27
48
  }[];
28
49
  };