@juspay/neurolink 12.14.0 → 12.14.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.
@@ -88,5 +88,6 @@ export declare const proxyStatusCommand: CommandModule<object, ProxyStatusArgs>;
88
88
  export declare const proxyTelemetryCommand: CommandModule<object, ProxyTelemetryArgs>;
89
89
  export declare const proxyGuardCommand: CommandModule<object, ProxyGuardArgs>;
90
90
  export declare const proxySetupCommand: CommandModule;
91
+ export declare function buildProxyLaunchdPlist(port: number, host: string, envFile?: string, configFile?: string): string;
91
92
  export declare const proxyInstallCommand: CommandModule;
92
93
  export declare const proxyUninstallCommand: CommandModule;
@@ -1,14 +1,5 @@
1
- /**
2
- * Proxy CLI Commands for NeuroLink
3
- *
4
- * Implements commands for managing the Claude multi-account proxy:
5
- * - neurolink proxy start — Start the proxy server
6
- * - neurolink proxy status — Show proxy status (accounts, sessions, routing)
7
- *
8
- * The proxy creates a NeuroLink instance and builds a Hono app that registers
9
- * Claude-compatible proxy routes. All requests flow through ctx.neurolink
10
- * (generate/stream), with an optional ModelRouter for model remapping.
11
- */
1
+ import { initializeProxyOtelLogs, routeProxyConsoleToOtel, flushProxyOtelLogs, shutdownProxyOtelLogs, isProxyOtelOnly, withProxyOtelLogShutdown, } from "../../proxy/otelLogSink.js";
2
+ import { writeFileAtomic } from "../proxy-clients/snapshot.js";
12
3
  import { spawn } from "node:child_process";
13
4
  import { homedir } from "node:os";
14
5
  import { dirname, join } from "node:path";
@@ -2398,6 +2389,8 @@ function registerProxyShutdownHandlers(params) {
2398
2389
  }
2399
2390
  try {
2400
2391
  const { flushOpenTelemetry, shutdownOpenTelemetry } = await import("../../services/server/ai/observability/instrumentation.js");
2392
+ await flushProxyOtelLogs();
2393
+ await shutdownProxyOtelLogs();
2401
2394
  await flushOpenTelemetry();
2402
2395
  await shutdownOpenTelemetry();
2403
2396
  }
@@ -2758,6 +2751,9 @@ async function startProxyRuntime(params) {
2758
2751
  * serving-process exits.
2759
2752
  */
2760
2753
  async function runLaunchdProxySupervisor(argv, spinner) {
2754
+ await loadProxyStartEnv(argv, spinner);
2755
+ initializeProxyOtelLogs("supervisor");
2756
+ routeProxyConsoleToOtel();
2761
2757
  await ensureProxyStartAllowed(spinner);
2762
2758
  const entryScript = process.argv[1];
2763
2759
  if (!entryScript) {
@@ -2871,6 +2867,8 @@ async function runLaunchdProxySupervisor(argv, spinner) {
2871
2867
  updaterSupervisor.stop();
2872
2868
  await rollingServer.close();
2873
2869
  await flushProxyLifecycleEvents().catch((error) => logger.warn(String(error)));
2870
+ await flushProxyOtelLogs().catch(() => undefined);
2871
+ await shutdownProxyOtelLogs().catch(() => undefined);
2874
2872
  const supervisorState = loadProxySupervisorState();
2875
2873
  if (supervisorState?.pid === process.pid) {
2876
2874
  clearProxySupervisorState();
@@ -2951,6 +2949,8 @@ async function startProxyCommandHandler(argv) {
2951
2949
  env: baseEnv,
2952
2950
  });
2953
2951
  const loadedEnvFile = await loadProxyStartEnv(argv, spinner);
2952
+ initializeProxyOtelLogs("worker");
2953
+ routeProxyConsoleToOtel();
2954
2954
  // Reuse upstream TCP connections (longer keep-alive + bounded pool) instead
2955
2955
  // of opening a new flow per request — cuts outbound flow churn through host
2956
2956
  // content-filters. Runs once, after env load so it can be tuned via env.
@@ -3592,7 +3592,9 @@ export const proxyGuardCommand = {
3592
3592
  default: true,
3593
3593
  });
3594
3594
  },
3595
- handler: async (argv) => {
3595
+ handler: withProxyOtelLogShutdown(async (argv) => {
3596
+ initializeProxyOtelLogs("updater");
3597
+ routeProxyConsoleToOtel();
3596
3598
  const host = argv.host ?? "127.0.0.1";
3597
3599
  const port = argv.port ?? 55669;
3598
3600
  const parentPid = Number(argv.parentPid);
@@ -3916,6 +3918,8 @@ export const proxyGuardCommand = {
3916
3918
  logger.always(`[updater] update successful: now running ${result.latestVersion}`);
3917
3919
  persistUpdaterState("record successful update", () => recordSuccessfulUpdate(result.latestVersion));
3918
3920
  // The replacement proxy starts a worker running the new version.
3921
+ await flushProxyOtelLogs().catch(() => undefined);
3922
+ await shutdownProxyOtelLogs().catch(() => undefined);
3919
3923
  process.exit(0);
3920
3924
  }
3921
3925
  else {
@@ -4083,7 +4087,7 @@ export const proxyGuardCommand = {
4083
4087
  if (cleared && !argv.quiet) {
4084
4088
  logger.always(`[proxy] fail-open guard removed stale ${expectedBaseUrl} from Claude settings`);
4085
4089
  }
4086
- },
4090
+ }),
4087
4091
  };
4088
4092
  // =============================================================================
4089
4093
  // PROXY SETUP COMMAND
@@ -4257,7 +4261,7 @@ function buildLaunchdPath() {
4257
4261
  }
4258
4262
  return [...segments].join(":");
4259
4263
  }
4260
- function buildPlist(port, host, envFile, configFile) {
4264
+ export function buildProxyLaunchdPlist(port, host, envFile, configFile) {
4261
4265
  // The plist invokes the trampoline script (a tiny shell wrapper at
4262
4266
  // ~/.neurolink/bin/neurolink-proxy) which re-resolves the real
4263
4267
  // `neurolink` binary via PATH on every launch. This way, launchd
@@ -4273,6 +4277,20 @@ function buildPlist(port, host, envFile, configFile) {
4273
4277
  <string>--config</string>
4274
4278
  <string>${escapeXml(configFile)}</string>`
4275
4279
  : "";
4280
+ const otelEnvironment = isProxyOtelOnly()
4281
+ ? [
4282
+ "NEUROLINK_PROXY_LOG_SINK",
4283
+ "OTEL_EXPORTER_OTLP_ENDPOINT",
4284
+ "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
4285
+ "OTEL_EXPORTER_OTLP_HEADERS",
4286
+ "OTEL_EXPORTER_OTLP_LOGS_HEADERS",
4287
+ "OTEL_SERVICE_NAME",
4288
+ "NEUROLINK_PROXY_SESSION_SECRET",
4289
+ ]
4290
+ .filter((name) => process.env[name] !== undefined)
4291
+ .map((name) => ` <key>${name}</key>\n <string>${escapeXml(process.env[name])}</string>`)
4292
+ .join("\n")
4293
+ : "";
4276
4294
  return `<?xml version="1.0" encoding="UTF-8"?>
4277
4295
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
4278
4296
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -4311,10 +4329,10 @@ ${configArgs}
4311
4329
  <integer>45</integer>
4312
4330
 
4313
4331
  <key>StandardOutPath</key>
4314
- <string>${join(homedir(), ".neurolink", "logs", "proxy-launchd-stdout.log")}</string>
4332
+ <string>${isProxyOtelOnly() ? "/dev/null" : join(homedir(), ".neurolink", "logs", "proxy-launchd-stdout.log")}</string>
4315
4333
 
4316
4334
  <key>StandardErrorPath</key>
4317
- <string>${join(homedir(), ".neurolink", "logs", "proxy-launchd-stderr.log")}</string>
4335
+ <string>${isProxyOtelOnly() ? "/dev/null" : join(homedir(), ".neurolink", "logs", "proxy-launchd-stderr.log")}</string>
4318
4336
 
4319
4337
  <key>EnvironmentVariables</key>
4320
4338
  <dict>
@@ -4322,6 +4340,7 @@ ${configArgs}
4322
4340
  <string>${buildLaunchdPath()}</string>
4323
4341
  <key>HOME</key>
4324
4342
  <string>${homedir()}</string>
4343
+ ${otelEnvironment}
4325
4344
  </dict>
4326
4345
  </dict>
4327
4346
  </plist>`;
@@ -4362,7 +4381,7 @@ export const proxyInstallCommand = {
4362
4381
  console.info(chalk.yellow("On Linux, use systemd. On Windows, use Task Scheduler."));
4363
4382
  process.exit(1);
4364
4383
  }
4365
- const { writeFileSync, mkdirSync, existsSync } = await import("fs");
4384
+ const { mkdirSync, existsSync, chmodSync } = await import("fs");
4366
4385
  const envResolution = resolveProxyEnvFile({
4367
4386
  explicitEnvFile: argv.envFile,
4368
4387
  });
@@ -4378,8 +4397,9 @@ export const proxyInstallCommand = {
4378
4397
  console.info(chalk.red(`Proxy env file not found: ${envFile}`));
4379
4398
  process.exit(1);
4380
4399
  }
4400
+ await loadProxyEnvFile({ explicitEnvFile: envFile });
4381
4401
  const logsDir = join(homedir(), ".neurolink", "logs");
4382
- if (!existsSync(logsDir)) {
4402
+ if (!isProxyOtelOnly() && !existsSync(logsDir)) {
4383
4403
  mkdirSync(logsDir, { recursive: true });
4384
4404
  }
4385
4405
  if (!existsSync(PLIST_DIR)) {
@@ -4403,8 +4423,9 @@ export const proxyInstallCommand = {
4403
4423
  process.exit(1);
4404
4424
  }
4405
4425
  console.info(chalk.green(`✓ Trampoline validated (resolves to neurolink v${trampolineVersion})`));
4406
- const plist = buildPlist(port, host, envFile, configFile);
4407
- writeFileSync(PLIST_PATH, plist, "utf-8");
4426
+ const plist = buildProxyLaunchdPlist(port, host, envFile, configFile);
4427
+ await writeFileAtomic(PLIST_PATH, plist, 0o600);
4428
+ chmodSync(PLIST_PATH, 0o600);
4408
4429
  console.info(chalk.green(`✓ Plist written to ${PLIST_PATH}`));
4409
4430
  if (envFile) {
4410
4431
  console.info(chalk.green(`✓ Proxy env file: ${envFile}`));
package/dist/neurolink.js CHANGED
@@ -4159,6 +4159,22 @@ Current user's request: ${currentInput}`;
4159
4159
  middleware: options.middleware,
4160
4160
  conversationMessages: options.conversationMessages,
4161
4161
  credentials: options.credentials,
4162
+ // Extended thinking. Every provider gates it on `thinkingConfig`, so
4163
+ // omitting the field here meant it never reached one: the request went
4164
+ // out with no thinking block and the result carried no reasoning, with
4165
+ // nothing raised to say the option had been discarded. This is the same
4166
+ // failure the note above records for `disableInternalFallback` — an
4167
+ // allowlist that silently swallows a documented option.
4168
+ //
4169
+ // `thinkingConfig` is the only one of the documented thinking options
4170
+ // that `GenerateOptions` actually declares. `thinking`, `thinkingBudget`
4171
+ // and `thinkingLevel` are folded into a `thinkingConfig` only by the CLI,
4172
+ // in `src/lib/utils/thinkingConfig.ts`; nothing on the SDK path does that
4173
+ // merge. They exist solely on the internal `TextGenerationOptions`, so no
4174
+ // caller can pass them through
4175
+ // generate() today. Declaring them is a public-type decision and is
4176
+ // deliberately left out of this fix.
4177
+ thinkingConfig: options.thinkingConfig,
4162
4178
  // Lifecycle callbacks must reach the provider so non-AI-SDK paths
4163
4179
  // (Vertex's native @google/genai, native Bedrock, Ollama, etc.) can
4164
4180
  // invoke them directly. Pipeline A also still receives them via the
@@ -292,6 +292,12 @@ const messagesToAnthropic = (msgs) => {
292
292
  }
293
293
  case "assistant": {
294
294
  const blocks = [];
295
+ // Extended thinking must come back byte-identical — signature
296
+ // included — or Anthropic rejects the turn, and the loop replays this
297
+ // message on every tool step. Blocks are emitted in content order
298
+ // rather than hoisted: `interleaved-thinking-2025-05-14` (requested in
299
+ // the beta header) lets thinking appear between tool calls, so
300
+ // reordering would corrupt the chain it validates.
295
301
  for (const part of partsOf(msg.content)) {
296
302
  if (typeof part === "string") {
297
303
  if (part.length > 0) {
@@ -300,6 +306,29 @@ const messagesToAnthropic = (msgs) => {
300
306
  continue;
301
307
  }
302
308
  const p = part;
309
+ if (p?.type === "reasoning") {
310
+ const meta = p.providerOptions?.anthropic;
311
+ const redacted = meta?.redactedData;
312
+ if (typeof redacted === "string" && redacted.length > 0) {
313
+ blocks.push({ type: "redacted_thinking", data: redacted });
314
+ continue;
315
+ }
316
+ const signature = meta?.signature;
317
+ // Both halves required, matching loopAdapter's check on the
318
+ // streaming path: Anthropic rejects a thinking block that is
319
+ // unsigned, and equally one whose text is empty. Reasoning from a
320
+ // provider that never produced a signature (a reasoner model's
321
+ // plain text) is not an Anthropic thinking block at all, and an
322
+ // empty one carries nothing worth replaying — either way, dropping
323
+ // it beats sending a block that will be refused.
324
+ if (typeof signature === "string" &&
325
+ signature.length > 0 &&
326
+ typeof p.text === "string" &&
327
+ p.text.length > 0) {
328
+ blocks.push({ type: "thinking", thinking: p.text, signature });
329
+ }
330
+ continue;
331
+ }
303
332
  if (p?.type === "text" && typeof p.text === "string") {
304
333
  if (p.text.length > 0) {
305
334
  const cc = cacheControlOf(p);
@@ -1118,15 +1147,28 @@ export class AnthropicProvider extends BaseProvider {
1118
1147
  ? { topP: options.topP }
1119
1148
  : {}),
1120
1149
  }, "anthropic.doGenerate");
1150
+ // Dropping a caller's explicit sampling parameters is exactly the
1151
+ // kind of silent discard this change fixes elsewhere, so say so.
1152
+ if (thinking &&
1153
+ (samplingParams.temperature !== undefined ||
1154
+ samplingParams.topP !== undefined)) {
1155
+ logger.debug("[anthropic] extended thinking is enabled, so temperature/top_p are omitted — Anthropic rejects any temperature but 1 while thinking is set");
1156
+ }
1121
1157
  const params = {
1122
1158
  model: modelId,
1123
1159
  messages: cachedMessages,
1124
1160
  max_tokens: resolveClaudeMaxTokens(modelId, options.maxOutputTokens),
1125
1161
  ...(system ? { system } : {}),
1126
- ...(samplingParams.temperature !== undefined
1162
+ // Extended thinking fixes sampling: Anthropic rejects any
1163
+ // temperature but 1 while `thinking` is set, and does not honour
1164
+ // top_p there. The CLI always sends a default temperature, so
1165
+ // forwarding it alongside thinking turns a call that used to work
1166
+ // into a 400. Drop the sampling knobs for exactly those turns and
1167
+ // let Anthropic's thinking defaults stand.
1168
+ ...(!thinking && samplingParams.temperature !== undefined
1127
1169
  ? { temperature: samplingParams.temperature }
1128
1170
  : {}),
1129
- ...(samplingParams.topP !== undefined
1171
+ ...(!thinking && samplingParams.topP !== undefined
1130
1172
  ? { top_p: samplingParams.topP }
1131
1173
  : {}),
1132
1174
  ...(options.stopSequences && options.stopSequences.length > 0
@@ -1187,7 +1229,29 @@ export class AnthropicProvider extends BaseProvider {
1187
1229
  let jsonToolAnswered = false;
1188
1230
  for (const block of response.content) {
1189
1231
  if (block.type === "thinking") {
1190
- content.push({ type: "reasoning", text: block.thinking });
1232
+ // The signature rides along in providerOptions because Anthropic
1233
+ // rejects a replayed thinking block without it, and the tool loop
1234
+ // pushes this part straight back into the conversation.
1235
+ content.push({
1236
+ type: "reasoning",
1237
+ text: block.thinking,
1238
+ providerOptions: {
1239
+ anthropic: { signature: block.signature },
1240
+ },
1241
+ });
1242
+ }
1243
+ else if (block.type === "redacted_thinking") {
1244
+ // Encrypted reasoning: no readable text, but it must still be
1245
+ // replayed verbatim or the turn is rejected.
1246
+ content.push({
1247
+ type: "reasoning",
1248
+ text: "",
1249
+ providerOptions: {
1250
+ anthropic: {
1251
+ redactedData: block.data,
1252
+ },
1253
+ },
1254
+ });
1191
1255
  }
1192
1256
  else if (block.type === "text") {
1193
1257
  // In forced-json mode the payload arrives via the tool input, not
@@ -1824,6 +1888,9 @@ export class AnthropicProvider extends BaseProvider {
1824
1888
  const streamSamplingParams = resolveSamplingParams("anthropic", modelId, options.temperature !== undefined && options.temperature !== null
1825
1889
  ? { temperature: options.temperature }
1826
1890
  : {}, "anthropic.executeStream");
1891
+ if (thinking && streamSamplingParams.temperature !== undefined) {
1892
+ logger.debug("[anthropic] extended thinking is enabled, so temperature is omitted on the stream path — Anthropic rejects any temperature but 1 while thinking is set");
1893
+ }
1827
1894
  return {
1828
1895
  model: modelId,
1829
1896
  messages: cachedConversation,
@@ -1833,7 +1900,9 @@ export class AnthropicProvider extends BaseProvider {
1833
1900
  // the adapter immediately overwrites — and forced this whole params
1834
1901
  // object into the streaming variant for a field it does not own.
1835
1902
  ...(payload.system ? { system: payload.system } : {}),
1836
- ...(streamSamplingParams.temperature !== undefined
1903
+ // Same constraint on the streaming path: a temperature alongside
1904
+ // `thinking` is rejected outright.
1905
+ ...(!thinking && streamSamplingParams.temperature !== undefined
1837
1906
  ? { temperature: streamSamplingParams.temperature }
1838
1907
  : {}),
1839
1908
  ...(cachedTools && cachedTools.length > 0
@@ -19,4 +19,4 @@ export declare function redactProxyHeadersForLogging(headers: Record<string, str
19
19
  * Process a capture in the worker and retain redacted output when artifact
20
20
  * persistence fails.
21
21
  */
22
- export declare function processProxyBodyCapture(entry: ProxyBodyCaptureEntry, logDir: string): Promise<ProcessedProxyBodyCapture>;
22
+ export declare function processProxyBodyCapture(entry: ProxyBodyCaptureEntry, logDir: string | null): Promise<ProcessedProxyBodyCapture>;
@@ -203,6 +203,16 @@ export function redactProxyHeadersForLogging(headers) {
203
203
  export async function processProxyBodyCapture(entry, logDir) {
204
204
  const headers = redactHeaders(entry.headers);
205
205
  const prepared = prepareRedactedBody(entry.body);
206
+ if (logDir === null) {
207
+ return {
208
+ headers,
209
+ stored: {
210
+ redactedBody: prepared.value,
211
+ redactedBodyBytes: prepared.bytes,
212
+ bodyTruncated: prepared.truncated,
213
+ },
214
+ };
215
+ }
206
216
  let stored;
207
217
  try {
208
218
  stored = await writeBodyArtifact(logDir, entry, headers, prepared.value, prepared.truncated);
@@ -1,7 +1,7 @@
1
1
  import type { ProcessedProxyBodyCapture, ProxyBodyCaptureEntry, ProxyBodyCaptureWorkerSnapshot } from "../types/index.js";
2
2
  export declare const PROXY_BODY_CAPTURE_DEADLINE_MS = 20000;
3
3
  /** Bounded bulk capture. Failures are indexed; never fall back to blocking work. */
4
- export declare function captureProxyBody(entry: ProxyBodyCaptureEntry, logDir: string, consume: (result: ProcessedProxyBodyCapture) => Promise<void>): Promise<void>;
4
+ export declare function captureProxyBody(entry: ProxyBodyCaptureEntry, logDir: string | null, consume: (result: ProcessedProxyBodyCapture) => Promise<void>): Promise<void>;
5
5
  /**
6
6
  * Return independent counters for processing, rejection, failure, and
7
7
  * retained publication work.
@@ -1,3 +1,4 @@
1
+ import { isProxyOtelOnly } from "./otelLogSink.js";
1
2
  import { Worker } from "node:worker_threads";
2
3
  import { withTimeout } from "../utils/async/withTimeout.js";
3
4
  import { logger } from "../utils/logger.js";
@@ -9,6 +10,9 @@ const WORKER_TERMINATION_TIMEOUT_MS = 5_000;
9
10
  * block active streams. Concurrent runs are coalesced into the active scan.
10
11
  */
11
12
  export function startProxyLogCleanupScheduler(params) {
13
+ if (isProxyOtelOnly()) {
14
+ return { trigger: () => false, stop: async () => { } };
15
+ }
12
16
  const maxAgeDays = params.maxAgeDays ?? 7;
13
17
  const maxSizeMb = params.maxSizeMb ?? 500;
14
18
  let activeWorker;
@@ -0,0 +1,34 @@
1
+ import { LoggerProvider } from "@opentelemetry/sdk-logs";
2
+ /** Explicit opt-in; configuration never silently falls back to file logging. */
3
+ export declare function isProxyOtelOnly(): boolean;
4
+ /** Initialize a log-only provider in every proxy process, including the supervisor. */
5
+ export declare function initializeProxyOtelLogs(role?: string): LoggerProvider | undefined;
6
+ /** Structured evidence without final-request dashboard fields on auxiliary events. */
7
+ export declare function emitProxyOtelEvent(kind: string, record: Record<string, unknown>): void;
8
+ /** Capture application console diagnostics only inside proxy service processes. */
9
+ export declare function routeProxyConsoleToOtel(): void;
10
+ /** Counters acknowledge collector transport only, never backend persistence. */
11
+ export declare function getProxyOtelLogSnapshot(): {
12
+ mode: string;
13
+ initialized: boolean;
14
+ deliveryGuarantee: string;
15
+ invalidRecords: number;
16
+ queues: {
17
+ attempted: number;
18
+ submitted: number;
19
+ transportAcknowledged: number;
20
+ exportUnconfirmed: number;
21
+ dropped: number;
22
+ outstanding: number;
23
+ lastAcknowledgedAt: string | undefined;
24
+ lastFailureAt: string | undefined;
25
+ kind: string;
26
+ capacity: number;
27
+ }[];
28
+ };
29
+ /** Bounded provider flush belongs after final request and lifecycle publication. */
30
+ export declare function flushProxyOtelLogs(): Promise<void>;
31
+ /** Release this process's exporter and restore console ownership. */
32
+ export declare function shutdownProxyOtelLogs(): Promise<void>;
33
+ /** Flush short-lived proxy command diagnostics on every normal return or exception. */
34
+ export declare function withProxyOtelLogShutdown<TArg>(handler: (arg: TArg) => Promise<void>): (arg: TArg) => Promise<void>;
@@ -0,0 +1,254 @@
1
+ /* eslint-disable no-console -- This proxy-only sink replaces console methods with OTLP emission. */
2
+ import { inspect } from "node:util";
3
+ import { SeverityNumber } from "@opentelemetry/api-logs";
4
+ import { ExportResultCode } from "@opentelemetry/core";
5
+ import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
6
+ import { resourceFromAttributes } from "@opentelemetry/resources";
7
+ import { BatchLogRecordProcessor, LoggerProvider, } from "@opentelemetry/sdk-logs";
8
+ import { sanitizeForLog } from "../utils/logSanitize.js";
9
+ let provider;
10
+ let restoreConsole;
11
+ const queues = [];
12
+ /** Explicit opt-in; configuration never silently falls back to file logging. */
13
+ export function isProxyOtelOnly() {
14
+ return process.env.NEUROLINK_PROXY_LOG_SINK === "otel";
15
+ }
16
+ /** Reserve capacity including exports in flight, independently for metadata and bodies. */
17
+ function createTrackedProcessor(url, capacity) {
18
+ const state = {
19
+ attempted: 0,
20
+ submitted: 0,
21
+ transportAcknowledged: 0,
22
+ exportUnconfirmed: 0,
23
+ dropped: 0,
24
+ outstanding: 0,
25
+ lastAcknowledgedAt: undefined,
26
+ lastFailureAt: undefined,
27
+ };
28
+ const transport = new OTLPLogExporter({ url, timeoutMillis: 5000 });
29
+ const exporter = {
30
+ export(records, callback) {
31
+ let settled = false;
32
+ const settle = (result) => {
33
+ if (settled) {
34
+ return;
35
+ }
36
+ settled = true;
37
+ state.outstanding -= records.length;
38
+ if (result.code === ExportResultCode.SUCCESS) {
39
+ state.transportAcknowledged += records.length;
40
+ state.lastAcknowledgedAt = new Date().toISOString();
41
+ }
42
+ else {
43
+ state.exportUnconfirmed += records.length;
44
+ state.lastFailureAt = new Date().toISOString();
45
+ }
46
+ callback(result);
47
+ };
48
+ try {
49
+ transport.export(records, settle);
50
+ }
51
+ catch (error) {
52
+ settle({
53
+ code: ExportResultCode.FAILED,
54
+ error: error instanceof Error ? error : new Error(String(error)),
55
+ });
56
+ }
57
+ },
58
+ shutdown: () => transport.shutdown(),
59
+ };
60
+ const batch = new BatchLogRecordProcessor(exporter, {
61
+ maxQueueSize: capacity,
62
+ maxExportBatchSize: 64,
63
+ scheduledDelayMillis: 1000,
64
+ exportTimeoutMillis: 6000,
65
+ });
66
+ const processor = {
67
+ onEmit(record) {
68
+ state.attempted++;
69
+ if (state.outstanding >= capacity) {
70
+ state.dropped++;
71
+ return;
72
+ }
73
+ state.submitted++;
74
+ state.outstanding++;
75
+ batch.onEmit(record);
76
+ },
77
+ forceFlush: () => batch.forceFlush(),
78
+ shutdown: () => batch.shutdown(),
79
+ };
80
+ return { state, processor, capacity };
81
+ }
82
+ /** Initialize a log-only provider in every proxy process, including the supervisor. */
83
+ export function initializeProxyOtelLogs(role = "worker") {
84
+ if (!isProxyOtelOnly()) {
85
+ return undefined;
86
+ }
87
+ if (provider) {
88
+ return provider;
89
+ }
90
+ const endpoint = process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ??
91
+ (process.env.OTEL_EXPORTER_OTLP_ENDPOINT
92
+ ? `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT.replace(/\/$/, "")}/v1/logs`
93
+ : undefined);
94
+ if (!endpoint) {
95
+ throw new Error("OTel-only proxy logging requires an OTLP endpoint");
96
+ }
97
+ const url = new URL(endpoint);
98
+ if (!["http:", "https:"].includes(url.protocol)) {
99
+ throw new Error("Proxy OTLP logs endpoint must use HTTP or HTTPS");
100
+ }
101
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
102
+ if (url.protocol === "http:" && !loopback) {
103
+ throw new Error("Proxy OTLP logs require HTTPS for non-loopback collectors");
104
+ }
105
+ const metadata = createTrackedProcessor(endpoint, 2048);
106
+ const bodies = createTrackedProcessor(endpoint, 256);
107
+ queues.push(metadata, bodies);
108
+ provider = new LoggerProvider({
109
+ resource: resourceFromAttributes({
110
+ "service.name": process.env.OTEL_SERVICE_NAME ?? "neurolink-proxy",
111
+ "service.instance.id": `${role}-${process.pid}`,
112
+ "process.pid": process.pid,
113
+ "proxy.process.role": role,
114
+ }),
115
+ processors: [
116
+ {
117
+ onEmit(record, context) {
118
+ (record.attributes?.["proxy.record_kind"] === "body"
119
+ ? bodies
120
+ : metadata).processor.onEmit(record, context);
121
+ },
122
+ forceFlush: async () => {
123
+ await Promise.all(queues.map((q) => q.processor.forceFlush()));
124
+ },
125
+ shutdown: async () => {
126
+ await Promise.all(queues.map((q) => q.processor.shutdown()));
127
+ },
128
+ },
129
+ ],
130
+ });
131
+ return provider;
132
+ }
133
+ /** Structured evidence without final-request dashboard fields on auxiliary events. */
134
+ export function emitProxyOtelEvent(kind, record) {
135
+ if (!isProxyOtelOnly()) {
136
+ return;
137
+ }
138
+ try {
139
+ initializeProxyOtelLogs()
140
+ ?.getLogger("neurolink-proxy-events")
141
+ .emit({
142
+ severityNumber: SeverityNumber.INFO,
143
+ severityText: "INFO",
144
+ body: JSON.stringify(record),
145
+ attributes: {
146
+ "proxy.record_kind": kind,
147
+ "event.name": `proxy.${kind}`,
148
+ ...(typeof record.requestId === "string"
149
+ ? { "request.id": record.requestId }
150
+ : {}),
151
+ ...(typeof record.event === "string"
152
+ ? { "proxy.lifecycle.event": record.event }
153
+ : {}),
154
+ },
155
+ });
156
+ }
157
+ catch {
158
+ // Telemetry must never fail a model request. Invalid records are observable.
159
+ invalidRecords++;
160
+ }
161
+ }
162
+ let invalidRecords = 0;
163
+ /** Capture application console diagnostics only inside proxy service processes. */
164
+ export function routeProxyConsoleToOtel() {
165
+ if (!isProxyOtelOnly() || restoreConsole) {
166
+ return;
167
+ }
168
+ initializeProxyOtelLogs();
169
+ const originals = {
170
+ log: console.log,
171
+ info: console.info,
172
+ warn: console.warn,
173
+ error: console.error,
174
+ debug: console.debug,
175
+ };
176
+ let emitting = false;
177
+ for (const level of Object.keys(originals)) {
178
+ console[level] = (...args) => {
179
+ if (emitting) {
180
+ return;
181
+ }
182
+ emitting = true;
183
+ try {
184
+ const body = args
185
+ .map((value) => typeof value === "string"
186
+ ? value
187
+ : inspect(value, {
188
+ depth: 4,
189
+ maxArrayLength: 30,
190
+ maxStringLength: 16000,
191
+ }))
192
+ .join(" ");
193
+ provider?.getLogger("neurolink-proxy-console").emit({
194
+ body: sanitizeForLog(body, 32000),
195
+ severityText: level.toUpperCase(),
196
+ severityNumber: level === "error"
197
+ ? SeverityNumber.ERROR
198
+ : level === "warn"
199
+ ? SeverityNumber.WARN
200
+ : level === "debug"
201
+ ? SeverityNumber.DEBUG
202
+ : SeverityNumber.INFO,
203
+ attributes: { "proxy.record_kind": "console" },
204
+ });
205
+ }
206
+ catch {
207
+ invalidRecords++;
208
+ }
209
+ finally {
210
+ emitting = false;
211
+ }
212
+ };
213
+ }
214
+ restoreConsole = () => Object.assign(console, originals);
215
+ }
216
+ /** Counters acknowledge collector transport only, never backend persistence. */
217
+ export function getProxyOtelLogSnapshot() {
218
+ return {
219
+ mode: isProxyOtelOnly() ? "otel" : "file-and-otel",
220
+ initialized: provider !== undefined,
221
+ deliveryGuarantee: "best-effort; HTTP success is not per-record acceptance or backend persistence",
222
+ invalidRecords,
223
+ queues: queues.map((q, index) => ({
224
+ kind: index === 0 ? "metadata" : "bodies",
225
+ capacity: q.capacity,
226
+ ...q.state,
227
+ })),
228
+ };
229
+ }
230
+ /** Bounded provider flush belongs after final request and lifecycle publication. */
231
+ export async function flushProxyOtelLogs() {
232
+ await provider?.forceFlush();
233
+ }
234
+ /** Release this process's exporter and restore console ownership. */
235
+ export async function shutdownProxyOtelLogs() {
236
+ restoreConsole?.();
237
+ restoreConsole = undefined;
238
+ await provider?.shutdown();
239
+ provider = undefined;
240
+ queues.length = 0;
241
+ invalidRecords = 0;
242
+ }
243
+ /** Flush short-lived proxy command diagnostics on every normal return or exception. */
244
+ export function withProxyOtelLogShutdown(handler) {
245
+ return async (arg) => {
246
+ try {
247
+ await handler(arg);
248
+ }
249
+ finally {
250
+ await flushProxyOtelLogs().catch(() => undefined);
251
+ await shutdownProxyOtelLogs().catch(() => undefined);
252
+ }
253
+ };
254
+ }